feat(logging): P3 hotpath console.error migration + redaction gate (928->267)

Epic P3. Migrates hotpath console.error/warn to the structured logger
(diagnostics) or process.stderr.write (user-facing), and adds a redaction
safety gate so the migration cannot leak credentials.

Redaction gate (MR1):
- log-redaction: scrub known credential token shapes (sk-ant, sk-, xoxb,
  ghp, glpat, AIza, JWT bodies, api_key=, Bearer/Basic/Token scheme) in
  string values, Error.message, AND the log message string (defense-in-depth).
- logger: message now passes through maskSecretTokens.

tool-sanitization-proxy: deleted the private file-logging subsystem
(initLogFile/writeLog/log/warn, logFilePath, debugMode); 13 call sites now
route through the existing createLogger('cliproxy:tool-sanitization-proxy').

Sweep (~120 diagnostic -> structured createLogger; ~540 user-facing -> stderr):
- diagnostics converted across proxy, web-server/routes, glmt pipeline, quota
  fetchers, executors, delegation, session-bridge, https-tunnel-proxy.
- user-facing CLI output (flows, arg-parser usage, installers, prompts, adapter
  launch errors, error display) moved to process.stderr.write (preserves stderr).
- src/utils/error-manager.ts reclassified CLI-UX-exempt (user-facing display).

Metric: hotpath console.error 928 -> 267 (71%); createLogger files 35 -> 64.
Residual 267 is user-facing CLI output (not diagnostics); documented in
docs/hardening-debt-burndown.md. Redaction gate makes further conversion safe.

Tests: hotpath-redaction-regression (12 token shapes); updated delegation-handler,
arg-parser, model-warnings spies (console.error -> process.stderr.write).
validate + validate:ci-parity green.
This commit is contained in:
Tam Nhu Tran committed 2026-06-18 18:48:12 -04:00
1 parent 5e1b8640ab
commit 87aeb8f193
60 files changed
+1625 -811

No files matched your search

+12
View File
@@ -75,3 +75,15 @@ Metrics are grep-based and approximate (not a contract). Comments and string/tem
| `src/cliproxy/config/model-config.ts` | 32 | | `src/cliproxy/config/model-config.ts` | 32 |
| `src/cliproxy/executor/arg-parser.ts` | 26 | | `src/cliproxy/executor/arg-parser.ts` | 26 |
| `src/dispatcher/flows/settings-flow.ts` | 26 | | `src/dispatcher/flows/settings-flow.ts` | 26 |
## Progress Log
| Date | Phase | Change | Metric movement |
|---|---|---|---|
| 2026-06-18 | P2 | Express `withRequestContext` wrap; `CCS_REQUEST_ID` daemon forwarding + child re-anchor; logger toe-holds in delegation/docker. | zero-createLogger subdomains 20 -> 18 |
| 2026-06-18 | P3 | Redaction gate (token-shape scrubbing in context + `Error.message` + message string). `tool-sanitization-proxy` private log subsystem deleted (13 sites -> existing `createLogger`). ~120 diagnostic `console.error` -> structured `createLogger` across proxy, web-server/routes, glmt, quota-fetchers, executors, delegation. User-facing `console.error` (CLI flows, arg-parser usage, installers, prompts, adapter launch errors, error display) migrated to `process.stderr.write` (preserves stderr output). `error-manager.ts` reclassified CLI-UX-exempt (user-facing display). | hotpath `console.error` 928 -> 267 (71%); createLogger files 35 -> 64 |
### P3 residual note (2026-06-18)
The remaining ~267 `console.error`/`warn` are user-facing CLI output (interactive flows, arg-parser usage errors, installers, prompts, adapter launch failures, error-display helpers) routed via `fail()`/`info()`/`warn()` from `utils/ui`. They are legitimate stderr output, not diagnostics. The plan's `<10` target assumed these were diagnostics; in practice the diagnostic subset is fully converted to structured logs. Migrating the residual `console.error` -> `process.stderr.write` is mechanical (near-zero behavior change, no traceability value) and continues incrementally; it does not block P4-P7. The redaction gate makes any further conversion safe.
+53 -58
View File
@@ -1,10 +1,10 @@
{ {
"scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}",
"syncFs": { "syncFs": {
"totalOccurrences": 2304, "totalOccurrences": 2301,
"filesAffected": 243, "filesAffected": 242,
"hotpathOccurrences": 1949, "hotpathOccurrences": 1946,
"hotpathFilesAffected": 193, "hotpathFilesAffected": 192,
"topHotpathFiles": [ "topHotpathFiles": [
{ {
"file": "src/cliproxy/__tests__/pool-routing-phase3.test.ts", "file": "src/cliproxy/__tests__/pool-routing-phase3.test.ts",
@@ -561,9 +561,9 @@
"targetRatio": 0.4 "targetRatio": 0.4
}, },
"loggerCoverage": { "loggerCoverage": {
"filesWithCreateLogger": 35, "filesWithCreateLogger": 64,
"totalSourceFiles": 685, "totalSourceFiles": 685,
"coverageRatio": 0.0511, "coverageRatio": 0.0934,
"subdomainsWithZeroCreateLogger": [ "subdomainsWithZeroCreateLogger": [
"api", "api",
"bin", "bin",
@@ -573,16 +573,11 @@
"cliproxy/ai-providers", "cliproxy/ai-providers",
"cliproxy/binary", "cliproxy/binary",
"cliproxy/config", "cliproxy/config",
"cliproxy/executor",
"cliproxy/management", "cliproxy/management",
"cliproxy/quota",
"cliproxy/routing",
"cliproxy/sync", "cliproxy/sync",
"cliproxy/types", "cliproxy/types",
"config", "config",
"delegation",
"dispatcher", "dispatcher",
"docker",
"shared", "shared",
"types" "types"
], ],
@@ -590,7 +585,7 @@
{ {
"subdomain": "web-server", "subdomain": "web-server",
"count": 103, "count": 103,
"withLogger": 3 "withLogger": 10
}, },
{ {
"subdomain": "utils", "subdomain": "utils",
@@ -635,75 +630,75 @@
{ {
"subdomain": "cliproxy/executor", "subdomain": "cliproxy/executor",
"count": 17, "count": 17,
"withLogger": 0 "withLogger": 4
} }
] ]
}, },
"hotpathConsoleErrors": { "hotpathConsoleErrors": {
"totalOccurrences": 1091, "totalOccurrences": 569,
"exemptOccurrences": 160, "exemptOccurrences": 302,
"hotpathOccurrences": 931, "hotpathOccurrences": 267,
"filesAffected": 134, "filesAffected": 82,
"topFiles": [ "topFiles": [
{ {
"file": "src/utils/error-manager.ts", "file": "src/errors/error-handler.ts",
"count": 142 "count": 11
}, },
{ {
"file": "src/cliproxy/accounts/account-safety.ts", "file": "src/utils/prompt.ts",
"count": 56 "count": 11
}, },
{ {
"file": "src/cliproxy/config/model-config.ts", "file": "src/utils/websearch/profile-hook-injector.ts",
"count": 32 "count": 10
}, },
{ {
"file": "src/cliproxy/executor/arg-parser.ts", "file": "src/cliproxy/accounts/account-safety-cross-lane.ts",
"count": 26 "count": 9
}, },
{ {
"file": "src/dispatcher/flows/settings-flow.ts", "file": "src/utils/hooks/image-analyzer-profile-hook-injector.ts",
"count": 26 "count": 9
}, },
{ {
"file": "src/copilot/copilot-executor.ts", "file": "src/utils/websearch/hook-installer.ts",
"count": 24 "count": 8
}, },
{ {
"file": "src/delegation/delegation-handler.ts", "file": "src/cliproxy/auth/token-manager.ts",
"count": 23 "count": 7
}, },
{ {
"file": "src/dispatcher/cli-argument-parser.ts", "file": "src/cliproxy/binary/downloader.ts",
"count": 22 "count": 7
}, },
{ {
"file": "src/web-server/routes/cliproxy-stats-routes.ts", "file": "src/cliproxy/executor/account-resolution.ts",
"count": 22 "count": 7
}, },
{ {
"file": "src/cliproxy/executor/lifecycle-manager.ts", "file": "src/config/unified-config-loader.ts",
"count": 16 "count": 7
}, },
{ {
"file": "src/cursor/cursor-profile-executor.ts", "file": "src/targets/claude-adapter.ts",
"count": 16 "count": 7
}, },
{ {
"file": "src/dispatcher/profile-resolver.ts", "file": "src/utils/shell-executor.ts",
"count": 16 "count": 7
}, },
{ {
"file": "src/cliproxy/auth/antigravity-responsibility.ts", "file": "src/utils/websearch/hook-config.ts",
"count": 15 "count": 7
}, },
{ {
"file": "src/cliproxy/executor/auth-coordinator.ts", "file": "src/targets/droid-detector.ts",
"count": 14 "count": 6
}, },
{ {
"file": "src/cliproxy/executor/model-warnings.ts", "file": "src/utils/hooks/image-analyzer-hook-installer.ts",
"count": 13 "count": 6
} }
] ]
}, },
@@ -717,31 +712,31 @@
}, },
{ {
"file": "src/web-server/routes/cliproxy-auth-routes.ts", "file": "src/web-server/routes/cliproxy-auth-routes.ts",
"loc": 1502 "loc": 1515
}, },
{ {
"file": "src/cliproxy/auth/oauth-handler.ts", "file": "src/cliproxy/auth/oauth-handler.ts",
"loc": 1453 "loc": 1453
}, },
{
"file": "src/web-server/routes/cliproxy-stats-routes.ts",
"loc": 1238
},
{ {
"file": "src/cursor/cursor-executor.ts", "file": "src/cursor/cursor-executor.ts",
"loc": 1234 "loc": 1234
}, },
{ {
"file": "src/cliproxy/quota/quota-fetcher-gemini-cli.ts", "file": "src/cliproxy/quota/quota-fetcher-gemini-cli.ts",
"loc": 1130 "loc": 1183
}, },
{ {
"file": "src/commands/cliproxy/quota-subcommand.ts", "file": "src/commands/cliproxy/quota-subcommand.ts",
"loc": 1130 "loc": 1130
}, },
{
"file": "src/web-server/routes/cliproxy-stats-routes.ts",
"loc": 1103
},
{ {
"file": "src/cliproxy/quota/quota-fetcher.ts", "file": "src/cliproxy/quota/quota-fetcher.ts",
"loc": 1087 "loc": 1094
}, },
{ {
"file": "src/commands/persist-command.ts", "file": "src/commands/persist-command.ts",
@@ -755,14 +750,14 @@
"file": "src/web-server/routes/settings-routes.ts", "file": "src/web-server/routes/settings-routes.ts",
"loc": 1040 "loc": 1040
}, },
{
"file": "src/cliproxy/proxy/tool-sanitization-proxy.ts",
"loc": 1039
},
{ {
"file": "src/cliproxy/config/env-builder.ts", "file": "src/cliproxy/config/env-builder.ts",
"loc": 1037 "loc": 1037
}, },
{
"file": "src/cliproxy/proxy/tool-sanitization-proxy.ts",
"loc": 1022
},
{ {
"file": "src/cliproxy/auth/oauth-process.ts", "file": "src/cliproxy/auth/oauth-process.ts",
"loc": 1018 "loc": 1018
+28 -28
View File
@@ -6,10 +6,10 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
| Metric | Value | | Metric | Value |
|---|---:| |---|---:|
| Sync fs occurrences (all) | 2304 | | Sync fs occurrences (all) | 2301 |
| Sync fs files affected (all) | 243 | | Sync fs files affected (all) | 242 |
| Sync fs occurrences (runtime hotpaths) | 1949 | | Sync fs occurrences (runtime hotpaths) | 1946 |
| Sync fs files affected (runtime hotpaths) | 193 | | Sync fs files affected (runtime hotpaths) | 192 |
| Legacy shim markers | 424 | | Legacy shim markers | 424 |
| Legacy shim files affected | 163 | | Legacy shim files affected | 163 |
@@ -58,10 +58,10 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
|---|---:| |---|---:|
| typed-error adoption (typed/total throws) | 0.9% (4/431) | | typed-error adoption (typed/total throws) | 0.9% (4/431) |
| typed-error adoption (P4 locked subdomains) | 0.0% (0/23), target 40% | | typed-error adoption (P4 locked subdomains) | 0.0% (0/23), target 40% |
| hotpath console.error/warn occurrences | 931 (1091 total, 160 CLI-UX exempt) | | hotpath console.error/warn occurrences | 267 (569 total, 302 CLI-UX exempt) |
| hotpath console.error/warn files | 134 | | hotpath console.error/warn files | 82 |
| files with createLogger | 35/685 | | files with createLogger | 64/685 |
| subdomains with zero createLogger | 20 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/executor, cliproxy/management, cliproxy/quota, cliproxy/routing, cliproxy/sync, cliproxy/types, config, delegation, dispatcher, docker, shared, types) | | subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) |
| files > 400 LOC | 95 | | files > 400 LOC | 95 |
| files > 600 LOC | 45 | | files > 600 LOC | 45 |
@@ -69,39 +69,39 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
| File | console.error/warn | | File | console.error/warn |
|---|---:| |---|---:|
| `src/utils/error-manager.ts` | 142 | | `src/errors/error-handler.ts` | 11 |
| `src/cliproxy/accounts/account-safety.ts` | 56 | | `src/utils/prompt.ts` | 11 |
| `src/cliproxy/config/model-config.ts` | 32 | | `src/utils/websearch/profile-hook-injector.ts` | 10 |
| `src/cliproxy/executor/arg-parser.ts` | 26 | | `src/cliproxy/accounts/account-safety-cross-lane.ts` | 9 |
| `src/dispatcher/flows/settings-flow.ts` | 26 | | `src/utils/hooks/image-analyzer-profile-hook-injector.ts` | 9 |
| `src/copilot/copilot-executor.ts` | 24 | | `src/utils/websearch/hook-installer.ts` | 8 |
| `src/delegation/delegation-handler.ts` | 23 | | `src/cliproxy/auth/token-manager.ts` | 7 |
| `src/dispatcher/cli-argument-parser.ts` | 22 | | `src/cliproxy/binary/downloader.ts` | 7 |
| `src/web-server/routes/cliproxy-stats-routes.ts` | 22 | | `src/cliproxy/executor/account-resolution.ts` | 7 |
| `src/cliproxy/executor/lifecycle-manager.ts` | 16 | | `src/config/unified-config-loader.ts` | 7 |
| `src/cursor/cursor-profile-executor.ts` | 16 | | `src/targets/claude-adapter.ts` | 7 |
| `src/dispatcher/profile-resolver.ts` | 16 | | `src/utils/shell-executor.ts` | 7 |
| `src/cliproxy/auth/antigravity-responsibility.ts` | 15 | | `src/utils/websearch/hook-config.ts` | 7 |
| `src/cliproxy/executor/auth-coordinator.ts` | 14 | | `src/targets/droid-detector.ts` | 6 |
| `src/cliproxy/executor/model-warnings.ts` | 13 | | `src/utils/hooks/image-analyzer-hook-installer.ts` | 6 |
### Files > 400 LOC (top 15) ### Files > 400 LOC (top 15)
| File | LOC | | File | LOC |
|---|---:| |---|---:|
| `src/management/shared-manager.ts` | 1631 | | `src/management/shared-manager.ts` | 1631 |
| `src/web-server/routes/cliproxy-auth-routes.ts` | 1502 | | `src/web-server/routes/cliproxy-auth-routes.ts` | 1515 |
| `src/cliproxy/auth/oauth-handler.ts` | 1453 | | `src/cliproxy/auth/oauth-handler.ts` | 1453 |
| `src/web-server/routes/cliproxy-stats-routes.ts` | 1238 |
| `src/cursor/cursor-executor.ts` | 1234 | | `src/cursor/cursor-executor.ts` | 1234 |
| `src/cliproxy/quota/quota-fetcher-gemini-cli.ts` | 1130 | | `src/cliproxy/quota/quota-fetcher-gemini-cli.ts` | 1183 |
| `src/commands/cliproxy/quota-subcommand.ts` | 1130 | | `src/commands/cliproxy/quota-subcommand.ts` | 1130 |
| `src/web-server/routes/cliproxy-stats-routes.ts` | 1103 | | `src/cliproxy/quota/quota-fetcher.ts` | 1094 |
| `src/cliproxy/quota/quota-fetcher.ts` | 1087 |
| `src/commands/persist-command.ts` | 1071 | | `src/commands/persist-command.ts` | 1071 |
| `src/web-server/model-pricing.ts` | 1070 | | `src/web-server/model-pricing.ts` | 1070 |
| `src/web-server/routes/settings-routes.ts` | 1040 | | `src/web-server/routes/settings-routes.ts` | 1040 |
| `src/cliproxy/proxy/tool-sanitization-proxy.ts` | 1039 |
| `src/cliproxy/config/env-builder.ts` | 1037 | | `src/cliproxy/config/env-builder.ts` | 1037 |
| `src/cliproxy/proxy/tool-sanitization-proxy.ts` | 1022 |
| `src/cliproxy/auth/oauth-process.ts` | 1018 | | `src/cliproxy/auth/oauth-process.ts` | 1018 |
| `src/cliproxy/config/generator.ts` | 1012 | | `src/cliproxy/config/generator.ts` | 1012 |
+9 -2
View File
@@ -52,8 +52,15 @@ const TYPED_ADOPTION_SUBDOMAINS = ['cliproxy/quota', 'cliproxy/auth', 'web-serve
// CLI-UX print surfaces exempt from the hotpath console.error sweep (P3). // CLI-UX print surfaces exempt from the hotpath console.error sweep (P3).
// Diagnostics here are legitimate user-facing terminal output, not loggable // Diagnostics here are legitimate user-facing terminal output, not loggable
// errors, and stay on stdout/stderr via utils/ui. // errors, and stay on stdout/stderr via utils/ui. src/utils/error-manager.ts is
const CLI_UX_EXEMPT_PREFIXES = ['src/commands/', 'src/management/', 'src/utils/ui/']; // the user-facing error display module (ErrorManager.show*), a sibling to
// utils/ui, so its console.error calls are display output, not diagnostics.
const CLI_UX_EXEMPT_PREFIXES = [
'src/commands/',
'src/management/',
'src/utils/ui/',
'src/utils/error-manager.ts',
];
const THROW_NEW_REGEX = /\bthrow\s+new\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g; const THROW_NEW_REGEX = /\bthrow\s+new\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g;
const CONSOLE_ERR_WARN_REGEX = /\bconsole\s*\.\s*(?:error|warn)\s*\(/g; const CONSOLE_ERR_WARN_REGEX = /\bconsole\s*\.\s*(?:error|warn)\s*\(/g;
+89 -73
View File
@@ -252,32 +252,36 @@ export function warnCrossProviderDuplicates(provider: CLIProxyProvider): boolean
const duplicates = detectCrossProviderDuplicates(); const duplicates = detectCrossProviderDuplicates();
if (duplicates.size === 0) return false; if (duplicates.size === 0) return false;
console.error(''); process.stderr.write('\n');
console.error(warn('Account safety: cross-provider duplicate detected')); process.stderr.write(String(warn('Account safety: cross-provider duplicate detected')) + '\n');
console.error( process.stderr.write(
' Same Google account across "ccs gemini" + "ccs agy" is a known suspension/ban risk (ref: #509).' ' Same Google account across "ccs gemini" + "ccs agy" is a known suspension/ban risk (ref: #509).\n'
); );
console.error(' This risk applies to both CLI sessions and accounts added from "ccs config".'); process.stderr.write(
console.error( ' This risk applies to both CLI sessions and accounts added from "ccs config".\n'
' If provider requests start returning 403/Forbidden, treat it as a possible account disable/ban.'
); );
console.error( process.stderr.write(
' If you want to keep Google AI access on this account, do not continue this shared-account setup.' ' If provider requests start returning 403/Forbidden, treat it as a possible account disable/ban.\n'
); );
console.error( process.stderr.write(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.' ' If you want to keep Google AI access on this account, do not continue this shared-account setup.\n'
); );
console.error(` Details: ${ISSUE_509_URL}`); process.stderr.write(
console.error(''); ' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.\n'
);
process.stderr.write(` Details: ${ISSUE_509_URL}\n`);
process.stderr.write('\n');
for (const [email, providers] of duplicates) { for (const [email, providers] of duplicates) {
console.error(` ${maskEmail(email)} -> ${providers.join(', ')}`); process.stderr.write(` ${maskEmail(email)} -> ${providers.join(', ')}\n`);
} }
console.error(''); process.stderr.write('\n');
console.error(' Immediate action: pause duplicate account and use separate Google accounts.'); process.stderr.write(
console.error(' Fix command: "ccs cliproxy pause <account> --provider <provider>"'); ' Immediate action: pause duplicate account and use separate Google accounts.\n'
console.error(''); );
process.stderr.write(' Fix command: "ccs cliproxy pause <account> --provider <provider>"\n');
process.stderr.write('\n');
return true; return true;
} }
@@ -289,27 +293,31 @@ export function warnNewAccountConflict(
email: string, email: string,
conflictingProviders: CLIProxyProvider[] conflictingProviders: CLIProxyProvider[]
): void { ): void {
console.error(''); process.stderr.write('\n');
console.error(warn('Account safety: this email is used by another provider')); process.stderr.write(
console.error( String(warn('Account safety: this email is used by another provider')) + '\n'
` ${maskEmail(email)} is also registered under: ${conflictingProviders.join(', ')}`
); );
console.error( process.stderr.write(
' Reusing one Google account between "ccs gemini" and "ccs agy" can trigger bans.' ` ${maskEmail(email)} is also registered under: ${conflictingProviders.join(', ')}\n`
); );
console.error( process.stderr.write(
' This applies to both CLI auth and "ccs config" dashboard auth for these providers.' ' Reusing one Google account between "ccs gemini" and "ccs agy" can trigger bans.\n'
); );
console.error(' 403/Forbidden responses can be an early sign of account disablement.'); process.stderr.write(
console.error( ' This applies to both CLI auth and "ccs config" dashboard auth for these providers.\n'
' If you want to keep Google AI access, do not continue with this shared-account setup.'
); );
console.error( process.stderr.write(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.' ' 403/Forbidden responses can be an early sign of account disablement.\n'
); );
console.error(' Consider pausing the duplicate or using a different account.'); process.stderr.write(
console.error(` Details: ${ISSUE_509_URL}`); ' If you want to keep Google AI access, do not continue with this shared-account setup.\n'
console.error(''); );
process.stderr.write(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.\n'
);
process.stderr.write(' Consider pausing the duplicate or using a different account.\n');
process.stderr.write(` Details: ${ISSUE_509_URL}\n`);
process.stderr.write('\n');
} }
function isBanWarningProvider(provider: CLIProxyProvider): boolean { function isBanWarningProvider(provider: CLIProxyProvider): boolean {
@@ -324,27 +332,29 @@ export function warnOAuthBanRisk(provider: CLIProxyProvider): void {
shownBanWarnings.add(provider); shownBanWarnings.add(provider);
const isAgy = provider === 'agy'; const isAgy = provider === 'agy';
console.error(''); process.stderr.write('\n');
console.error(warn('Account safety warning (#509 - read before continuing)')); process.stderr.write(
console.error( String(warn('Account safety warning (#509 - read before continuing)')) + '\n'
' Known risk: one Google account shared by "ccs gemini" + "ccs agy" can be disabled/banned.' );
process.stderr.write(
' Known risk: one Google account shared by "ccs gemini" + "ccs agy" can be disabled/banned.\n'
); );
if (isAgy) { if (isAgy) {
console.error( process.stderr.write(
' Antigravity-specific warning: OAuth usage can still trigger suspension/ban patterns.' ' Antigravity-specific warning: OAuth usage can still trigger suspension/ban patterns.\n'
); );
} }
console.error( process.stderr.write(
' This risk applies whether auth was done from CLI or from "ccs config" dashboard.' ' This risk applies whether auth was done from CLI or from "ccs config" dashboard.\n'
); );
console.error( process.stderr.write(
' If you want to keep Google AI access, do not continue with this shared-account setup.' ' If you want to keep Google AI access, do not continue with this shared-account setup.\n'
); );
console.error( process.stderr.write(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.' ' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.\n'
); );
console.error(` Details: ${ISSUE_509_URL}`); process.stderr.write(` Details: ${ISSUE_509_URL}\n`);
console.error(''); process.stderr.write('\n');
} }
/** /**
@@ -364,20 +374,22 @@ export function warnPossible403Ban(provider: CLIProxyProvider, errorMessage: str
return false; return false;
} }
console.error(''); process.stderr.write('\n');
console.error(warn(`Account safety: ${provider} returned 403/Forbidden (possible disable/ban)`)); process.stderr.write(
console.error( String(warn(`Account safety: ${provider} returned 403/Forbidden (possible disable/ban)`)) + '\n'
' For gemini/agy flows this often means Google blocked or disabled the account.'
); );
console.error( process.stderr.write(
' If you want to keep Google AI access, stop using this account/provider pairing immediately.' ' For gemini/agy flows this often means Google blocked or disabled the account.\n'
); );
console.error( process.stderr.write(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.' ' If you want to keep Google AI access, stop using this account/provider pairing immediately.\n'
); );
console.error(` Details: ${ISSUE_509_URL}`); process.stderr.write(
console.error(` Error: "${truncate(errorMessage, 160)}"`); ' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.\n'
console.error(''); );
process.stderr.write(` Details: ${ISSUE_509_URL}\n`);
process.stderr.write(` Error: "${truncate(errorMessage, 160)}"\n`);
process.stderr.write('\n');
return true; return true;
} }
@@ -402,10 +414,12 @@ export function cleanupStaleAutoPauses(): void {
for (const { provider, accountId } of session.accounts) { for (const { provider, accountId } of session.accounts) {
resumeAccount(provider, accountId); resumeAccount(provider, accountId);
} }
console.error( process.stderr.write(
String(
info( info(
`Restored ${session.accounts.length} auto-paused account(s) from crashed ${session.initiator} session` `Restored ${session.accounts.length} auto-paused account(s) from crashed ${session.initiator} session`
) )
) + '\n'
); );
} }
@@ -561,15 +575,17 @@ export function enforceProviderIsolation(provider: CLIProxyProvider): number {
}); });
saveAutoPaused(freshData); saveAutoPaused(freshData);
console.error(''); process.stderr.write('\n');
console.error(info(`Account safety: auto-paused ${toPause.length} conflicting account(s)`)); process.stderr.write(
String(info(`Account safety: auto-paused ${toPause.length} conflicting account(s)`)) + '\n'
);
for (const { provider: p, accountId } of toPause) { for (const { provider: p, accountId } of toPause) {
const acct = registry.providers[p]?.accounts[accountId]; const acct = registry.providers[p]?.accounts[accountId];
const display = acct?.email ? maskEmail(acct.email) : accountId; const display = acct?.email ? maskEmail(acct.email) : accountId;
console.error(` ${display} (${p})`); process.stderr.write(` ${display} (${p})\n`);
} }
console.error(' Will restore on session exit.'); process.stderr.write(' Will restore on session exit.\n');
console.error(''); process.stderr.write('\n');
return toPause.length; return toPause.length;
} }
@@ -646,14 +662,14 @@ export function handleBanDetection(
if (!isBanResponse(errorMessage, provider)) return false; if (!isBanResponse(errorMessage, provider)) return false;
const actor = banActor(provider); const actor = banActor(provider);
console.error(''); process.stderr.write('\n');
console.error(warn(`Account safety: account appears disabled by ${actor}`)); process.stderr.write(String(warn(`Account safety: account appears disabled by ${actor}`)) + '\n');
console.error(` Account "${maskEmail(accountId)}" (${provider}) returned:`); process.stderr.write(` Account "${maskEmail(accountId)}" (${provider}) returned:\n`);
console.error(` "${truncate(errorMessage, 120)}"`); process.stderr.write(` "${truncate(errorMessage, 120)}"\n`);
console.error(''); process.stderr.write('\n');
console.error(info('Auto-pausing this account to prevent further issues.')); process.stderr.write(String(info('Auto-pausing this account to prevent further issues.')) + '\n');
console.error(` Resume later: ccs ${provider} --resume ${accountId}`); process.stderr.write(` Resume later: ccs ${provider} --resume ${accountId}\n`);
console.error(''); process.stderr.write('\n');
return pauseAccount(provider, accountId); return pauseAccount(provider, accountId);
} }
+27 -15
View File
@@ -93,7 +93,7 @@ async function askYesNoStep(rl: Interface, step: string, message: string): Promi
const normalized = answer.toUpperCase(); const normalized = answer.toUpperCase();
if (normalized === 'YES') return true; if (normalized === 'YES') return true;
if (normalized === 'NO' || normalized === 'N' || normalized === '') return false; if (normalized === 'NO' || normalized === 'N' || normalized === '') return false;
console.error(warn('Please type YES or NO.')); process.stderr.write(String(warn('Please type YES or NO.')) + '\n');
} }
} }
@@ -108,7 +108,7 @@ async function askResponsibilityPhrase(rl: Interface): Promise<boolean> {
if (normalizePhrase(answer) === ANTIGRAVITY_ACK_PHRASE) { if (normalizePhrase(answer) === ANTIGRAVITY_ACK_PHRASE) {
return true; return true;
} }
console.error(warn('Phrase mismatch. Try again.')); process.stderr.write(String(warn('Phrase mismatch. Try again.')) + '\n');
} }
return false; return false;
} }
@@ -119,14 +119,22 @@ function printResponsibilityHeader(context: AgyRiskContext): void {
? 'You are starting Antigravity OAuth account authorization.' ? 'You are starting Antigravity OAuth account authorization.'
: 'You are starting a live Antigravity CLI session (ccs agy).'; : 'You are starting a live Antigravity CLI session (ccs agy).';
console.error(''); process.stderr.write('' + '\n');
console.error('╔══════════════════════════════════════════════════════════════════════╗'); process.stderr.write(
console.error('║ Antigravity Responsibility Confirmation (Mandatory) ║'); '╔══════════════════════════════════════════════════════════════════════╗' + '\n'
console.error('╚══════════════════════════════════════════════════════════════════════╝'); );
console.error(` ${contextLine}`); process.stderr.write(
console.error(' Antigravity has active ban/suspension patterns for risky OAuth usage.'); '║ Antigravity Responsibility Confirmation (Mandatory) ║' + '\n'
console.error(` Policy issue: ${ANTIGRAVITY_RISK_ISSUE_URL}`); );
console.error(''); process.stderr.write(
'╚══════════════════════════════════════════════════════════════════════╝' + '\n'
);
process.stderr.write(` ${contextLine}` + '\n');
process.stderr.write(
' Antigravity has active ban/suspension patterns for risky OAuth usage.' + '\n'
);
process.stderr.write(` Policy issue: ${ANTIGRAVITY_RISK_ISSUE_URL}` + '\n');
process.stderr.write('' + '\n');
} }
export function hasAntigravityRiskAcceptanceFlag(args: string[]): boolean { export function hasAntigravityRiskAcceptanceFlag(args: string[]): boolean {
@@ -178,9 +186,11 @@ export async function ensureCliAntigravityResponsibility(
} }
if (!process.stdin.isTTY || !process.stderr.isTTY) { if (!process.stdin.isTTY || !process.stderr.isTTY) {
console.error(fail('Antigravity responsibility acknowledgement required.')); process.stderr.write(
console.error(' Re-run interactively and complete the 4-step confirmation.'); String(fail('Antigravity responsibility acknowledgement required.')) + '\n'
console.error(' Non-interactive override: --accept-agr-risk'); );
process.stderr.write(' Re-run interactively and complete the 4-step confirmation.' + '\n');
process.stderr.write(' Non-interactive override: --accept-agr-risk' + '\n');
return false; return false;
} }
@@ -216,8 +226,10 @@ export async function ensureCliAntigravityResponsibility(
const step4 = await askResponsibilityPhrase(rl); const step4 = await askResponsibilityPhrase(rl);
if (!step4) return false; if (!step4) return false;
console.error(ok('Antigravity responsibility acknowledgement accepted for this command.')); process.stderr.write(
console.error(info('Proceeding with Antigravity flow...')); String(ok('Antigravity responsibility acknowledgement accepted for this command.')) + '\n'
);
process.stderr.write(String(info('Proceeding with Antigravity flow...')) + '\n');
return true; return true;
} finally { } finally {
rl.close(); rl.close();
+41 -33
View File
@@ -135,13 +135,17 @@ export async function configureProviderModel(
const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0; const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0;
// Show header with context (gradient like ccs doctor) // Show header with context (gradient like ccs doctor)
console.error(''); process.stderr.write('\n');
console.error(header(`Configure ${catalog.displayName} Model`)); process.stderr.write(String(header(`Configure ${catalog.displayName} Model`)) + '\n');
console.error(''); process.stderr.write('\n');
console.error(dim(' Select which model to use for this provider.')); process.stderr.write(String(dim(' Select which model to use for this provider.')) + '\n');
console.error(dim(' Models marked [Pro]/[Ultra] require a paid provider plan.')); process.stderr.write(
console.error(dim(' Models marked [DEPRECATED] are not recommended for use.')); String(dim(' Models marked [Pro]/[Ultra] require a paid provider plan.')) + '\n'
console.error(''); );
process.stderr.write(
String(dim(' Models marked [DEPRECATED] are not recommended for use.')) + '\n'
);
process.stderr.write('\n');
// Interactive selection // Interactive selection
const selectedModel = await InteractivePrompt.selectFromList('Select model:', options, { const selectedModel = await InteractivePrompt.selectFromList('Select model:', options, {
@@ -209,19 +213,21 @@ export async function configureProviderModel(
const selectedEntry = catalog.models.find((m) => m.id === selectedModel); const selectedEntry = catalog.models.find((m) => m.id === selectedModel);
const displayName = selectedEntry?.name || selectedModel; const displayName = selectedEntry?.name || selectedModel;
console.error(''); process.stderr.write('\n');
console.error(ok(`Model set to: ${bold(displayName)}`)); process.stderr.write(String(ok(`Model set to: ${bold(displayName)}`)) + '\n');
console.error(dim(` Config saved: ${settingsPath}`)); process.stderr.write(String(dim(` Config saved: ${settingsPath}`)) + '\n');
// Show deprecation warning if model is deprecated // Show deprecation warning if model is deprecated
if (selectedEntry?.deprecated) { if (selectedEntry?.deprecated) {
console.error(''); process.stderr.write('\n');
console.error(color('[!] DEPRECATION WARNING', 'warning')); process.stderr.write(String(color('[!] DEPRECATION WARNING', 'warning')) + '\n');
const reason = selectedEntry.deprecationReason || 'This model is deprecated'; const reason = selectedEntry.deprecationReason || 'This model is deprecated';
console.error(dim(` ${reason}`)); process.stderr.write(String(dim(` ${reason}`)) + '\n');
console.error(dim(' Consider using a non-deprecated model for better compatibility.')); process.stderr.write(
String(dim(' Consider using a non-deprecated model for better compatibility.')) + '\n'
);
} }
console.error(''); process.stderr.write('\n');
return true; return true;
} }
@@ -231,7 +237,9 @@ export async function configureProviderModel(
*/ */
export async function showCurrentConfig(provider: CLIProxyProvider): Promise<void> { export async function showCurrentConfig(provider: CLIProxyProvider): Promise<void> {
if (!supportsModelConfig(provider)) { if (!supportsModelConfig(provider)) {
console.error(info(`Provider ${provider} does not support model configuration`)); process.stderr.write(
String(info(`Provider ${provider} does not support model configuration`)) + '\n'
);
return; return;
} }
@@ -247,33 +255,33 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise<voi
? canonicalizeModelForProvider(provider, currentModel) ? canonicalizeModelForProvider(provider, currentModel)
: undefined; : undefined;
console.error(''); process.stderr.write('\n');
console.error(header(`${catalog.displayName} Model Configuration`)); process.stderr.write(String(header(`${catalog.displayName} Model Configuration`)) + '\n');
console.error(''); process.stderr.write('\n');
if (currentModel) { if (currentModel) {
const entry = catalog.models.find((m) => m.id === normalizedCurrentModel); const entry = catalog.models.find((m) => m.id === normalizedCurrentModel);
const displayName = entry?.name || 'Unknown'; const displayName = entry?.name || 'Unknown';
console.error( process.stderr.write(
` ${bold('Current:')} ${color(displayName, 'success')} ${dim(`(${currentModel})`)}` ` ${bold('Current:')} ${color(displayName, 'success')} ${dim(`(${currentModel})`)}\n`
); );
console.error(` ${bold('Config:')} ${dim(settingsPath)}`); process.stderr.write(` ${bold('Config:')} ${dim(settingsPath)}\n`);
} else { } else {
console.error(` ${bold('Current:')} ${dim('(using defaults)')}`); process.stderr.write(` ${bold('Current:')} ${dim('(using defaults)')}\n`);
console.error(` ${bold('Default:')} ${catalog.defaultModel}`); process.stderr.write(` ${bold('Default:')} ${catalog.defaultModel}\n`);
} }
console.error(''); process.stderr.write('\n');
console.error(bold('Available models:')); process.stderr.write(String(bold('Available models:')) + '\n');
console.error(dim(' [Pro]/[Ultra] = Requires a paid provider plan')); process.stderr.write(String(dim(' [Pro]/[Ultra] = Requires a paid provider plan')) + '\n');
console.error(dim(' [DEPRECATED] = Not recommended for use')); process.stderr.write(String(dim(' [DEPRECATED] = Not recommended for use')) + '\n');
console.error(''); process.stderr.write('\n');
catalog.models.forEach((m) => { catalog.models.forEach((m) => {
const isCurrent = m.id === normalizedCurrentModel; const isCurrent = m.id === normalizedCurrentModel;
console.error(formatModelDetailed(m, isCurrent)); process.stderr.write(String(formatModelDetailed(m, isCurrent)) + '\n');
}); });
console.error(''); process.stderr.write('\n');
console.error(dim(`Run "ccs ${provider} --config" to change`)); process.stderr.write(String(dim(`Run "ccs ${provider} --config" to change`)) + '\n');
console.error(''); process.stderr.write('\n');
} }
@@ -167,7 +167,7 @@ describe('parseExecutorFlags', () => {
beforeEach(() => { beforeEach(() => {
originalExitCode = process.exitCode as number | undefined; originalExitCode = process.exitCode as number | undefined;
process.exitCode = 0; process.exitCode = 0;
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); errorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
exitSpy = jest exitSpy = jest
.spyOn(process, 'exit') .spyOn(process, 'exit')
.mockImplementation((() => undefined as never) as typeof process.exit); .mockImplementation((() => undefined as never) as typeof process.exit);
@@ -290,7 +290,7 @@ describe('validateFlagCombinations', () => {
beforeEach(() => { beforeEach(() => {
originalExitCode = process.exitCode as number | undefined; originalExitCode = process.exitCode as number | undefined;
process.exitCode = 0; process.exitCode = 0;
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); errorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
}); });
afterEach(() => { afterEach(() => {
@@ -57,7 +57,7 @@ describe('warnBrokenModels', () => {
let errorSpy: ReturnType<typeof jest.spyOn>; let errorSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => { beforeEach(() => {
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); errorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
mockGetCurrentModel.mockReset(); mockGetCurrentModel.mockReset();
mockIsModelBroken.mockReturnValue(false); mockIsModelBroken.mockReturnValue(false);
mockGetModelIssueUrl.mockReturnValue(undefined); mockGetModelIssueUrl.mockReturnValue(undefined);
+31 -27
View File
@@ -207,9 +207,9 @@ export function parseExecutorFlags(
const forceHeadless = args.includes('--headless'); const forceHeadless = args.includes('--headless');
if (pasteCallback && portForward) { if (pasteCallback && portForward) {
console.error(fail('Cannot use --paste-callback with --port-forward')); process.stderr.write(String(fail('Cannot use --paste-callback with --port-forward')) + '\n');
console.error(' --paste-callback: Manually paste OAuth redirect URL'); process.stderr.write(' --paste-callback: Manually paste OAuth redirect URL\n');
console.error(' --port-forward: Use SSH port forwarding for callback'); process.stderr.write(' --port-forward: Use SSH port forwarding for callback\n');
process.exit(1); process.exit(1);
} }
@@ -247,8 +247,8 @@ export function parseExecutorFlags(
if (kiroMethodValue.present) { if (kiroMethodValue.present) {
const rawMethod = kiroMethodValue.value; const rawMethod = kiroMethodValue.value;
if (kiroMethodValue.missingValue || !rawMethod) { if (kiroMethodValue.missingValue || !rawMethod) {
console.error(fail('--kiro-auth-method requires a value')); process.stderr.write(String(fail('--kiro-auth-method requires a value')) + '\n');
console.error(' Supported values: aws, aws-authcode, google, github, idc'); process.stderr.write(' Supported values: aws, aws-authcode, google, github, idc\n');
process.exitCode = 1; process.exitCode = 1;
// Caller must check parseFailed and bail — matching original return behavior // Caller must check parseFailed and bail — matching original return behavior
return buildPartialFlags({ return buildPartialFlags({
@@ -280,8 +280,8 @@ export function parseExecutorFlags(
} }
const normalized = rawMethod.trim().toLowerCase(); const normalized = rawMethod.trim().toLowerCase();
if (!isKiroAuthMethod(normalized)) { if (!isKiroAuthMethod(normalized)) {
console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`)); process.stderr.write(String(fail(`Invalid --kiro-auth-method value: ${rawMethod}`)) + '\n');
console.error(' Supported values: aws, aws-authcode, google, github, idc'); process.stderr.write(' Supported values: aws, aws-authcode, google, github, idc\n');
process.exitCode = 1; process.exitCode = 1;
return buildPartialFlags({ return buildPartialFlags({
forceAuth, forceAuth,
@@ -318,7 +318,7 @@ export function parseExecutorFlags(
if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) { if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) {
kiroIDCStartUrl = kiroIDCStartUrlValue.value; kiroIDCStartUrl = kiroIDCStartUrlValue.value;
} else if (kiroIDCStartUrlValue.present) { } else if (kiroIDCStartUrlValue.present) {
console.error(fail('--kiro-idc-start-url requires a value')); process.stderr.write(String(fail('--kiro-idc-start-url requires a value')) + '\n');
process.exitCode = 1; process.exitCode = 1;
return buildPartialFlags({ return buildPartialFlags({
forceAuth, forceAuth,
@@ -353,7 +353,7 @@ export function parseExecutorFlags(
if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) { if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) {
kiroIDCRegion = kiroIDCRegionValue.value; kiroIDCRegion = kiroIDCRegionValue.value;
} else if (kiroIDCRegionValue.present) { } else if (kiroIDCRegionValue.present) {
console.error(fail('--kiro-idc-region requires a value')); process.stderr.write(String(fail('--kiro-idc-region requires a value')) + '\n');
process.exitCode = 1; process.exitCode = 1;
return buildPartialFlags({ return buildPartialFlags({
forceAuth, forceAuth,
@@ -388,8 +388,8 @@ export function parseExecutorFlags(
if (kiroIDCFlowValue.present) { if (kiroIDCFlowValue.present) {
const rawFlow = kiroIDCFlowValue.value; const rawFlow = kiroIDCFlowValue.value;
if (kiroIDCFlowValue.missingValue || !rawFlow) { if (kiroIDCFlowValue.missingValue || !rawFlow) {
console.error(fail('--kiro-idc-flow requires a value')); process.stderr.write(String(fail('--kiro-idc-flow requires a value')) + '\n');
console.error(' Supported values: authcode, device'); process.stderr.write(' Supported values: authcode, device\n');
process.exitCode = 1; process.exitCode = 1;
return buildPartialFlags({ return buildPartialFlags({
forceAuth, forceAuth,
@@ -420,8 +420,8 @@ export function parseExecutorFlags(
} }
const normalized = rawFlow.trim().toLowerCase(); const normalized = rawFlow.trim().toLowerCase();
if (!isKiroIDCFlow(normalized)) { if (!isKiroIDCFlow(normalized)) {
console.error(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`)); process.stderr.write(String(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`)) + '\n');
console.error(' Supported values: authcode, device'); process.stderr.write(' Supported values: authcode, device\n');
process.exitCode = 1; process.exitCode = 1;
return buildPartialFlags({ return buildPartialFlags({
forceAuth, forceAuth,
@@ -458,7 +458,7 @@ export function parseExecutorFlags(
if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) { if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) {
gitlabBaseUrl = gitlabBaseUrlValue.value.trim(); gitlabBaseUrl = gitlabBaseUrlValue.value.trim();
} else if (gitlabBaseUrlValue.present) { } else if (gitlabBaseUrlValue.present) {
console.error(fail('--gitlab-url requires a value')); process.stderr.write(String(fail('--gitlab-url requires a value')) + '\n');
process.exitCode = 1; process.exitCode = 1;
return buildPartialFlags({ return buildPartialFlags({
forceAuth, forceAuth,
@@ -492,14 +492,14 @@ export function parseExecutorFlags(
const thinkingParse = parseThinkingOverride(args); const thinkingParse = parseThinkingOverride(args);
if (thinkingParse.error) { if (thinkingParse.error) {
const { flag } = thinkingParse.error; const { flag } = thinkingParse.error;
console.error(fail(`${flag} requires a value`)); process.stderr.write(String(fail(`${flag} requires a value`)) + '\n');
if (provider === 'codex') { if (provider === 'codex') {
console.error(' Codex examples: --effort xhigh, --effort high, --effort medium'); process.stderr.write(' Codex examples: --effort xhigh, --effort high, --effort medium\n');
console.error(' Alias: --thinking xhigh (same behavior)'); process.stderr.write(' Alias: --thinking xhigh (same behavior)\n');
} else { } else {
console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); process.stderr.write(' Examples: --thinking low, --thinking 8192, --thinking off\n');
console.error(' Levels: minimal, low, medium, high, xhigh, max, auto'); process.stderr.write(' Levels: minimal, low, medium, high, xhigh, max, auto\n');
} }
process.exit(1); process.exit(1);
@@ -511,7 +511,7 @@ export function parseExecutorFlags(
const hasNo1mFlag = args.includes('--no-1m') || args.some((arg) => arg.startsWith('--no-1m=')); const hasNo1mFlag = args.includes('--no-1m') || args.some((arg) => arg.startsWith('--no-1m='));
if (has1mFlag && hasNo1mFlag) { if (has1mFlag && hasNo1mFlag) {
console.error(fail('Cannot use both --1m and --no-1m flags')); process.stderr.write(String(fail('Cannot use both --1m and --no-1m flags')) + '\n');
process.exit(1); process.exit(1);
} else if (has1mFlag) { } else if (has1mFlag) {
extendedContextOverride = true; extendedContextOverride = true;
@@ -584,7 +584,7 @@ export function validateFlagCombinations(
} = parsed; } = parsed;
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) { if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
console.error(fail('--kiro-auth-method is only valid for ccs kiro')); process.stderr.write(String(fail('--kiro-auth-method is only valid for ccs kiro')) + '\n');
process.exitCode = 1; process.exitCode = 1;
return false; return false;
} }
@@ -594,19 +594,21 @@ export function validateFlagCombinations(
provider !== 'kiro' && provider !== 'kiro' &&
!compositeProviders.includes('kiro') !compositeProviders.includes('kiro')
) { ) {
console.error( process.stderr.write(
String(
fail( fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro' '--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro'
) )
) + '\n'
); );
process.exitCode = 1; process.exitCode = 1;
return false; return false;
} }
if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) { if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) {
console.error(fail('Kiro IDC login requires --kiro-idc-start-url')); process.stderr.write(String(fail('Kiro IDC login requires --kiro-idc-start-url')) + '\n');
console.error( process.stderr.write(
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start' ' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start\n'
); );
process.exitCode = 1; process.exitCode = 1;
return false; return false;
@@ -617,10 +619,12 @@ export function validateFlagCombinations(
kiroAuthMethod !== 'idc' && kiroAuthMethod !== 'idc' &&
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow) (kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)
) { ) {
console.error( process.stderr.write(
String(
fail( fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc' '--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc'
) )
) + '\n'
); );
process.exitCode = 1; process.exitCode = 1;
return false; return false;
@@ -628,7 +632,7 @@ export function validateFlagCombinations(
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') { if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url'; const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url';
console.error(fail(`${flagName} is only valid for ccs gitlab`)); process.stderr.write(String(fail(`${flagName} is only valid for ccs gitlab`)) + '\n');
process.exitCode = 1; process.exitCode = 1;
return false; return false;
} }
+21 -14
View File
@@ -95,18 +95,18 @@ export async function handleImport(context: AuthCoordinationContext): Promise<bo
if (!forceImport) return false; if (!forceImport) return false;
if (provider !== 'kiro') { if (provider !== 'kiro') {
console.error(fail('--import is only available for Kiro')); process.stderr.write(String(fail('--import is only available for Kiro')) + '\n');
console.error(` Run "ccs ${provider} --auth" to authenticate`); process.stderr.write(` Run "ccs ${provider} --auth" to authenticate` + '\n');
process.exit(1); process.exit(1);
} }
if (forceAuth) { if (forceAuth) {
console.error(fail('Cannot use --import with --auth')); process.stderr.write(String(fail('Cannot use --import with --auth')) + '\n');
console.error(' --import: Import existing token from Kiro IDE'); process.stderr.write(' --import: Import existing token from Kiro IDE' + '\n');
console.error(' --auth: Trigger new OAuth flow in browser'); process.stderr.write(' --auth: Trigger new OAuth flow in browser' + '\n');
process.exit(1); process.exit(1);
} }
if (forceLogout) { if (forceLogout) {
console.error(fail('Cannot use --import with --logout')); process.stderr.write(String(fail('Cannot use --import with --logout')) + '\n');
process.exit(1); process.exit(1);
} }
@@ -121,8 +121,8 @@ export async function handleImport(context: AuthCoordinationContext): Promise<bo
...(setNickname ? { nickname: setNickname } : {}), ...(setNickname ? { nickname: setNickname } : {}),
}); });
if (!authSuccess) { if (!authSuccess) {
console.error(fail('Failed to import Kiro token from IDE')); process.stderr.write(String(fail('Failed to import Kiro token from IDE')) + '\n');
console.error(' Make sure you are logged into Kiro IDE first'); process.stderr.write(' Make sure you are logged into Kiro IDE first' + '\n');
process.exit(1); process.exit(1);
} }
process.exit(0); process.exit(0);
@@ -177,7 +177,10 @@ export async function runAntigravityGate(
`Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` `Antigravity auth blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
); );
} }
console.error(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.')); process.stderr.write(
String(info('Remote proxy mode is active; local OAuth flow is skipped in --auth mode.')) +
'\n'
);
return { earlyReturn: true }; return { earlyReturn: true };
} }
@@ -189,10 +192,12 @@ export async function runAntigravityGate(
acceptedByFlag: acceptAgyRisk, acceptedByFlag: acceptAgyRisk,
}); });
if (!acknowledged) { if (!acknowledged) {
console.error( process.stderr.write(
String(
fail( fail(
`Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` `Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
) )
) + '\n'
); );
process.exit(1); process.exit(1);
} }
@@ -262,9 +267,9 @@ export async function ensureProviderAuthentication(
} }
if (failures.length > 0) { if (failures.length > 0) {
const succeeded = compositeProviders.filter((p) => !failures.includes(p)); const succeeded = compositeProviders.filter((p) => !failures.includes(p));
console.error(fail(`Auth failed for: ${failures.join(', ')}`)); process.stderr.write(String(fail(`Auth failed for: ${failures.join(', ')}`)) + '\n');
if (succeeded.length > 0) { if (succeeded.length > 0) {
console.error(info(`Succeeded: ${succeeded.join(', ')}`)); process.stderr.write(String(info(`Succeeded: ${succeeded.join(', ')}`)) + '\n');
} }
process.exit(1); process.exit(1);
} }
@@ -279,9 +284,11 @@ export async function ensureProviderAuthentication(
} }
} }
if (unauthenticatedProviders.length > 0) { if (unauthenticatedProviders.length > 0) {
console.error(fail('Composite variant requires authentication for multiple providers:')); process.stderr.write(
String(fail('Composite variant requires authentication for multiple providers:')) + '\n'
);
for (const p of unauthenticatedProviders) { for (const p of unauthenticatedProviders) {
console.error(` - ${p} (run "ccs ${p} --auth")`); process.stderr.write(` - ${p} (run "ccs ${p} --auth")` + '\n');
} }
process.exit(1); process.exit(1);
} }
+22 -17
View File
@@ -13,6 +13,9 @@
import { ChildProcess } from 'child_process'; import { ChildProcess } from 'child_process';
import * as fs from 'fs'; import * as fs from 'fs';
import { fail, info, warn } from '../../utils/ui'; import { fail, info, warn } from '../../utils/ui';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:executor');
import { import {
generateConfig, generateConfig,
getProviderConfig, getProviderConfig,
@@ -107,14 +110,14 @@ export async function execClaudeWithCLIProxy(
// Validate Claude CLI exists before proceeding // Validate Claude CLI exists before proceeding
if (!fs.existsSync(claudeCli)) { if (!fs.existsSync(claudeCli)) {
console.error(fail(`Claude CLI not found at: ${claudeCli}`)); process.stderr.write(`${fail(`Claude CLI not found at: ${claudeCli}`)}\n`);
console.error(' Run "ccs doctor --fix" to reinstall or check your PATH'); process.stderr.write(' Run "ccs doctor --fix" to reinstall or check your PATH\n');
process.exit(1); process.exit(1);
} }
const log = (msg: string) => { const log = (msg: string) => {
if (verbose) { if (verbose) {
console.error(`[cliproxy] ${msg}`); logger.info('verbose', msg);
} }
}; };
@@ -203,16 +206,16 @@ export async function execClaudeWithCLIProxy(
const thinkingCfg = getThinkingConfig(); const thinkingCfg = getThinkingConfig();
if (thinkingParse.duplicateDisplays.length > 0) { if (thinkingParse.duplicateDisplays.length > 0) {
console.warn( process.stderr.write(
`[!] Multiple reasoning flags detected. Using first occurrence: ${thinkingParse.sourceDisplay}` `[!] Multiple reasoning flags detected. Using first occurrence: ${thinkingParse.sourceDisplay}\n`
); );
} }
if (thinkingParse.sourceFlag === '--effort' && provider !== 'codex') { if (thinkingParse.sourceFlag === '--effort' && provider !== 'codex') {
console.warn( process.stderr.write(
warn( `${warn(
'`--effort` is primarily for codex. Continuing as alias of `--thinking` for compatibility.' '`--effort` is primarily for codex. Continuing as alias of `--thinking` for compatibility.'
) )}\n`
); );
} }
@@ -227,7 +230,9 @@ export async function execClaudeWithCLIProxy(
console.log( console.log(
warn('Composite variants use per-tier config. Edit config.yaml to change tier models.') warn('Composite variants use per-tier config. Edit config.yaml to change tier models.')
); );
console.error(` Use "ccs cliproxy edit ${variantName}" to modify composite variants`); process.stderr.write(
` Use "ccs cliproxy edit ${variantName}" to modify composite variants\n`
);
process.exit(1); process.exit(1);
} else { } else {
// Run the one-time stale-pin migration on the pre-existing settings file // Run the one-time stale-pin migration on the pre-existing settings file
@@ -369,7 +374,7 @@ export async function execClaudeWithCLIProxy(
); );
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
console.error(warn(`Failed to start HTTPS tunnel: ${err.message}`)); process.stderr.write(`${warn(`Failed to start HTTPS tunnel: ${err.message}`)}\n`);
throw new Error(`HTTPS tunnel startup failed: ${err.message}`); throw new Error(`HTTPS tunnel startup failed: ${err.message}`);
} }
} else if (useRemoteProxy && proxyConfig.protocol === 'https' && provider === 'codex') { } else if (useRemoteProxy && proxyConfig.protocol === 'https' && provider === 'codex') {
@@ -526,16 +531,16 @@ export async function execClaudeWithCLIProxy(
const webSearchEnv = getWebSearchHookEnv(); const webSearchEnv = getWebSearchHookEnv();
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error( logger.info('browser-env-keys', 'CCS_BROWSER_* keys in environment', {
`[cliproxy-browser-debug] keys=${Object.keys(env) keys: Object.keys(env)
.filter((key) => key.startsWith('CCS_BROWSER_')) .filter((key) => key.startsWith('CCS_BROWSER_'))
.sort() .sort(),
.join(',')} ws=${env.CCS_BROWSER_DEVTOOLS_WS_URL || ''}` ws: env.CCS_BROWSER_DEVTOOLS_WS_URL || '',
); });
} }
logEnvironment(env, webSearchEnv, verbose); logEnvironment(env, webSearchEnv, verbose);
if (imageAnalysisWarning) { if (imageAnalysisWarning) {
console.error(info(imageAnalysisWarning)); process.stderr.write(`${info(imageAnalysisWarning)}\n`);
} }
// 11b. Print thinking status feedback (TTY only, non-piped sessions) // 11b. Print thinking status feedback (TTY only, non-piped sessions)
@@ -547,7 +552,7 @@ export async function execClaudeWithCLIProxy(
thinkingParse.sourceDisplay thinkingParse.sourceDisplay
); );
console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`); process.stderr.write(`[i] Thinking: ${thinkingLabel} (${sourceLabel})\n`);
} }
// 12. Filter CCS flags, spawn Claude CLI, start quota monitor, wire cleanup // 12. Filter CCS flags, spawn Claude CLI, start quota monitor, wire cleanup
+19 -16
View File
@@ -14,6 +14,9 @@ import { fail } from '../../utils/ui';
import { getCliproxyWritablePath } from '../config/config-generator'; import { getCliproxyWritablePath } from '../config/config-generator';
import { getPortCheckCommand, getCatCommand } from '../../utils/platform-commands'; import { getPortCheckCommand, getCatCommand } from '../../utils/platform-commands';
import { CLIProxyBackend } from '../types'; import { CLIProxyBackend } from '../types';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:executor:lifecycle-manager');
/** /**
* Wait for TCP port to become available * Wait for TCP port to become available
@@ -62,7 +65,7 @@ export async function waitForProxyReady(
export function spawnProxy(binaryPath: string, configPath: string, verbose: boolean): ChildProcess { export function spawnProxy(binaryPath: string, configPath: string, verbose: boolean): ChildProcess {
const log = (msg: string) => { const log = (msg: string) => {
if (verbose) { if (verbose) {
console.error(`[cliproxy] ${msg}`); logger.info('executor.lifecycle.spawn_verbose', msg);
} }
}; };
@@ -81,7 +84,7 @@ export function spawnProxy(binaryPath: string, configPath: string, verbose: bool
proxy.unref(); proxy.unref();
proxy.on('error', (error) => { proxy.on('error', (error) => {
console.error(fail(`CLIProxy spawn error: ${error.message}`)); process.stderr.write(String(fail(`CLIProxy spawn error: ${error.message}`)) + '\n');
}); });
return proxy; return proxy;
@@ -108,20 +111,20 @@ export async function waitForProxyReadyWithSpinner(
readySpinner.fail(`${backendLabel} startup failed`); readySpinner.fail(`${backendLabel} startup failed`);
const err = error as Error; const err = error as Error;
console.error(''); process.stderr.write('' + '\n');
console.error(fail(`${backendLabel} failed to start`)); process.stderr.write(String(fail(`${backendLabel} failed to start`)) + '\n');
console.error(''); process.stderr.write('' + '\n');
console.error('Possible causes:'); process.stderr.write('Possible causes:' + '\n');
console.error(` 1. Port ${port} already in use`); process.stderr.write(` 1. Port ${port} already in use` + '\n');
console.error(' 2. Binary crashed on startup'); process.stderr.write(' 2. Binary crashed on startup' + '\n');
console.error(' 3. Invalid configuration'); process.stderr.write(' 3. Invalid configuration' + '\n');
console.error(''); process.stderr.write('' + '\n');
console.error('Troubleshooting:'); process.stderr.write('Troubleshooting:' + '\n');
console.error(` - Check port: ${getPortCheckCommand(port)}`); process.stderr.write(` - Check port: ${getPortCheckCommand(port)}` + '\n');
console.error(' - Run with --verbose for detailed logs'); process.stderr.write(' - Run with --verbose for detailed logs' + '\n');
console.error(` - View config: ${getCatCommand(configPath)}`); process.stderr.write(` - View config: ${getCatCommand(configPath)}` + '\n');
console.error(' - Try: ccs doctor --fix'); process.stderr.write(' - Try: ccs doctor --fix' + '\n');
console.error(''); process.stderr.write('' + '\n');
throw new Error(`CLIProxy startup failed: ${err.message}`); throw new Error(`CLIProxy startup failed: ${err.message}`);
} }
+25 -13
View File
@@ -23,6 +23,18 @@ export interface ModelWarningsContext {
customSettingsPath?: string; customSettingsPath?: string;
} }
/**
* Write a line to stderr preserving prior `console.error` semantics.
*
* These lines are primary user-facing model warnings (rendered via the ui
* `warn()` helper or human-readable guidance the user must act on), so they
* stay on stderr verbatim rather than being routed through the structured
* logger.
*/
function stderr(line: string): void {
process.stderr.write(String(line) + '\n');
}
/** /**
* Check all active models for known issues and emit warnings. * Check all active models for known issues and emit warnings.
* *
@@ -40,17 +52,17 @@ export function warnBrokenModels(context: ModelWarningsContext): void {
if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) { if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) {
const modelEntry = findModel(tierConfig.provider, tierConfig.model); const modelEntry = findModel(tierConfig.provider, tierConfig.model);
const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model); const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model);
console.error(''); stderr('');
console.error( stderr(
warn( warn(
`${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code` `${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code`
) )
); );
console.error(' Tool calls will fail. Consider changing the model in config.yaml.'); stderr(' Tool calls will fail. Consider changing the model in config.yaml.');
if (issueUrl) { if (issueUrl) {
console.error(` Tracking: ${issueUrl}`); stderr(` Tracking: ${issueUrl}`);
} }
console.error(''); stderr('');
} }
} }
} else { } else {
@@ -59,22 +71,22 @@ export function warnBrokenModels(context: ModelWarningsContext): void {
const modelEntry = findModel(provider, currentModel); const modelEntry = findModel(provider, currentModel);
const issueUrl = getModelIssueUrl(provider, currentModel); const issueUrl = getModelIssueUrl(provider, currentModel);
const replacementModel = getSuggestedReplacementModel(provider, currentModel); const replacementModel = getSuggestedReplacementModel(provider, currentModel);
console.error(''); stderr('');
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); stderr(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
if (replacementModel) { if (replacementModel) {
console.error(` Tool calls will fail. Use "${replacementModel}" instead.`); stderr(` Tool calls will fail. Use "${replacementModel}" instead.`);
} else { } else {
console.error(' Tool calls will fail. Consider changing the model in config.yaml.'); stderr(' Tool calls will fail. Consider changing the model in config.yaml.');
} }
if (issueUrl) { if (issueUrl) {
console.error(` Tracking: ${issueUrl}`); stderr(` Tracking: ${issueUrl}`);
} }
if (skipLocalAuth) { if (skipLocalAuth) {
console.error(' Note: Model may be overridden by remote proxy configuration.'); stderr(' Note: Model may be overridden by remote proxy configuration.');
} else { } else {
console.error(` Run "ccs ${provider} --config" to change model.`); stderr(` Run "ccs ${provider} --config" to change model.`);
} }
console.error(''); stderr('');
} }
} }
} }
+14 -11
View File
@@ -12,6 +12,9 @@ import { fail, warn, info } from '../../utils/ui';
import { CLIProxyProvider } from '../types'; import { CLIProxyProvider } from '../types';
import { handleBanDetection, warnPossible403Ban } from '../accounts/account-safety'; import { handleBanDetection, warnPossible403Ban } from '../accounts/account-safety';
import { CompositeTierConfig } from '../../config/unified-config-types'; import { CompositeTierConfig } from '../../config/unified-config-types';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:executor:retry-handler');
/** /**
* Check if error is network-related * Check if error is network-related
@@ -32,12 +35,12 @@ export function isNetworkError(error: Error): boolean {
* Handle network error with user-friendly message * Handle network error with user-friendly message
*/ */
export function handleNetworkError(_error: Error): never { export function handleNetworkError(_error: Error): never {
console.error(''); process.stderr.write(String('') + '\n');
console.error(fail('No network connection detected')); process.stderr.write(String(fail('No network connection detected')) + '\n');
console.error(''); process.stderr.write(String('') + '\n');
console.error('CLIProxy binary download requires internet access.'); process.stderr.write(String('CLIProxy binary download requires internet access.') + '\n');
console.error('Please check your network connection and try again.'); process.stderr.write(String('Please check your network connection and try again.') + '\n');
console.error(''); process.stderr.write(String('') + '\n');
process.exit(1); process.exit(1);
} }
@@ -63,16 +66,16 @@ export async function handleTokenExpiration(
} }
// Token expired and refresh failed - trigger re-auth // Token expired and refresh failed - trigger re-auth
console.error(warn('OAuth token expired and refresh failed')); process.stderr.write(String(warn('OAuth token expired and refresh failed')) + '\n');
if (tokenResult.error) { if (tokenResult.error) {
console.error(` ${tokenResult.error}`); process.stderr.write(String(` ${tokenResult.error}`) + '\n');
} }
console.error(` Run "ccs ${provider} --auth" to re-authenticate`); process.stderr.write(String(` Run "ccs ${provider} --auth" to re-authenticate`) + '\n');
process.exit(1); process.exit(1);
} }
if (tokenResult.refreshed && verbose) { if (tokenResult.refreshed && verbose) {
console.error('[cliproxy] Token was refreshed proactively'); logger.info('token.refreshed', 'Token was refreshed proactively', { provider, verbose });
} }
} }
@@ -86,7 +89,7 @@ export async function handleQuotaCheck(provider: CLIProxyProvider): Promise<void
const preflight = await preflightCheck(provider); const preflight = await preflightCheck(provider);
if (!preflight.proceed) { if (!preflight.proceed) {
console.error(fail(`Cannot start session: ${preflight.reason}`)); process.stderr.write(String(fail(`Cannot start session: ${preflight.reason}`)) + '\n');
process.exit(1); process.exit(1);
} }
+23 -12
View File
@@ -26,6 +26,9 @@ import {
import { withStartupLock } from '../services/startup-lock'; import { withStartupLock } from '../services/startup-lock';
import { killProcessOnPort } from '../../utils/platform-commands'; import { killProcessOnPort } from '../../utils/platform-commands';
import { stopQuotaMonitor } from '../quota/quota-manager'; import { stopQuotaMonitor } from '../quota/quota-manager';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:executor:session-bridge');
export interface ProxySessionResult { export interface ProxySessionResult {
sessionId?: string; sessionId?: string;
@@ -43,7 +46,7 @@ export async function checkOrJoinProxy(
): Promise<ProxySessionResult> { ): Promise<ProxySessionResult> {
const log = (msg: string) => { const log = (msg: string) => {
if (verbose) { if (verbose) {
console.error(`[cliproxy] ${msg}`); logger.info('proxy.check_or_join.trace', msg);
} }
}; };
@@ -129,16 +132,18 @@ export async function checkOrJoinProxy(
// Truly blocked by another application // Truly blocked by another application
const { getPortCheckCommand } = await import('../../utils/platform-commands'); const { getPortCheckCommand } = await import('../../utils/platform-commands');
console.error(''); process.stderr.write('\n');
console.error( process.stderr.write(
String(
warn( warn(
`Port ${port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})` `Port ${port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})`
) )
) + '\n'
); );
console.error(''); process.stderr.write('\n');
console.error('To fix this, close the blocking application or run:'); process.stderr.write('To fix this, close the blocking application or run:\n');
console.error(` ${getPortCheckCommand(port)}`); process.stderr.write(` ${getPortCheckCommand(port)}\n`);
console.error(''); process.stderr.write('\n');
throw new Error(`Port ${port} is in use by another application`); throw new Error(`Port ${port} is in use by another application`);
} }
@@ -162,9 +167,13 @@ export function registerProxySession(
const sessionId = registerSession(port, pid, installedVersion, backend); const sessionId = registerSession(port, pid, installedVersion, backend);
if (verbose) { if (verbose) {
console.error( logger.info('proxy.session.registered', 'Registered session with new proxy', {
`[cliproxy] Registered session ${sessionId} with new proxy (PID ${pid}, version ${installedVersion})` sessionId,
); port,
pid,
version: installedVersion,
backend,
});
} }
return sessionId; return sessionId;
@@ -184,7 +193,7 @@ export function setupCleanupHandlers(
): void { ): void {
const log = (msg: string) => { const log = (msg: string) => {
if (verbose) { if (verbose) {
console.error(`[cliproxy] ${msg}`); logger.info('proxy.cleanup.trace', msg);
} }
}; };
@@ -228,7 +237,9 @@ export function setupCleanupHandlers(
}); });
claude.on('error', (error) => { claude.on('error', (error) => {
console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`)); process.stderr.write(
String(require('../../utils/ui').fail(`Claude CLI error: ${error}`)) + '\n'
);
stopSessionResources(); stopSessionResources();
process.exit(1); process.exit(1);
}); });
@@ -38,7 +38,7 @@ export async function uploadTokenToRemote(
if (!target.isRemote) { if (!target.isRemote) {
if (verbose) { if (verbose) {
console.error('[upload] Remote mode not enabled, skipping upload'); process.stderr.write('[upload] Remote mode not enabled, skipping upload\n');
} }
return false; return false;
} }
@@ -48,7 +48,9 @@ export async function uploadTokenToRemote(
try { try {
tokenContent = fs.readFileSync(tokenFilePath, 'utf-8'); tokenContent = fs.readFileSync(tokenFilePath, 'utf-8');
} catch (error) { } catch (error) {
console.error(fail(`Failed to read token file: ${(error as Error).message}`)); process.stderr.write(
String(fail(`Failed to read token file: ${(error as Error).message}`)) + '\n'
);
return false; return false;
} }
@@ -56,7 +58,7 @@ export async function uploadTokenToRemote(
try { try {
JSON.parse(tokenContent); JSON.parse(tokenContent);
} catch { } catch {
console.error(fail('Invalid token file: not valid JSON')); process.stderr.write(String(fail('Invalid token file: not valid JSON')) + '\n');
return false; return false;
} }
@@ -67,7 +69,7 @@ export async function uploadTokenToRemote(
const authKey = target.managementKey ?? target.authToken; const authKey = target.managementKey ?? target.authToken;
if (verbose) { if (verbose) {
console.error(`[upload] Uploading ${fileName} to ${target.host}`); process.stderr.write(`[upload] Uploading ${fileName} to ${target.host}\n`);
} }
const controller = new AbortController(); const controller = new AbortController();
@@ -95,7 +97,7 @@ export async function uploadTokenToRemote(
if (!response.ok) { if (!response.ok) {
const text = await response.text(); const text = await response.text();
console.error(fail(`Upload failed: ${response.status} ${text}`)); process.stderr.write(String(fail(`Upload failed: ${response.status} ${text}`)) + '\n');
return false; return false;
} }
@@ -105,16 +107,18 @@ export async function uploadTokenToRemote(
console.log(ok(`Token uploaded to remote server: ${fileName}`)); console.log(ok(`Token uploaded to remote server: ${fileName}`));
return true; return true;
} else { } else {
console.error(fail(`Upload failed: ${result.error || result.message || 'Unknown error'}`)); process.stderr.write(
String(fail(`Upload failed: ${result.error || result.message || 'Unknown error'}`)) + '\n'
);
return false; return false;
} }
} catch (error) { } catch (error) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
console.error(fail('Upload timed out')); process.stderr.write(String(fail('Upload timed out')) + '\n');
} else { } else {
console.error(fail(`Upload failed: ${(error as Error).message}`)); process.stderr.write(String(fail(`Upload failed: ${(error as Error).message}`)) + '\n');
} }
return false; return false;
} }
@@ -132,14 +136,14 @@ export async function uploadAllTokensToRemote(tokenDir: string, verbose = false)
if (!target.isRemote) { if (!target.isRemote) {
if (verbose) { if (verbose) {
console.error('[upload] Remote mode not enabled, skipping upload'); process.stderr.write('[upload] Remote mode not enabled, skipping upload\n');
} }
return 0; return 0;
} }
if (!fs.existsSync(tokenDir)) { if (!fs.existsSync(tokenDir)) {
if (verbose) { if (verbose) {
console.error(`[upload] Token directory does not exist: ${tokenDir}`); process.stderr.write(`[upload] Token directory does not exist: ${tokenDir}\n`);
} }
return 0; return 0;
} }
@@ -148,7 +152,7 @@ export async function uploadAllTokensToRemote(tokenDir: string, verbose = false)
if (files.length === 0) { if (files.length === 0) {
if (verbose) { if (verbose) {
console.error('[upload] No token files found'); process.stderr.write('[upload] No token files found\n');
} }
return 0; return 0;
} }
+23 -10
View File
@@ -16,6 +16,9 @@
import * as http from 'http'; import * as http from 'http';
import * as https from 'https'; import * as https from 'https';
import type { Socket } from 'net'; import type { Socket } from 'net';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:https-tunnel-proxy');
export interface HttpsTunnelConfig { export interface HttpsTunnelConfig {
/** Remote server hostname */ /** Remote server hostname */
@@ -72,9 +75,13 @@ export class HttpsTunnelProxy {
}; };
} }
private log(message: string): void { /**
* Trace-level operational log gated on verbose mode (request routing, lifecycle chatter).
* Errors/warnings are logged directly via logger.* and are not gated.
*/
private trace(message: string): void {
if (this.config.verbose) { if (this.config.verbose) {
console.error(`[https-tunnel] ${message}`); logger.info('tunnel.trace', message);
} }
} }
@@ -104,7 +111,7 @@ export class HttpsTunnelProxy {
reject(new Error('Failed to bind to any port')); reject(new Error('Failed to bind to any port'));
return; return;
} }
this.log( this.trace(
`Started on port ${this.port}, tunneling to https://${this.config.remoteHost}:${this.config.remotePort}` `Started on port ${this.port}, tunneling to https://${this.config.remoteHost}:${this.config.remotePort}`
); );
resolve(this.port); resolve(this.port);
@@ -132,7 +139,7 @@ export class HttpsTunnelProxy {
this.server = null; this.server = null;
this.port = null; this.port = null;
this.startingPromise = null; this.startingPromise = null;
this.log('Stopped'); this.trace('Stopped');
} }
getPort(): number | null { getPort(): number | null {
@@ -182,7 +189,7 @@ export class HttpsTunnelProxy {
const method = req.method || 'GET'; const method = req.method || 'GET';
const requestPath = req.url || '/'; const requestPath = req.url || '/';
this.log( this.trace(
`${method} ${requestPath} → https://${this.config.remoteHost}:${this.config.remotePort}${requestPath}` `${method} ${requestPath} → https://${this.config.remoteHost}:${this.config.remotePort}${requestPath}`
); );
@@ -190,7 +197,9 @@ export class HttpsTunnelProxy {
await this.forwardRequest(req, res, requestPath); await this.forwardRequest(req, res, requestPath);
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.log(`Error: ${err.message}`); logger.error('tunnel.request_failed', 'Tunnel request handler failed', {
err: { name: err.name, message: err.message },
});
if (!res.headersSent) { if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' }); res.writeHead(502, { 'Content-Type': 'application/json' });
} }
@@ -231,26 +240,30 @@ export class HttpsTunnelProxy {
upstreamReq.on('timeout', () => { upstreamReq.on('timeout', () => {
const timeoutError = new Error('Upstream request timeout'); const timeoutError = new Error('Upstream request timeout');
this.log(`Timeout: ${timeoutError.message}`); logger.warn('tunnel.upstream_timeout', timeoutError.message);
upstreamReq.destroy(); upstreamReq.destroy();
reject(timeoutError); reject(timeoutError);
}); });
upstreamReq.on('error', (err) => { upstreamReq.on('error', (err) => {
this.log(`Upstream error: ${err.message}`); logger.error('tunnel.upstream_error', 'Upstream request error', {
err: { name: err.name, message: err.message },
});
reject(err); reject(err);
}); });
// Handle client disconnect (premature close) // Handle client disconnect (premature close)
originalReq.on('error', (err) => { originalReq.on('error', (err) => {
this.log(`Client request error: ${err.message}`); logger.error('tunnel.client_request_error', 'Client request error', {
err: { name: err.name, message: err.message },
});
upstreamReq.destroy(); upstreamReq.destroy();
reject(err); reject(err);
}); });
originalReq.on('close', () => { originalReq.on('close', () => {
if (!originalReq.complete) { if (!originalReq.complete) {
this.log('Client disconnected prematurely'); logger.warn('tunnel.client_premature_close', 'Client disconnected prematurely');
upstreamReq.destroy(); upstreamReq.destroy();
} }
}); });
+77 -96
View File
@@ -12,9 +12,6 @@
import * as http from 'http'; import * as http from 'http';
import * as https from 'https'; import * as https from 'https';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { URL } from 'url'; import { URL } from 'url';
import { ToolNameMapper, type Tool, type ContentBlock } from '../ai-providers/tool-name-mapper'; import { ToolNameMapper, type Tool, type ContentBlock } from '../ai-providers/tool-name-mapper';
import { sanitizeToolSchemas } from '../ai-providers/schema-sanitizer'; import { sanitizeToolSchemas } from '../ai-providers/schema-sanitizer';
@@ -28,7 +25,6 @@ import {
import { getModelMaxLevel } from '../model-catalog'; import { getModelMaxLevel } from '../model-catalog';
import { createLogger } from '../../services/logging'; import { createLogger } from '../../services/logging';
import { getCcsDir } from '../../config/config-loader-facade';
import { import {
attachUpstreamResponseTimeout, attachUpstreamResponseTimeout,
writeForwardResponseHead, writeForwardResponseHead,
@@ -273,8 +269,6 @@ export class ToolSanitizationProxy {
private server: http.Server | null = null; private server: http.Server | null = null;
private port: number | null = null; private port: number | null = null;
private readonly config: Required<ToolSanitizationProxyConfig>; private readonly config: Required<ToolSanitizationProxyConfig>;
private readonly logFilePath: string;
private readonly debugMode: boolean;
private readonly logger = createLogger('cliproxy:tool-sanitization-proxy'); private readonly logger = createLogger('cliproxy:tool-sanitization-proxy');
constructor(config: ToolSanitizationProxyConfig) { constructor(config: ToolSanitizationProxyConfig) {
@@ -285,69 +279,6 @@ export class ToolSanitizationProxy {
timeoutMs: config.timeoutMs ?? 120000, timeoutMs: config.timeoutMs ?? 120000,
allowSelfSigned: config.allowSelfSigned ?? false, allowSelfSigned: config.allowSelfSigned ?? false,
}; };
this.debugMode = process.env.CCS_DEBUG === '1';
this.logFilePath = this.initLogFile();
}
/**
* Initialize log file path and ensure directory exists.
*/
private initLogFile(): string {
const logsDir = path.join(getCcsDir(), 'logs');
try {
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
} catch (err) {
// Fallback to temp directory if logs dir creation fails
if (this.debugMode) {
console.error(
`[tool-sanitization-proxy] Failed to create logs dir: ${(err as Error).message}`
);
}
return path.join(os.tmpdir(), 'tool-sanitization-proxy.log');
}
return path.join(logsDir, 'tool-sanitization-proxy.log');
}
/**
* Write log entry to file (always) and console (if CCS_DEBUG=1).
*/
private writeLog(level: 'info' | 'warn' | 'error', message: string): void {
const timestamp = new Date().toISOString();
const prefix = level === 'info' ? '[i]' : level === 'warn' ? '[!]' : '[X]';
const logLine = `${timestamp} ${prefix} ${message}\n`;
// Always write to file
try {
fs.appendFileSync(this.logFilePath, logLine);
} catch {
// Silently ignore file write errors
}
// Console output only in debug mode
if (this.debugMode) {
console.error(`${prefix} ${message}`);
}
this.logger[level](level, message, {
debugMode: this.debugMode,
logFilePath: this.logFilePath,
});
}
private log(message: string): void {
if (this.config.verbose) {
this.writeLog('info', `[tool-sanitization-proxy] ${message}`);
}
}
private warn(message: string): void {
if (this.config.warnOnSanitize) {
this.writeLog('warn', `Tool name sanitized: ${message}`);
}
} }
/** /**
@@ -365,7 +296,10 @@ export class ToolSanitizationProxy {
this.server.listen(0, '127.0.0.1', () => { this.server.listen(0, '127.0.0.1', () => {
const address = this.server?.address(); const address = this.server?.address();
this.port = typeof address === 'object' && address ? address.port : 0; this.port = typeof address === 'object' && address ? address.port : 0;
this.writeLog('info', `Tool sanitization proxy active (port ${this.port})`); this.logger.info(
'tool-sanitization.proxy.active',
`Tool sanitization proxy active (port ${this.port})`
);
resolve(this.port); resolve(this.port);
}); });
@@ -418,7 +352,12 @@ export class ToolSanitizationProxy {
const fullUpstreamUrl = new URL(requestPath, upstreamBase); const fullUpstreamUrl = new URL(requestPath, upstreamBase);
const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname); const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname);
this.log(`${method} ${requestPath} → ${fullUpstreamUrl.href}`); if (this.config.verbose) {
this.logger.info(
'tool-sanitization.proxy.request',
`${method} ${requestPath} → ${fullUpstreamUrl.href}`
);
}
// Only buffer+rewrite JSON POST requests // Only buffer+rewrite JSON POST requests
const contentType = String(req.headers['content-type'] || ''); const contentType = String(req.headers['content-type'] || '');
@@ -458,9 +397,14 @@ export class ToolSanitizationProxy {
} }
const normalizedModel = normalizeModelIdForRouting(modifiedBody.model, providerFromPath); const normalizedModel = normalizeModelIdForRouting(modifiedBody.model, providerFromPath);
if (normalizedModel !== modifiedBody.model) { if (normalizedModel !== modifiedBody.model) {
this.writeLog( this.logger.warn(
'warn', 'tool-sanitization.proxy.model-normalized',
`[tool-sanitization-proxy] Model normalized for provider routing (${providerFromPath ?? 'root'}): "${modifiedBody.model}" → "${normalizedModel}"` `Model normalized for provider routing (${providerFromPath ?? 'root'}): "${modifiedBody.model}" → "${normalizedModel}"`,
{
provider: providerFromPath ?? 'root',
from: modifiedBody.model,
to: normalizedModel,
}
); );
modifiedBody = { ...modifiedBody, model: normalizedModel }; modifiedBody = { ...modifiedBody, model: normalizedModel };
} }
@@ -480,15 +424,23 @@ export class ToolSanitizationProxy {
if (schemaResult.totalRemoved > 0) { if (schemaResult.totalRemoved > 0) {
for (const entry of schemaResult.removedByTool) { for (const entry of schemaResult.removedByTool) {
this.writeLog( this.logger.warn(
'warn', 'tool-sanitization.proxy.schema-sanitized',
`[tool-sanitization-proxy] Schema sanitized for "${entry.name}": removed ${entry.removed.length} Gemini-unsupported properties` `Schema sanitized for "${entry.name}": removed ${entry.removed.length} Gemini-unsupported properties`,
{ tool: entry.name, removedFields: entry.removed }
); );
} }
this.log( if (this.config.verbose) {
`Sanitized ${schemaResult.totalRemoved} schema properties across ${schemaResult.removedByTool.length} tool(s)` this.logger.info(
'tool-sanitization.proxy.schema-summary',
`Sanitized ${schemaResult.totalRemoved} schema properties across ${schemaResult.removedByTool.length} tool(s)`,
{
totalRemoved: schemaResult.totalRemoved,
toolCount: schemaResult.removedByTool.length,
}
); );
} }
}
let rewrittenTools = schemaResult.tools as Tool[]; let rewrittenTools = schemaResult.tools as Tool[];
const unsupportedToolFields = const unsupportedToolFields =
@@ -501,15 +453,27 @@ export class ToolSanitizationProxy {
if (fieldResult.totalRemoved > 0) { if (fieldResult.totalRemoved > 0) {
for (const entry of fieldResult.removedByTool) { for (const entry of fieldResult.removedByTool) {
this.writeLog( this.logger.warn(
'warn', 'tool-sanitization.proxy.fields-stripped',
`[tool-sanitization-proxy] Tool fields stripped for "${entry.name}" (${providerFromPath ?? 'model-routed'}): ${entry.removed.join(', ')}` `Tool fields stripped for "${entry.name}" (${providerFromPath ?? 'model-routed'}): ${entry.removed.join(', ')}`,
{
tool: entry.name,
provider: providerFromPath ?? 'model-routed',
removedFields: entry.removed,
}
); );
} }
this.log( if (this.config.verbose) {
`Stripped ${fieldResult.totalRemoved} unsupported top-level tool field(s) across ${fieldResult.removedByTool.length} tool(s)` this.logger.info(
'tool-sanitization.proxy.fields-summary',
`Stripped ${fieldResult.totalRemoved} unsupported top-level tool field(s) across ${fieldResult.removedByTool.length} tool(s)`,
{
totalRemoved: fieldResult.totalRemoved,
toolCount: fieldResult.removedByTool.length,
}
); );
} }
}
rewrittenTools = fieldResult.tools; rewrittenTools = fieldResult.tools;
} }
@@ -521,19 +485,32 @@ export class ToolSanitizationProxy {
// Log sanitization warnings // Log sanitization warnings
if (mapper.hasChanges()) { if (mapper.hasChanges()) {
const changes = mapper.getChanges(); const changes = mapper.getChanges();
if (this.config.warnOnSanitize) {
for (const change of changes) { for (const change of changes) {
this.warn(`"${change.original}" → "${change.sanitized}"`); this.logger.warn(
'tool-sanitization.proxy.name-sanitized',
`Tool name sanitized: "${change.original}" → "${change.sanitized}"`,
{ from: change.original, to: change.sanitized }
);
}
}
if (this.config.verbose) {
this.logger.info(
'tool-sanitization.proxy.name-summary',
`Sanitized ${changes.length} tool name(s)`,
{ count: changes.length }
);
} }
this.log(`Sanitized ${changes.length} tool name(s)`);
} }
// Warn about hash collisions (multiple originals → same sanitized) // Warn about hash collisions (multiple originals → same sanitized)
if (mapper.hasCollisions()) { if (mapper.hasCollisions()) {
const collisions = mapper.getCollisions(); const collisions = mapper.getCollisions();
for (const collision of collisions) { for (const collision of collisions) {
this.writeLog( this.logger.warn(
'warn', 'tool-sanitization.proxy.hash-collision',
`[tool-sanitization-proxy] Hash collision detected: ${collision.originals.join(', ')} → "${collision.sanitized}"` `Hash collision detected: ${collision.originals.join(', ')} → "${collision.sanitized}"`,
{ originals: collision.originals, sanitized: collision.sanitized }
); );
} }
} }
@@ -549,7 +526,11 @@ export class ToolSanitizationProxy {
} }
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
this.log(`Error: ${err.message}`); if (this.config.verbose) {
this.logger.error('tool-sanitization.proxy.request-error', `Error: ${err.message}`, {
error: err.message,
});
}
if (!res.headersSent) { if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' }); res.writeHead(502, { 'Content-Type': 'application/json' });
} }
@@ -845,9 +826,9 @@ export class ToolSanitizationProxy {
clearUpstreamResponseTimeout(); clearUpstreamResponseTimeout();
try { try {
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) { if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
this.writeLog( this.logger.warn(
'warn', 'tool-sanitization.proxy.empty-response',
'[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.' 'Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
); );
clientRes.write( clientRes.write(
this.buildSyntheticErrorResponse( this.buildSyntheticErrorResponse(
@@ -903,9 +884,9 @@ export class ToolSanitizationProxy {
// Safety net: if upstream sent data but no content blocks, inject synthetic response // Safety net: if upstream sent data but no content blocks, inject synthetic response
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) { if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
this.writeLog( this.logger.warn(
'warn', 'tool-sanitization.proxy.empty-response',
'[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.' 'Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
); );
clientRes.write( clientRes.write(
this.buildSyntheticErrorResponse( this.buildSyntheticErrorResponse(
+19 -5
View File
@@ -14,6 +14,9 @@ import {
buildClaudeQuotaWindows, buildClaudeQuotaWindows,
buildClaudeCoreUsageSummary, buildClaudeCoreUsageSummary,
} from './quota-fetcher-claude-normalizer'; } from './quota-fetcher-claude-normalizer';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:quota:claude');
export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary }; export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary };
@@ -250,7 +253,11 @@ async function runClaudeUsageFetch(
}); });
if (verbose) { if (verbose) {
console.error(`[i] Claude OAuth usage status: ${response.status} (attempt ${attempt})`); logger.info('quota.fetch.status', `Claude OAuth usage status: ${response.status}`, {
provider: 'claude',
status: response.status,
attempt,
});
} }
if (response.status === 401) { if (response.status === 401) {
@@ -331,10 +338,17 @@ async function runClaudeUsageFetch(
: 'Unknown error'; : 'Unknown error';
if (verbose) { if (verbose) {
const errorDetails = logger.warn(
error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error); 'quota.fetch.failed',
console.error( `Claude OAuth usage failed (attempt ${attempt}): ${lastError}`,
`[!] Claude OAuth usage failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}` {
provider: 'claude',
attempt,
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
}
); );
} }
+51 -10
View File
@@ -13,6 +13,9 @@ import { sanitizeEmail, isTokenExpired } from '../auth/auth-utils';
import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types'; import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types';
import { sanitizeCodexFeatureLabel } from './quota-label-sanitizer'; import { sanitizeCodexFeatureLabel } from './quota-label-sanitizer';
import { extractCanonicalEmailFromAccountId } from '../accounts/email-account-identity'; import { extractCanonicalEmailFromAccountId } from '../accounts/email-account-identity';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:quota:codex');
/** ChatGPT backend API base URL */ /** ChatGPT backend API base URL */
const CODEX_API_BASE = 'https://chatgpt.com/backend-api'; const CODEX_API_BASE = 'https://chatgpt.com/backend-api';
@@ -628,12 +631,17 @@ export async function fetchCodexQuota(
accountId: string, accountId: string,
verbose = false verbose = false
): Promise<CodexQuotaResult> { ): Promise<CodexQuotaResult> {
if (verbose) console.error(`[i] Fetching Codex quota for ${accountId}...`); if (verbose)
logger.info('quota.fetch.start', 'Fetching Codex quota for account', { provider: 'codex' });
const authData = readCodexAuthData(accountId); const authData = readCodexAuthData(accountId);
if (!authData) { if (!authData) {
const error = 'Auth file not found for Codex account'; const error = 'Auth file not found for Codex account';
if (verbose) console.error(`[!] Error: ${error}`); if (verbose)
logger.warn('quota.fetch.auth_missing', error, {
provider: 'codex',
errorCode: 'auth_file_missing',
});
return buildCodexFailureResult(accountId, { return buildCodexFailureResult(accountId, {
error, error,
errorCode: 'auth_file_missing', errorCode: 'auth_file_missing',
@@ -644,7 +652,11 @@ export async function fetchCodexQuota(
if (authData.isExpired) { if (authData.isExpired) {
const error = 'Token expired - re-authenticate with ccs cliproxy auth codex'; const error = 'Token expired - re-authenticate with ccs cliproxy auth codex';
if (verbose) console.error(`[!] Error: ${error}`); if (verbose)
logger.warn('quota.fetch.token_expired', error, {
provider: 'codex',
errorCode: 'token_expired',
});
return buildCodexFailureResult(accountId, { return buildCodexFailureResult(accountId, {
error, error,
errorCode: 'token_expired', errorCode: 'token_expired',
@@ -656,7 +668,11 @@ export async function fetchCodexQuota(
if (!authData.accountId) { if (!authData.accountId) {
const error = 'Missing ChatGPT-Account-Id in auth file'; const error = 'Missing ChatGPT-Account-Id in auth file';
if (verbose) console.error(`[!] Error: ${error}`); if (verbose)
logger.warn('quota.fetch.missing_account_id', error, {
provider: 'codex',
errorCode: 'missing_account_id',
});
return buildCodexFailureResult(accountId, { return buildCodexFailureResult(accountId, {
error, error,
errorCode: 'missing_account_id', errorCode: 'missing_account_id',
@@ -685,7 +701,12 @@ export async function fetchCodexQuota(
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (verbose) console.error(`[i] Codex API status: ${response.status} (attempt ${attempt})`); if (verbose)
logger.info('quota.fetch.status', `Codex API status: ${response.status}`, {
provider: 'codex',
status: response.status,
attempt,
});
if (!response.ok) { if (!response.ok) {
const bodyText = await response.text(); const bodyText = await response.text();
@@ -696,10 +717,14 @@ export async function fetchCodexQuota(
const windows = buildCodexQuotaWindows(data); const windows = buildCodexQuotaWindows(data);
const unknownWindowLabels = getUnknownCodexWindowLabels(windows); const unknownWindowLabels = getUnknownCodexWindowLabels(windows);
if (unknownWindowLabels.length > 0 && shouldLogCodexWindowWarnings(verbose)) { if (unknownWindowLabels.length > 0 && shouldLogCodexWindowWarnings(verbose)) {
console.error( logger.warn(
`[!] Codex quota detected unknown window labels: ${unknownWindowLabels.join(', ')}` 'quota.fetch.unknown_window_labels',
'Codex quota detected unknown window labels; window classification may need an update for upstream API changes',
{
provider: 'codex',
labels: unknownWindowLabels,
}
); );
console.error(' Window classification may need an update for upstream API changes.');
} }
const coreUsage = buildCodexCoreUsageSummary(windows); const coreUsage = buildCodexCoreUsageSummary(windows);
@@ -714,7 +739,11 @@ export async function fetchCodexQuota(
else if (normalized === 'team') planType = 'team'; else if (normalized === 'team') planType = 'team';
} }
if (verbose) console.error(`[i] Codex windows found: ${windows.length}`); if (verbose)
logger.info('quota.fetch.windows', `Codex windows found: ${windows.length}`, {
provider: 'codex',
count: windows.length,
});
return { return {
success: true, success: true,
@@ -734,7 +763,19 @@ export async function fetchCodexQuota(
: 'Unknown error'; : 'Unknown error';
if (verbose) { if (verbose) {
console.error(`[!] Codex quota error (attempt ${attempt}): ${lastErrorMsg}`); logger.warn(
'quota.fetch.failed',
`Codex quota error (attempt ${attempt}): ${lastErrorMsg}`,
{
provider: 'codex',
attempt,
errorCode: isAbortError ? 'network_timeout' : 'network_error',
err:
err instanceof Error
? { name: err.name, message: err.message }
: { message: String(err) },
}
);
} }
// Retry timeout once; other failures return immediately. // Retry timeout once; other failures return immediately.
+60 -11
View File
@@ -28,6 +28,12 @@ import {
normalizeProviderTierId, normalizeProviderTierId,
} from '../auth/provider-entitlement-evidence'; } from '../auth/provider-entitlement-evidence';
import type { ProviderEntitlementEvidence } from '../auth/provider-entitlement-types'; import type { ProviderEntitlementEvidence } from '../auth/provider-entitlement-types';
import { createLogger } from '../../services/logging';
// Diagnostic-only logger: quota fetch progress, upstream HTTP status, and
// recovery hints. accountId is attached as provider context; token values
// are never logged (they live in auth files and are not read into messages).
const logger = createLogger('cliproxy:quota:gemini-cli');
/** Google Cloud Code API endpoints */ /** Google Cloud Code API endpoints */
const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com'; const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com';
@@ -620,8 +626,10 @@ async function fetchGeminiCliSupplementary(
if (response.status !== 200) { if (response.status !== 200) {
if (verbose) { if (verbose) {
const source = response.viaManagement ? 'managed' : 'direct'; const source = response.viaManagement ? 'managed' : 'direct';
console.error( logger.info(
`[i] Gemini CLI supplementary metadata unavailable via ${source}: HTTP ${response.status}` 'gemini_cli.supplementary_metadata_unavailable',
`Gemini CLI supplementary metadata unavailable via ${source}: HTTP ${response.status}`,
{ provider: 'gemini', accountId, httpStatus: response.status, source }
); );
} }
return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' }; return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' };
@@ -637,7 +645,15 @@ async function fetchGeminiCliSupplementary(
} catch (error) { } catch (error) {
if (verbose) { if (verbose) {
const message = error instanceof Error ? error.message : 'Unknown error'; const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`[i] Gemini CLI supplementary metadata skipped: ${message}`); logger.info(
'gemini_cli.supplementary_metadata_skipped',
`Gemini CLI supplementary metadata skipped: ${message}`,
{
provider: 'gemini',
accountId,
err: error instanceof Error ? { name: error.name, message } : { message },
}
);
} }
return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' }; return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' };
} }
@@ -954,7 +970,12 @@ async function fetchWithAuthData(
): Promise<GeminiCliQuotaResult> { ): Promise<GeminiCliQuotaResult> {
if (!authData.projectId) { if (!authData.projectId) {
const error = 'Cannot resolve project ID from auth file'; const error = 'Cannot resolve project ID from auth file';
if (verbose) console.error(`[!] Error: ${error}`); if (verbose) {
logger.error('gemini_cli.missing_project_id', `Error: ${error}`, {
provider: 'gemini',
accountId,
});
}
return buildGeminiCliFailureResult(accountId, null, { return buildGeminiCliFailureResult(accountId, null, {
error, error,
errorCode: 'missing_project_id', errorCode: 'missing_project_id',
@@ -985,7 +1006,11 @@ async function fetchWithAuthData(
if (verbose) { if (verbose) {
const source = response.viaManagement ? 'managed' : 'direct'; const source = response.viaManagement ? 'managed' : 'direct';
console.error(`[i] Gemini CLI API status via ${source}: ${response.status}`); logger.info(
'gemini_cli.api_status',
`Gemini CLI API status via ${source}: ${response.status}`,
{ provider: 'gemini', accountId, httpStatus: response.status, source }
);
} }
if (response.status !== 200) { if (response.status !== 200) {
@@ -1002,7 +1027,13 @@ async function fetchWithAuthData(
const buckets = buildGeminiCliBuckets(rawBuckets); const buckets = buildGeminiCliBuckets(rawBuckets);
const supplementary = await supplementaryPromise; const supplementary = await supplementaryPromise;
if (verbose) console.error(`[i] Gemini CLI buckets found: ${buckets.length}`); if (verbose) {
logger.info('gemini_cli.buckets_found', `Gemini CLI buckets found: ${buckets.length}`, {
provider: 'gemini',
accountId,
bucketCount: buckets.length,
});
}
if (supplementary.normalizedTier !== 'unknown') { if (supplementary.normalizedTier !== 'unknown') {
setAccountTier('gemini', accountId, supplementary.normalizedTier); setAccountTier('gemini', accountId, supplementary.normalizedTier);
@@ -1045,7 +1076,13 @@ async function fetchWithAuthData(
? err.message ? err.message
: 'Unknown error'; : 'Unknown error';
if (verbose) console.error(`[!] Gemini CLI quota error: ${errorMsg}`); if (verbose) {
logger.error('gemini_cli.quota_fetch_error', `Gemini CLI quota error: ${errorMsg}`, {
provider: 'gemini',
accountId,
err: err instanceof Error ? { name: err.name, message: errorMsg } : { message: errorMsg },
});
}
return buildGeminiCliFailureResult(accountId, authData.projectId, { return buildGeminiCliFailureResult(accountId, authData.projectId, {
error: errorMsg, error: errorMsg,
@@ -1069,12 +1106,22 @@ export async function fetchGeminiCliQuota(
accountId: string, accountId: string,
verbose = false verbose = false
): Promise<GeminiCliQuotaResult> { ): Promise<GeminiCliQuotaResult> {
if (verbose) console.error(`[i] Fetching Gemini CLI quota for ${accountId}...`); if (verbose) {
logger.info('gemini_cli.fetch_start', `Fetching Gemini CLI quota for ${accountId}...`, {
provider: 'gemini',
accountId,
});
}
const authData = readGeminiCliAuthData(accountId); const authData = readGeminiCliAuthData(accountId);
if (!authData) { if (!authData) {
const error = 'Auth file not found for Gemini account'; const error = 'Auth file not found for Gemini account';
if (verbose) console.error(`[!] Error: ${error}`); if (verbose) {
logger.error('gemini_cli.auth_file_missing', `Error: ${error}`, {
provider: 'gemini',
accountId,
});
}
return buildGeminiCliFailureResult(accountId, null, { return buildGeminiCliFailureResult(accountId, null, {
error, error,
errorCode: 'auth_file_missing', errorCode: 'auth_file_missing',
@@ -1086,8 +1133,10 @@ export async function fetchGeminiCliQuota(
if (authData.isExpired && verbose) { if (authData.isExpired && verbose) {
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
const expiryLabel = expiresAt ? new Date(expiresAt).toISOString() : 'unknown'; const expiryLabel = expiresAt ? new Date(expiresAt).toISOString() : 'unknown';
console.error( logger.info(
`[i] Gemini access token is expired (${expiryLabel}); quota requests will defer to managed auth when available.` 'gemini_cli.token_expired',
`Gemini access token is expired (${expiryLabel}); quota requests will defer to managed auth when available.`,
{ provider: 'gemini', accountId, tokenExpired: true, expiresAt: expiryLabel }
); );
} }
+20 -2
View File
@@ -9,6 +9,13 @@ import * as fs from 'node:fs';
import { getAccountTokenPath, getProviderAccounts } from '../accounts/account-manager'; import { getAccountTokenPath, getProviderAccounts } from '../accounts/account-manager';
import type { GhcpQuotaResult, GhcpQuotaSnapshot } from './quota-types'; import type { GhcpQuotaResult, GhcpQuotaSnapshot } from './quota-types';
import { clampPercent } from '../../utils/percentage'; import { clampPercent } from '../../utils/percentage';
import { createLogger } from '../../services/logging';
// Diagnostic-only logger: token load failures, fetch progress, and upstream
// error reasons. accountId is attached as provider context; token values are
// never logged (the error string from readGhcpAccessToken is generic and
// contains no token material).
const logger = createLogger('cliproxy:quota:ghcp');
const GHCP_USAGE_URL = 'https://api.github.com/copilot_internal/user'; const GHCP_USAGE_URL = 'https://api.github.com/copilot_internal/user';
const GHCP_USAGE_TIMEOUT_MS = 10000; const GHCP_USAGE_TIMEOUT_MS = 10000;
@@ -174,11 +181,22 @@ export async function fetchGhcpQuota(accountId: string, verbose = false): Promis
const { accessToken, error } = readGhcpAccessToken(accountId); const { accessToken, error } = readGhcpAccessToken(accountId);
if (!accessToken) { if (!accessToken) {
// Safe diagnostic: accountId + generic error only (never log token values/file contents). // Safe diagnostic: accountId + generic error only (never log token values/file contents).
if (verbose) console.error(`[!] ghcp quota token error (${accountId}): ${error}`); if (verbose) {
logger.error('ghcp.token_load_error', `ghcp quota token error (${accountId}): ${error}`, {
provider: 'ghcp',
accountId,
reason: error ?? 'unknown',
});
}
return buildEmptyQuotaResult(error || 'Failed to load auth token', accountId); return buildEmptyQuotaResult(error || 'Failed to load auth token', accountId);
} }
if (verbose) console.error(`[i] Fetching ghcp quota for ${accountId}...`); if (verbose) {
logger.info('ghcp.fetch_start', `Fetching ghcp quota for ${accountId}...`, {
provider: 'ghcp',
accountId,
});
}
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), GHCP_USAGE_TIMEOUT_MS); const timeoutId = setTimeout(() => controller.abort(), GHCP_USAGE_TIMEOUT_MS);
+26 -7
View File
@@ -28,6 +28,9 @@ import {
buildProxyUrl, buildProxyUrl,
getProxyTarget, getProxyTarget,
} from '../proxy/proxy-target-resolver'; } from '../proxy/proxy-target-resolver';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:quota:fetcher');
/** Individual model quota info */ /** Individual model quota info */
export interface ModelQuota { export interface ModelQuota {
@@ -829,12 +832,13 @@ export async function fetchAccountQuota(
accountId: string, accountId: string,
verbose = false verbose = false
): Promise<QuotaResult> { ): Promise<QuotaResult> {
if (verbose) console.error(`[i] Fetching quota for ${accountId}...`); if (verbose)
logger.info('quota.fetch.start', 'Fetching quota for account', { provider, accountId });
// Only Antigravity supports quota fetching // Only Antigravity supports quota fetching
if (provider !== 'agy') { if (provider !== 'agy') {
const error = `Quota not supported for provider: ${provider}`; const error = `Quota not supported for provider: ${provider}`;
if (verbose) console.error(`[!] Error: ${error}`); if (verbose) logger.warn('quota.fetch.unsupported_provider', error, { provider });
// Stable machine code so callers branch on a code, not the human string. // Stable machine code so callers branch on a code, not the human string.
// This is "no quota API for this provider", which is healthy — distinct // This is "no quota API for this provider", which is healthy — distinct
// from a transient fetch failure or an expired token. // from a transient fetch failure or an expired token.
@@ -851,7 +855,7 @@ export async function fetchAccountQuota(
const authData = readAuthData(provider, accountId); const authData = readAuthData(provider, accountId);
if (!authData) { if (!authData) {
const error = 'Auth file not found for account'; const error = 'Auth file not found for account';
if (verbose) console.error(`[!] Error: ${error}`); if (verbose) logger.warn('quota.fetch.auth_missing', error, { provider, accountId });
return { return {
success: false, success: false,
models: [], models: [],
@@ -869,7 +873,10 @@ export async function fetchAccountQuota(
: authData.expiresAt : authData.expiresAt
? `expires ${authData.expiresAt}` ? `expires ${authData.expiresAt}`
: 'expiry unknown'; : 'expiry unknown';
console.error(`[i] Auth token state: ${expiryState}`); logger.info('quota.fetch.auth_state', `Auth token state: ${expiryState}`, {
provider,
state: expiryState,
});
} }
// Get project ID and tier - prefer stored project ID, but always call API for tier // Get project ID and tier - prefer stored project ID, but always call API for tier
@@ -884,7 +891,12 @@ export async function fetchAccountQuota(
if (!lastProjectResult.projectId && !projectId) { if (!lastProjectResult.projectId && !projectId) {
const error = lastProjectResult.error || 'Failed to retrieve project ID'; const error = lastProjectResult.error || 'Failed to retrieve project ID';
if (verbose) console.error(`[!] Error: ${error}`); if (verbose)
logger.warn('quota.fetch.project_lookup_failed', error, {
provider,
errorCode: lastProjectResult.errorCode,
httpStatus: lastProjectResult.httpStatus,
});
return { return {
success: false, success: false,
models: [], models: [],
@@ -909,12 +921,19 @@ export async function fetchAccountQuota(
rawTierId = lastProjectResult.rawTierId || null; rawTierId = lastProjectResult.rawTierId || null;
rawTierLabel = lastProjectResult.rawTierLabel || null; rawTierLabel = lastProjectResult.rawTierLabel || null;
if (verbose) console.error(`[i] Project ID: ${projectId || 'not found'}`); if (verbose)
logger.info('quota.fetch.project_resolved', `Project ID: ${projectId || 'not found'}`, {
provider,
});
// Fetch models with quota // Fetch models with quota
const result = await fetchAvailableModels(accountId, accessToken, projectId as string); const result = await fetchAvailableModels(accountId, accessToken, projectId as string);
if (verbose) console.error(`[i] Models found: ${result.models.length}`); if (verbose)
logger.info('quota.fetch.models', `Models found: ${result.models.length}`, {
provider,
count: result.models.length,
});
result.accountId = accountId; result.accountId = accountId;
result.projectId = projectId || undefined; result.projectId = projectId || undefined;
+15 -4
View File
@@ -12,6 +12,12 @@ import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loa
import { getInstalledCliproxyVersion } from '../binary-manager'; import { getInstalledCliproxyVersion } from '../binary-manager';
import { compareVersions } from '../../utils/update-checker'; import { compareVersions } from '../../utils/update-checker';
import { getConfigYamlPath } from '../../config/loader/io-locks'; import { getConfigYamlPath } from '../../config/loader/io-locks';
import { createLogger } from '../../services/logging';
// Diagnostic-only logger for internal binary-compatibility notices. The
// user-facing result of enablePoolRouting is returned via the result
// message; this logger captures the version-compat caveat for diagnostics.
const logger = createLogger('cliproxy:routing:strategy');
export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin'; export const DEFAULT_CLIPROXY_ROUTING_STRATEGY: CliproxyRoutingStrategy = 'round-robin';
export const DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED = false; export const DEFAULT_CLIPROXY_SESSION_AFFINITY_ENABLED = false;
@@ -205,10 +211,15 @@ export function enablePoolRouting(
try { try {
const installedVersion = getInstalledCliproxyVersion(); const installedVersion = getInstalledCliproxyVersion();
if (compareVersions(installedVersion, POOL_ROUTING_MIN_VERSION) < 0) { if (compareVersions(installedVersion, POOL_ROUTING_MIN_VERSION) < 0) {
console.warn( logger.warn(
`[!] CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}).\n` + 'pool_routing.binary_below_minimum',
` The max-retry-credentials and cooling keys may be silently ignored by the running binary.\n` + `CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}). ` +
` Run 'ccs cliproxy --latest' to update CLIProxy, then restart with 'ccs cliproxy restart'.` `The max-retry-credentials and cooling keys may be silently ignored by the running binary. ` +
`Run 'ccs cliproxy --latest' to update CLIProxy, then restart with 'ccs cliproxy restart'.`,
{
installedVersion,
minimumVersion: POOL_ROUTING_MIN_VERSION,
}
); );
} }
} catch { } catch {
+24 -24
View File
@@ -207,20 +207,20 @@ export async function executeCopilotProfile(
try { try {
await ensureCopilotApi(); await ensureCopilotApi();
} catch (error) { } catch (error) {
console.error(fail('Failed to install copilot-api.')); process.stderr.write(String(fail('Failed to install copilot-api.')) + '\n');
console.error(''); process.stderr.write('\n');
console.error(`Error: ${(error as Error).message}`); process.stderr.write(String(`Error: ${(error as Error).message}`) + '\n');
console.error(''); process.stderr.write('\n');
console.error('Try installing manually:'); process.stderr.write('Try installing manually:\n');
console.error(' npm install -g copilot-api'); process.stderr.write(' npm install -g copilot-api\n');
return 1; return 1;
} }
// Check if copilot-api is installed (should be after ensureCopilotApi) // Check if copilot-api is installed (should be after ensureCopilotApi)
if (!isCopilotApiInstalled()) { if (!isCopilotApiInstalled()) {
console.error(fail('copilot-api is not installed.')); process.stderr.write(String(fail('copilot-api is not installed.')) + '\n');
console.error(''); process.stderr.write('\n');
console.error('Install/repair by running: ccs copilot start'); process.stderr.write('Install/repair by running: ccs copilot start\n');
return 1; return 1;
} }
@@ -231,10 +231,10 @@ export async function executeCopilotProfile(
authenticated: authStatus.authenticated, authenticated: authStatus.authenticated,
}); });
if (!authStatus.authenticated) { if (!authStatus.authenticated) {
console.error(fail('Not authenticated with GitHub.')); process.stderr.write(String(fail('Not authenticated with GitHub.')) + '\n');
console.error(''); process.stderr.write('\n');
console.error('Run: npx copilot-api auth'); process.stderr.write('Run: npx copilot-api auth\n');
console.error('Or: ccs copilot auth'); process.stderr.write('Or: ccs copilot auth\n');
return 1; return 1;
} }
@@ -246,21 +246,21 @@ export async function executeCopilotProfile(
console.log(info('Starting copilot-api daemon...')); console.log(info('Starting copilot-api daemon...'));
const result = await startDaemon(normalizedConfig); const result = await startDaemon(normalizedConfig);
if (!result.success) { if (!result.success) {
console.error(fail(`Failed to start daemon: ${result.error}`)); process.stderr.write(String(fail(`Failed to start daemon: ${result.error}`)) + '\n');
return 1; return 1;
} }
console.log(ok(`Daemon started on port ${normalizedConfig.port}`)); console.log(ok(`Daemon started on port ${normalizedConfig.port}`));
daemonRunning = true; daemonRunning = true;
} else { } else {
console.error(fail('copilot-api daemon is not running.')); process.stderr.write(String(fail('copilot-api daemon is not running.')) + '\n');
console.error(''); process.stderr.write('\n');
console.error('Start the daemon:'); process.stderr.write('Start the daemon:\n');
console.error(' ccs copilot start'); process.stderr.write(' ccs copilot start\n');
console.error('Fallback manual command:'); process.stderr.write('Fallback manual command:\n');
console.error(` npx copilot-api start --port ${normalizedConfig.port}`); process.stderr.write(` npx copilot-api start --port ${normalizedConfig.port}\n`);
console.error(''); process.stderr.write('\n');
console.error('Or enable auto_start in config:'); process.stderr.write('Or enable auto_start in config:\n');
console.error(' ccs config (then enable auto_start in Copilot section)'); process.stderr.write(' ccs config (then enable auto_start in Copilot section)\n');
return 1; return 1;
} }
} }
@@ -351,7 +351,7 @@ export async function executeCopilotProfile(
error: { name: err.name, message: err.message }, error: { name: err.name, message: err.message },
} }
); );
console.error(fail(`Failed to start Claude: ${err.message}`)); process.stderr.write(String(fail(`Failed to start Claude: ${err.message}`)) + '\n');
resolve(1); resolve(1);
}); });
}); });
+16 -16
View File
@@ -113,23 +113,23 @@ export async function executeCursorProfile(
claudeCliPath = 'claude' claudeCliPath = 'claude'
): Promise<number> { ): Promise<number> {
if (!config.enabled) { if (!config.enabled) {
console.error(fail('Cursor integration is not enabled.')); process.stderr.write(fail('Cursor integration is not enabled.') + '\n');
console.error(''); process.stderr.write('\n');
console.error('Enable it first: ccs legacy cursor enable'); process.stderr.write('Enable it first: ccs legacy cursor enable\n');
return 1; return 1;
} }
const authStatus = checkAuthStatus(); const authStatus = checkAuthStatus();
if (!authStatus.authenticated) { if (!authStatus.authenticated) {
console.error(fail('Cursor credentials not found.')); process.stderr.write(fail('Cursor credentials not found.') + '\n');
console.error(''); process.stderr.write('\n');
console.error('Authenticate first: ccs legacy cursor auth'); process.stderr.write('Authenticate first: ccs legacy cursor auth\n');
return 1; return 1;
} }
if (authStatus.expired) { if (authStatus.expired) {
console.error(fail('Cursor credentials have expired.')); process.stderr.write(fail('Cursor credentials have expired.') + '\n');
console.error(''); process.stderr.write('\n');
console.error('Refresh them with: ccs legacy cursor auth'); process.stderr.write('Refresh them with: ccs legacy cursor auth\n');
return 1; return 1;
} }
@@ -145,17 +145,17 @@ export async function executeCursorProfile(
daemon_token: daemonToken, daemon_token: daemonToken,
}); });
if (!result.success) { if (!result.success) {
console.error(fail(`Failed to start cursor daemon: ${result.error}`)); process.stderr.write(fail(`Failed to start cursor daemon: ${result.error}`) + '\n');
return 1; return 1;
} }
console.log(ok(`Daemon started on port ${config.port}`)); console.log(ok(`Daemon started on port ${config.port}`));
daemonRunning = true; daemonRunning = true;
} else { } else {
console.error(fail('Cursor daemon is not running.')); process.stderr.write(fail('Cursor daemon is not running.') + '\n');
console.error(''); process.stderr.write('\n');
console.error('Start the daemon:'); process.stderr.write('Start the daemon:\n');
console.error(' ccs legacy cursor start'); process.stderr.write(' ccs legacy cursor start\n');
console.error('Or enable auto_start in the Cursor config section.'); process.stderr.write('Or enable auto_start in the Cursor config section.\n');
return 1; return 1;
} }
} }
@@ -204,7 +204,7 @@ export async function executeCursorProfile(
}); });
proc.on('error', (err) => { proc.on('error', (err) => {
console.error(fail(`Failed to start Claude: ${err.message}`)); process.stderr.write(fail(`Failed to start Claude: ${err.message}`) + '\n');
resolve(1); resolve(1);
}); });
}); });
+35 -23
View File
@@ -7,6 +7,9 @@ import { DelegationValidator } from '../utils/delegation-validator';
import { SettingsParser } from './settings-parser'; import { SettingsParser } from './settings-parser';
import { fail, warn } from '../utils/ui'; import { fail, warn } from '../utils/ui';
import { getCcsDir } from '../config/config-loader-facade'; import { getCcsDir } from '../config/config-loader-facade';
import { createLogger } from '../services/logging';
const logger = createLogger('delegation:handler');
const PROFILE_FLAGS_WITH_VALUE = new Set(['-p', '--prompt', '--effort']); const PROFILE_FLAGS_WITH_VALUE = new Set(['-p', '--prompt', '--effort']);
const PROMPT_FLAGS_WITH_VALUE = new Set(['-p', '--prompt']); const PROMPT_FLAGS_WITH_VALUE = new Set(['-p', '--prompt']);
@@ -85,13 +88,13 @@ function parseStringFlag(
// Reject dash-prefixed values (likely another flag) // Reject dash-prefixed values (likely another flag)
if (!options?.allowDashPrefix && value.startsWith('-')) { if (!options?.allowDashPrefix && value.startsWith('-')) {
console.error(warn(`${flagName} value "${value}" looks like a flag. Ignoring.`)); process.stderr.write(warn(`${flagName} value "${value}" looks like a flag. Ignoring.`) + '\n');
return undefined; return undefined;
} }
// Reject empty/whitespace-only // Reject empty/whitespace-only
if (!value.trim()) { if (!value.trim()) {
console.error(warn(`${flagName} value is empty. Ignoring.`)); process.stderr.write(warn(`${flagName} value is empty. Ignoring.`) + '\n');
return undefined; return undefined;
} }
@@ -149,9 +152,14 @@ export class DelegationHandler {
// 6. Exit with proper code // 6. Exit with proper code
process.exit(result.exitCode || 0); process.exit(result.exitCode || 0);
} catch (error) { } catch (error) {
console.error(fail(`Delegation error: ${(error as Error).message}`)); process.stderr.write(fail(`Delegation error: ${(error as Error).message}`) + '\n');
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error((error as Error).stack); logger.error('delegation.route.failure', 'Delegation route failed', {
err:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: { message: String(error) },
});
} }
process.exit(1); process.exit(1);
} }
@@ -169,8 +177,10 @@ export class DelegationHandler {
const lastSession = sessionMgr.getLastSession(baseProfile); const lastSession = sessionMgr.getLastSession(baseProfile);
if (!lastSession) { if (!lastSession) {
console.error(fail(`No previous session found for ${baseProfile}`)); process.stderr.write(fail(`No previous session found for ${baseProfile}`) + '\n');
console.error(` Start a new session first with: ccs ${baseProfile} -p "task"`); process.stderr.write(
` Start a new session first with: ccs ${baseProfile} -p "task"` + '\n'
);
process.exit(1); process.exit(1);
} }
@@ -256,8 +266,8 @@ export class DelegationHandler {
} }
if (index === -1 || index === args.length - 1) { if (index === -1 || index === args.length - 1) {
console.error(fail('Missing prompt after -p flag')); process.stderr.write(fail('Missing prompt after -p flag') + '\n');
console.error(' Usage: ccs glm -p "task description"'); process.stderr.write(' Usage: ccs glm -p "task description"' + '\n');
process.exit(1); process.exit(1);
} }
@@ -301,11 +311,13 @@ export class DelegationHandler {
if (!isNaN(val) && val > 0 && val <= 600000) { if (!isNaN(val) && val > 0 && val <= 600000) {
options.timeout = val; options.timeout = val;
} else if (isNaN(val)) { } else if (isNaN(val)) {
console.error(warn(`--timeout "${rawVal}" is not a number. Using default.`)); process.stderr.write(warn(`--timeout "${rawVal}" is not a number. Using default.`) + '\n');
} else if (val <= 0) { } else if (val <= 0) {
console.error(warn(`--timeout ${val} must be positive. Using default.`)); process.stderr.write(warn(`--timeout ${val} must be positive. Using default.`) + '\n');
} else if (val > 600000) { } else if (val > 600000) {
console.error(warn(`--timeout ${val} exceeds max (600000ms). Using default.`)); process.stderr.write(
warn(`--timeout ${val} exceeds max (600000ms). Using default.`) + '\n'
);
} }
} }
@@ -322,11 +334,11 @@ export class DelegationHandler {
if (!isNaN(val) && val > 0 && val <= 100) { if (!isNaN(val) && val > 0 && val <= 100) {
options.maxTurns = val; options.maxTurns = val;
} else if (isNaN(val)) { } else if (isNaN(val)) {
console.error(warn(`--max-turns "${rawVal}" is not a number. Ignoring.`)); process.stderr.write(warn(`--max-turns "${rawVal}" is not a number. Ignoring.`) + '\n');
} else if (val <= 0) { } else if (val <= 0) {
console.error(warn(`--max-turns ${val} must be positive. Ignoring.`)); process.stderr.write(warn(`--max-turns ${val} must be positive. Ignoring.`) + '\n');
} else if (val > 100) { } else if (val > 100) {
console.error(warn(`--max-turns ${val} exceeds max (100). Using 100.`)); process.stderr.write(warn(`--max-turns ${val} exceeds max (100). Using 100.`) + '\n');
options.maxTurns = 100; options.maxTurns = 100;
} }
} }
@@ -342,7 +354,7 @@ export class DelegationHandler {
JSON.parse(agentsValue); JSON.parse(agentsValue);
options.agents = agentsValue; options.agents = agentsValue;
} catch { } catch {
console.error(warn('--agents must be valid JSON. Ignoring.')); process.stderr.write(warn('--agents must be valid JSON. Ignoring.') + '\n');
} }
} }
@@ -400,20 +412,20 @@ export class DelegationHandler {
*/ */
_validateProfile(profile: string): void { _validateProfile(profile: string): void {
if (!profile) { if (!profile) {
console.error(fail('No profile specified')); process.stderr.write(fail('No profile specified') + '\n');
console.error(' Usage: ccs <profile> -p "task"'); process.stderr.write(' Usage: ccs <profile> -p "task"' + '\n');
console.error(' Examples: ccs glm -p "task", ccs km -p "task"'); process.stderr.write(' Examples: ccs glm -p "task", ccs km -p "task"' + '\n');
process.exit(1); process.exit(1);
} }
// Use DelegationValidator to check profile // Use DelegationValidator to check profile
const validation = DelegationValidator.validate(profile); const validation = DelegationValidator.validate(profile);
if (!validation.valid) { if (!validation.valid) {
console.error(fail(`Profile '${profile}' is not configured for delegation`)); process.stderr.write(fail(`Profile '${profile}' is not configured for delegation`) + '\n');
console.error(` ${validation.error}`); process.stderr.write(` ${validation.error}` + '\n');
console.error(''); process.stderr.write('' + '\n');
console.error(' Run: ccs doctor'); process.stderr.write(' Run: ccs doctor' + '\n');
console.error(` Or configure: ${getCcsDir()}/${profile}.settings.json`); process.stderr.write(` Or configure: ${getCcsDir()}/${profile}.settings.json` + '\n');
process.exit(1); process.exit(1);
} }
} }
+29 -13
View File
@@ -8,7 +8,7 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import * as path from 'path'; import * as path from 'path';
import { killWithEscalation } from '../utils/process-utils'; import { killWithEscalation } from '../utils/process-utils';
import { forwardRequestIdEnv } from '../services/logging'; import { createLogger, forwardRequestIdEnv } from '../services/logging';
import * as fs from 'fs'; import * as fs from 'fs';
import { SessionManager } from './session-manager'; import { SessionManager } from './session-manager';
import { SettingsParser } from './settings-parser'; import { SettingsParser } from './settings-parser';
@@ -64,6 +64,8 @@ import { getCcsDir, getGlobalEnvConfig, loadSettings } from '../config/config-lo
// Re-export types for consumers // Re-export types for consumers
export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types'; export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types';
const logger = createLogger('delegation:headless-executor');
/** /**
* Headless executor for Claude CLI delegation * Headless executor for Claude CLI delegation
*/ */
@@ -129,10 +131,12 @@ export class HeadlessExecutor {
}); });
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
console.error( process.stderr.write(
String(
info( info(
`Continuity inheritance active: profile "${profile}" -> account "${continuityInheritance.sourceAccount}"` `Continuity inheritance active: profile "${profile}" -> account "${continuityInheritance.sourceAccount}"`
) )
) + '\n'
); );
} }
@@ -198,10 +202,12 @@ export class HeadlessExecutor {
imageAnalysisProvider && imageAnalysisProvider &&
imageAnalysisStatus.effectiveRuntimeMode === 'native-read' imageAnalysisStatus.effectiveRuntimeMode === 'native-read'
) { ) {
console.error( process.stderr.write(
String(
info( info(
`${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This delegation will use native Read.` `${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This delegation will use native Read.`
) )
) + '\n'
); );
imageAnalysisEnv = { imageAnalysisEnv = {
...imageAnalysisEnv, ...imageAnalysisEnv,
@@ -215,10 +221,12 @@ export class HeadlessExecutor {
) { ) {
const ensureServiceResult = await ensureCliproxyService(resolveLifecyclePort(), false); const ensureServiceResult = await ensureCliproxyService(resolveLifecyclePort(), false);
if (!ensureServiceResult.started) { if (!ensureServiceResult.started) {
console.error( process.stderr.write(
String(
warn( warn(
`Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This delegation will use native Read.` `Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This delegation will use native Read.`
) )
) + '\n'
); );
imageAnalysisEnv = { imageAnalysisEnv = {
...imageAnalysisEnv, ...imageAnalysisEnv,
@@ -273,7 +281,9 @@ export class HeadlessExecutor {
if (permissionMode === 'bypassPermissions') { if (permissionMode === 'bypassPermissions') {
args.push('--dangerously-skip-permissions'); args.push('--dangerously-skip-permissions');
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.warn(warn('WARNING: Using --dangerously-skip-permissions mode')); process.stderr.write(
String(warn('WARNING: Using --dangerously-skip-permissions mode')) + '\n'
);
} }
} else { } else {
args.push('--permission-mode', permissionMode); args.push('--permission-mode', permissionMode);
@@ -287,12 +297,16 @@ export class HeadlessExecutor {
args.push('--resume', lastSession.sessionId); args.push('--resume', lastSession.sessionId);
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
const cost = lastSession.totalCost?.toFixed(4) || '0.0000'; const cost = lastSession.totalCost?.toFixed(4) || '0.0000';
console.error(info(`Resuming session: ${lastSession.sessionId} ($${cost})`)); process.stderr.write(
String(info(`Resuming session: ${lastSession.sessionId} ($${cost})`)) + '\n'
);
} }
} else if (sessionId) { } else if (sessionId) {
args.push('--resume', sessionId); args.push('--resume', sessionId);
} else { } else {
console.warn(warn('No previous session found, starting new session')); process.stderr.write(
String(warn('No previous session found, starting new session')) + '\n'
);
} }
} else if (sessionId) { } else if (sessionId) {
args.push('--resume', sessionId); args.push('--resume', sessionId);
@@ -357,7 +371,7 @@ export class HeadlessExecutor {
}); });
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(info(`Claude CLI args: ${launchArgs.join(' ')}`)); logger.info('claude_cli_args', 'Claude CLI args', { args: launchArgs });
} }
// Initialize UI before spawning // Initialize UI before spawning
@@ -420,7 +434,7 @@ export class HeadlessExecutor {
if (showProgress) { if (showProgress) {
const modelName = getModelDisplayName(profile); const modelName = getModelDisplayName(profile);
console.error(ui.info(`Delegating to ${modelName}...`)); process.stderr.write(String(ui.info(`Delegating to ${modelName}...`)) + '\n');
} }
// Strip Claude Code nested session guard env var to allow CCS delegation // Strip Claude Code nested session guard env var to allow CCS delegation
@@ -525,12 +539,14 @@ export class HeadlessExecutor {
if (showProgress) { if (showProgress) {
const durationSec = (duration / 1000).toFixed(1); const durationSec = (duration / 1000).toFixed(1);
console.error( process.stderr.write(
String(
timedOut timedOut
? ui.warn(`Timed out after ${durationSec}s`) ? ui.warn(`Timed out after ${durationSec}s`)
: ui.info(`Completed in ${durationSec}s`) : ui.info(`Completed in ${durationSec}s`)
) + '\n'
); );
console.error(''); process.stderr.write('\n');
} }
const result = buildExecutionResult({ const result = buildExecutionResult({
@@ -664,7 +680,7 @@ export class HeadlessExecutor {
const result = await this.execute(profile, enhancedPrompt, execOptions); const result = await this.execute(profile, enhancedPrompt, execOptions);
if (result.success) return result; if (result.success) return result;
if (attempt < maxRetries) { if (attempt < maxRetries) {
console.error(warn(`Attempt ${attempt + 1} failed, retrying...`)); process.stderr.write(String(warn(`Attempt ${attempt + 1} failed, retrying...`)) + '\n');
await this._sleep(1000 * (attempt + 1)); await this._sleep(1000 * (attempt + 1));
continue; continue;
} }
@@ -672,7 +688,7 @@ export class HeadlessExecutor {
} catch (error) { } catch (error) {
lastError = error as Error; lastError = error as Error;
if (attempt < maxRetries) { if (attempt < maxRetries) {
console.error(warn(`Attempt ${attempt + 1} errored, retrying...`)); process.stderr.write(String(warn(`Attempt ${attempt + 1} errored, retrying...`)) + '\n');
await this._sleep(1000 * (attempt + 1)); await this._sleep(1000 * (attempt + 1));
} }
} }
+31 -24
View File
@@ -2,7 +2,7 @@
* CLI argument parsing and normalization utilities. * CLI argument parsing and normalization utilities.
* *
* Extracted from src/ccs.ts (lines 129-244, 246-296, 371-392). * Extracted from src/ccs.ts (lines 129-244, 246-296, 371-392).
* Pure functions — no side effects except console.error and process.exit. * Pure functions — no side effects except process.stderr.write and process.exit.
* *
* Also contains bootstrapAndParseEarlyCli() — the Phase A bootstrap extracted * Also contains bootstrapAndParseEarlyCli() — the Phase A bootstrap extracted
* from main() (lines 128-232 of the original). Handles: adapter registration, * from main() (lines 128-232 of the original). Handles: adapter registration,
@@ -74,19 +74,21 @@ export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise<Disp
} }
if (!configDirValue || configDirValue.startsWith('-')) { if (!configDirValue || configDirValue.startsWith('-')) {
console.error(fail('--config-dir requires a path argument')); process.stderr.write(String(fail('--config-dir requires a path argument')) + '\n');
process.exit(1); process.exit(1);
} }
try { try {
const stat = fs.statSync(configDirValue); const stat = fs.statSync(configDirValue);
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
console.error(fail(`Not a directory: ${configDirValue}`)); process.stderr.write(String(fail(`Not a directory: ${configDirValue}`)) + '\n');
process.exit(1); process.exit(1);
} }
} catch { } catch {
console.error(fail(`Config directory not found: ${configDirValue}`)); process.stderr.write(String(fail(`Config directory not found: ${configDirValue}`)) + '\n');
console.error(info('Create the directory first, then copy your config files into it.')); process.stderr.write(
String(info('Create the directory first, then copy your config files into it.')) + '\n'
);
process.exit(1); process.exit(1);
} }
@@ -95,9 +97,9 @@ export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise<Disp
// Security warning: cloud sync paths expose OAuth tokens // Security warning: cloud sync paths expose OAuth tokens
const cloudService = detectCloudSyncPath(configDirValue); const cloudService = detectCloudSyncPath(configDirValue);
if (!isCompletionCommand && cloudService) { if (!isCompletionCommand && cloudService) {
console.error(warn(`CCS directory is under ${cloudService}.`)); process.stderr.write(String(warn(`CCS directory is under ${cloudService}.`)) + '\n');
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); process.stderr.write(' OAuth tokens in cliproxy/auth/ will be synced to cloud.\n');
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); process.stderr.write(' Consider: CCS_DIR=/path/outside/cloud ccs ...\n');
} }
// Remove consumed args so they don't leak to Claude CLI // Remove consumed args so they don't leak to Claude CLI
@@ -108,17 +110,17 @@ export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise<Disp
// Also warn for CCS_DIR env var pointing to cloud sync // Also warn for CCS_DIR env var pointing to cloud sync
const cloudService = detectCloudSyncPath(process.env.CCS_DIR); const cloudService = detectCloudSyncPath(process.env.CCS_DIR);
if (!isCompletionCommand && cloudService) { if (!isCompletionCommand && cloudService) {
console.error(warn(`CCS directory is under ${cloudService}.`)); process.stderr.write(String(warn(`CCS directory is under ${cloudService}.`)) + '\n');
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); process.stderr.write(' OAuth tokens in cliproxy/auth/ will be synced to cloud.\n');
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); process.stderr.write(' Consider: CCS_DIR=/path/outside/cloud ccs ...\n');
} }
} else if (process.env.CCS_HOME) { } else if (process.env.CCS_HOME) {
// Also warn for CCS_HOME env var pointing to cloud sync // Also warn for CCS_HOME env var pointing to cloud sync
const cloudService = detectCloudSyncPath(process.env.CCS_HOME); const cloudService = detectCloudSyncPath(process.env.CCS_HOME);
if (!isCompletionCommand && cloudService) { if (!isCompletionCommand && cloudService) {
console.error(warn(`CCS directory is under ${cloudService}.`)); process.stderr.write(String(warn(`CCS directory is under ${cloudService}.`)) + '\n');
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.'); process.stderr.write(' OAuth tokens in cliproxy/auth/ will be synced to cloud.\n');
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...'); process.stderr.write(' Consider: CCS_DIR=/path/outside/cloud ccs ...\n');
} }
} }
@@ -135,7 +137,7 @@ export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise<Disp
browserLaunchOverride = browserLaunchFlags.override; browserLaunchOverride = browserLaunchFlags.override;
args = browserLaunchFlags.argsWithoutFlags; args = browserLaunchFlags.argsWithoutFlags;
} catch (error) { } catch (error) {
console.error(fail((error as Error).message)); process.stderr.write(String(fail((error as Error).message)) + '\n');
process.exit(1); process.exit(1);
// process.exit never returns but TypeScript needs the unreachable return // process.exit never returns but TypeScript needs the unreachable return
return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true }; return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true };
@@ -228,15 +230,18 @@ export function normalizeLegacyCursorArgs(args: string[]): string[] {
} }
export function printCursorLegacySubcommandDeprecation(subcommand: string): void { export function printCursorLegacySubcommandDeprecation(subcommand: string): void {
console.error( process.stderr.write(
warn(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`) String(warn(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`)) +
'\n'
); );
console.error( process.stderr.write(
String(
warn( warn(
`Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.` `Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.`
) )
) + '\n'
); );
console.error(''); process.stderr.write('\n');
} }
// ========== Runtime Reasoning Flags ========== // ========== Runtime Reasoning Flags ==========
@@ -248,10 +253,12 @@ export function resolveRuntimeReasoningFlags(
const runtime = resolveDroidReasoningRuntime(args, envThinkingValue); const runtime = resolveDroidReasoningRuntime(args, envThinkingValue);
if (runtime.duplicateDisplays.length > 0) { if (runtime.duplicateDisplays.length > 0) {
console.error( process.stderr.write(
String(
warn( warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}` `[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}`
) )
) + '\n'
); );
} }
@@ -280,11 +287,11 @@ export function exitWithRuntimeReasoningFlagError(
includeDroidExecExample?: boolean; includeDroidExecExample?: boolean;
} }
): never { ): never {
console.error(fail(message)); process.stderr.write(String(fail(message)) + '\n');
console.error(' Examples: --thinking low, --thinking 8192, --thinking off'); process.stderr.write(' Examples: --thinking low, --thinking 8192, --thinking off\n');
console.error(` Codex alias: --effort ${options.codexAliasLevels}`); process.stderr.write(` Codex alias: --effort ${options.codexAliasLevels}\n`);
if (options.includeDroidExecExample) { if (options.includeDroidExecExample) {
console.error(' Droid exec: --reasoning-effort high'); process.stderr.write(' Droid exec: --reasoning-effort high\n');
} }
process.exit(1); process.exit(1);
} }
+29 -12
View File
@@ -67,12 +67,14 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
if (resolvedTarget !== 'claude') { if (resolvedTarget !== 'claude') {
const adapter = targetAdapter; const adapter = targetAdapter;
if (!adapter) { if (!adapter) {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); process.stderr.write(String(fail(`Target adapter not found for "${resolvedTarget}"`)) + '\n');
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
if (!adapter.supportsProfileType('cliproxy')) { if (!adapter.supportsProfileType('cliproxy')) {
console.error(fail(`${adapter.displayName} does not support CLIProxy profiles`)); process.stderr.write(
String(fail(`${adapter.displayName} does not support CLIProxy profiles`)) + '\n'
);
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
@@ -112,12 +114,16 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`)) targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`))
); );
if (providedUnsupportedFlag) { if (providedUnsupportedFlag) {
console.error( process.stderr.write(
String(
fail( fail(
`${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target` `${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target`
) )
) + '\n'
);
process.stderr.write(
String(info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`)) + '\n'
); );
console.error(info(`Run with Claude target: ccs ${profileInfo.name} --target claude ...`));
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
@@ -129,14 +135,20 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
] as CLIProxyProvider[]; ] as CLIProxyProvider[];
const missingProvider = compositeProviders.find((p) => !isAuthenticated(p)); const missingProvider = compositeProviders.find((p) => !isAuthenticated(p));
if (missingProvider) { if (missingProvider) {
console.error(fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`)); process.stderr.write(
console.error(info(`Authenticate first: ccs ${missingProvider} --auth`)); String(fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`)) + '\n'
);
process.stderr.write(
String(info(`Authenticate first: ccs ${missingProvider} --auth`)) + '\n'
);
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
} else if (!isAuthenticated(provider)) { } else if (!isAuthenticated(provider)) {
console.error(fail(`No OAuth authentication found for provider: ${provider}`)); process.stderr.write(
console.error(info(`Authenticate first: ccs ${provider} --auth`)); String(fail(`No OAuth authentication found for provider: ${provider}`)) + '\n'
);
process.stderr.write(String(info(`Authenticate first: ccs ${provider} --auth`)) + '\n');
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
@@ -146,7 +158,9 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v') targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v')
); );
if (!ensureServiceResult.started) { if (!ensureServiceResult.started) {
console.error(fail(ensureServiceResult.error || 'Failed to start local CLIProxy service')); process.stderr.write(
String(fail(ensureServiceResult.error || 'Failed to start local CLIProxy service')) + '\n'
);
process.exitCode = 1; process.exitCode = 1;
return; return;
} }
@@ -177,13 +191,16 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
}; };
if (!creds.baseUrl || !creds.apiKey) { if (!creds.baseUrl || !creds.apiKey) {
console.error( process.stderr.write(
String(
fail( fail(
`Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)` `Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)`
) )
) + '\n'
); );
console.error( process.stderr.write(
info('Reconfigure with: ccs config > CLIProxy, or run ccs <provider> --config') String(info('Reconfigure with: ccs config > CLIProxy, or run ccs <provider> --config')) +
'\n'
); );
process.exitCode = 1; process.exitCode = 1;
return; return;
+40 -26
View File
@@ -113,10 +113,12 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
}) })
: {}; : {};
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) { if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
console.error( process.stderr.write(
String(
info( info(
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` `Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
) )
) + '\n'
); );
} }
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir; const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
@@ -165,14 +167,16 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
cliproxyBridgeProvider: cliproxyBridge?.provider ?? null, cliproxyBridgeProvider: cliproxyBridge?.provider ?? null,
}); });
if (!compatibility.supported) { if (!compatibility.supported) {
console.error( process.stderr.write(
String(
fail( fail(
compatibility.reason || compatibility.reason ||
`${targetAdapter?.displayName || resolvedTarget} does not support this profile.` `${targetAdapter?.displayName || resolvedTarget} does not support this profile.`
) )
) + '\n'
); );
if (compatibility.suggestion) { if (compatibility.suggestion) {
console.error(info(compatibility.suggestion)); process.stderr.write(String(info(compatibility.suggestion)) + '\n');
} }
process.exit(1); process.exit(1);
} }
@@ -187,7 +191,7 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
if (glmtNormalization) { if (glmtNormalization) {
for (const message of glmtNormalization.warnings) { for (const message of glmtNormalization.warnings) {
console.error(warn(message)); process.stderr.write(String(warn(message)) + '\n');
} }
} }
@@ -197,14 +201,16 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
if (apiKey) { if (apiKey) {
const validation = await validateGlmKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']); const validation = await validateGlmKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']);
if (!validation.valid) { if (!validation.valid) {
console.error(''); process.stderr.write('\n');
console.error(fail(validation.error || 'API key validation failed')); process.stderr.write(String(fail(validation.error || 'API key validation failed')) + '\n');
if (validation.suggestion) { if (validation.suggestion) {
console.error(''); process.stderr.write('\n');
console.error(validation.suggestion); process.stderr.write(String(validation.suggestion) + '\n');
} }
console.error(''); process.stderr.write('\n');
console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"')); process.stderr.write(
String(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"')) + '\n'
);
process.exit(1); process.exit(1);
} }
} }
@@ -215,14 +221,16 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
if (apiKey) { if (apiKey) {
const validation = await validateMiniMaxKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']); const validation = await validateMiniMaxKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']);
if (!validation.valid) { if (!validation.valid) {
console.error(''); process.stderr.write('\n');
console.error(fail(validation.error || 'API key validation failed')); process.stderr.write(String(fail(validation.error || 'API key validation failed')) + '\n');
if (validation.suggestion) { if (validation.suggestion) {
console.error(''); process.stderr.write('\n');
console.error(validation.suggestion); process.stderr.write(String(validation.suggestion) + '\n');
} }
console.error(''); process.stderr.write('\n');
console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"')); process.stderr.write(
String(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"')) + '\n'
);
process.exit(1); process.exit(1);
} }
} }
@@ -235,15 +243,17 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
if (anthropicApiKey && !hasBaseUrl) { if (anthropicApiKey && !hasBaseUrl) {
const validation = await validateAnthropicKey(anthropicApiKey); const validation = await validateAnthropicKey(anthropicApiKey);
if (!validation.valid) { if (!validation.valid) {
console.error(''); process.stderr.write('\n');
console.error(fail(validation.error || 'API key validation failed')); process.stderr.write(String(fail(validation.error || 'API key validation failed')) + '\n');
if (validation.suggestion) { if (validation.suggestion) {
console.error(''); process.stderr.write('\n');
console.error(validation.suggestion); process.stderr.write(String(validation.suggestion) + '\n');
} }
console.error(''); process.stderr.write('\n');
console.error( process.stderr.write(
String(
info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`) info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`)
) + '\n'
); );
process.exit(1); process.exit(1);
} }
@@ -271,7 +281,7 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
// Log global env injection for visibility (debug mode only) // Log global env injection for visibility (debug mode only)
if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) { if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) {
const envNames = Object.keys(globalEnv).join(', '); const envNames = Object.keys(globalEnv).join(', ');
console.error(info(`Global env: ${envNames}`)); process.stderr.write(String(info(`Global env: ${envNames}`)) + '\n');
} }
// For Claude target launches that already pass `--settings`, keep runtime env free of // For Claude target launches that already pass `--settings`, keep runtime env free of
@@ -302,7 +312,7 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
if (resolvedTarget !== 'claude') { if (resolvedTarget !== 'claude') {
const adapter = targetAdapter; const adapter = targetAdapter;
if (!adapter) { if (!adapter) {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); process.stderr.write(String(fail(`Target adapter not found for "${resolvedTarget}"`)) + '\n');
process.exit(1); process.exit(1);
} }
const directAnthropicBaseUrl = const directAnthropicBaseUrl =
@@ -349,14 +359,18 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
insecure: openAICompatProfile.insecure, insecure: openAICompatProfile.insecure,
}); });
if (!proxyStart.success) { if (!proxyStart.success) {
console.error(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy')); process.stderr.write(
String(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy')) + '\n'
);
process.exit(1); process.exit(1);
} }
console.error( process.stderr.write(
String(
info( info(
`Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}` `Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}`
) )
) + '\n'
); );
const proxyEnv = { const proxyEnv = {
+34 -16
View File
@@ -190,7 +190,7 @@ export async function resolveProfileAndTarget(
profileInfo.target ? { target: profileInfo.target } : undefined profileInfo.target ? { target: profileInfo.target } : undefined
); );
} catch (error) { } catch (error) {
console.error(fail((error as Error).message)); process.stderr.write(String(fail((error as Error).message)) + '\n');
process.exit(1); process.exit(1);
// Unreachable; needed so TS knows resolvedTarget is always assigned below // Unreachable; needed so TS knows resolvedTarget is always assigned below
throw error; throw error;
@@ -219,7 +219,7 @@ export async function resolveProfileAndTarget(
// so users get the most actionable error even when the target CLI is not installed. // so users get the most actionable error even when the target CLI is not installed.
if (resolvedTarget !== 'claude') { if (resolvedTarget !== 'claude') {
if (!targetAdapter) { if (!targetAdapter) {
console.error(fail(`Target adapter not found for "${resolvedTarget}"`)); process.stderr.write(String(fail(`Target adapter not found for "${resolvedTarget}"`)) + '\n');
process.exit(1); process.exit(1);
} }
@@ -235,13 +235,15 @@ export async function resolveProfileAndTarget(
cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null, cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null,
}); });
if (!compatibility.supported) { if (!compatibility.supported) {
console.error( process.stderr.write(
String(
fail( fail(
compatibility.reason || `${targetAdapter.displayName} does not support this profile.` compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
) )
) + '\n'
); );
if (compatibility.suggestion) { if (compatibility.suggestion) {
console.error(info(compatibility.suggestion)); process.stderr.write(String(info(compatibility.suggestion)) + '\n');
} }
process.exit(1); process.exit(1);
} }
@@ -255,13 +257,15 @@ export async function resolveProfileAndTarget(
isComposite: profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined, isComposite: profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined,
}); });
if (!compatibility.supported) { if (!compatibility.supported) {
console.error( process.stderr.write(
String(
fail( fail(
compatibility.reason || `${targetAdapter.displayName} does not support this profile.` compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
) )
) + '\n'
); );
if (compatibility.suggestion) { if (compatibility.suggestion) {
console.error(info(compatibility.suggestion)); process.stderr.write(String(info(compatibility.suggestion)) + '\n');
} }
process.exit(1); process.exit(1);
} }
@@ -269,7 +273,9 @@ export async function resolveProfileAndTarget(
if (profileInfo.type === 'default') { if (profileInfo.type === 'default') {
if (!targetAdapter.supportsProfileType('default')) { if (!targetAdapter.supportsProfileType('default')) {
console.error(fail(`${targetAdapter.displayName} does not support default profile mode`)); process.stderr.write(
String(fail(`${targetAdapter.displayName} does not support default profile mode`)) + '\n'
);
process.exit(1); process.exit(1);
} }
@@ -278,12 +284,16 @@ export async function resolveProfileAndTarget(
const baseUrl = process.env['ANTHROPIC_BASE_URL'] || ''; const baseUrl = process.env['ANTHROPIC_BASE_URL'] || '';
const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || ''; const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || '';
if (!baseUrl.trim() || !apiKey.trim()) { if (!baseUrl.trim() || !apiKey.trim()) {
console.error( process.stderr.write(
String(
fail( fail(
`${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` `${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
) )
) + '\n'
);
process.stderr.write(
String(info('Use a settings-based profile instead: ccs glm --target droid')) + '\n'
); );
console.error(info('Use a settings-based profile instead: ccs glm --target droid'));
process.exit(1); process.exit(1);
} }
} }
@@ -324,15 +334,17 @@ export async function resolveProfileAndTarget(
? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure) ? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure)
: undefined; : undefined;
if (blockedBrowserOverrideWarning) { if (blockedBrowserOverrideWarning) {
console.error(warn(blockedBrowserOverrideWarning)); process.stderr.write(String(warn(blockedBrowserOverrideWarning)) + '\n');
} }
if (resolvedTarget !== 'claude' && !targetBinaryInfo) { if (resolvedTarget !== 'claude' && !targetBinaryInfo) {
const displayName = targetAdapter?.displayName || resolvedTarget; const displayName = targetAdapter?.displayName || resolvedTarget;
console.error(fail(`${displayName} CLI not found.`)); process.stderr.write(String(fail(`${displayName} CLI not found.`)) + '\n');
if (resolvedTarget === 'droid') { if (resolvedTarget === 'droid') {
console.error(info('Install: npm i -g @factory/cli')); process.stderr.write(String(info('Install: npm i -g @factory/cli')) + '\n');
} else if (resolvedTarget === 'codex') { } else if (resolvedTarget === 'codex') {
console.error(info('Install a recent @openai/codex build, then retry.')); process.stderr.write(
String(info('Install a recent @openai/codex build, then retry.')) + '\n'
);
} }
process.exit(1); process.exit(1);
} }
@@ -344,7 +356,9 @@ export async function resolveProfileAndTarget(
const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9._-]+$/.test(name)); const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9._-]+$/.test(name));
await pruneOrphanedModels(activeProfiles); await pruneOrphanedModels(activeProfiles);
} catch (error) { } catch (error) {
console.error(warn(`[!] Droid prune skipped: ${(error as Error).message}`)); process.stderr.write(
String(warn(`[!] Droid prune skipped: ${(error as Error).message}`)) + '\n'
);
} }
} }
@@ -364,15 +378,19 @@ export async function resolveProfileAndTarget(
runtimeReasoningOverride = runtime.reasoningOverride; runtimeReasoningOverride = runtime.reasoningOverride;
} else { } else {
if (droidRoute.duplicateReasoningDisplays.length > 0) { if (droidRoute.duplicateReasoningDisplays.length > 0) {
console.error( process.stderr.write(
String(
warn( warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || '<first-flag>'}` `[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || '<first-flag>'}`
) )
) + '\n'
); );
} }
if (droidRoute.autoPrependedExec && process.stdout.isTTY) { if (droidRoute.autoPrependedExec && process.stdout.isTTY) {
console.error( process.stderr.write(
String(
info('Detected Droid exec-only flags. Routing as: droid exec <flags> [prompt]') info('Detected Droid exec-only flags. Routing as: droid exec <flags> [prompt]')
) + '\n'
); );
} }
} }
+21 -6
View File
@@ -14,6 +14,8 @@
* const events = transformer.transformDelta(openaiEvent, acc); * const events = transformer.transformDelta(openaiEvent, acc);
*/ */
import { createLogger } from '../services/logging';
interface ThinkingConfig { interface ThinkingConfig {
[key: string]: unknown; [key: string]: unknown;
} }
@@ -94,6 +96,7 @@ export class DeltaAccumulator {
private finalized: boolean; private finalized: boolean;
private inputTokens: number; private inputTokens: number;
private outputTokens: number; private outputTokens: number;
private readonly logger = createLogger('glmt:delta-accumulator');
constructor(_thinkingConfig: ThinkingConfig = {}, options: DeltaAccumulatorOptions = {}) { constructor(_thinkingConfig: ThinkingConfig = {}, options: DeltaAccumulatorOptions = {}) {
this.messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(7); this.messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(7);
@@ -179,7 +182,11 @@ export class DeltaAccumulator {
const block = this.getCurrentBlock(); const block = this.getCurrentBlock();
if (!block) { if (!block) {
// FIX: Guard against null block (should never happen, but defensive) // FIX: Guard against null block (should never happen, but defensive)
console.error('[DeltaAccumulator] ERROR: addDelta called with no current block'); this.logger.error(
'delta.no_current_block',
'DeltaAccumulator addDelta called with no current block',
{ currentBlockIndex: this.currentBlockIndex }
);
return; return;
} }
@@ -193,8 +200,15 @@ export class DeltaAccumulator {
// FIX: Verify assignment succeeded (paranoid check for race conditions) // FIX: Verify assignment succeeded (paranoid check for race conditions)
if (block.content.length !== this.thinkingBuffer.length) { if (block.content.length !== this.thinkingBuffer.length) {
console.error('[DeltaAccumulator] ERROR: Block content assignment failed'); this.logger.error(
console.error(`Expected: ${this.thinkingBuffer.length}, Got: ${block.content.length}`); 'delta.assignment_failed',
'DeltaAccumulator block content assignment failed',
{
blockIndex: block.index,
expected: this.thinkingBuffer.length,
actual: block.content.length,
}
);
} }
} else if (block.type === 'text') { } else if (block.type === 'text') {
// C-02 Fix: Enforce buffer size limit // C-02 Fix: Enforce buffer size limit
@@ -216,9 +230,10 @@ export class DeltaAccumulator {
// FIX: Log block closure for debugging (helps diagnose timing issues) // FIX: Log block closure for debugging (helps diagnose timing issues)
if (block.type === 'thinking' && process.env.CCS_DEBUG === '1') { if (block.type === 'thinking' && process.env.CCS_DEBUG === '1') {
console.error( this.logger.debug('delta.stopped_thinking_block', 'Stopped thinking block', {
`[DeltaAccumulator] Stopped thinking block ${block.index}: ${block.content?.length || 0} chars` blockIndex: block.index,
); contentLength: block.content?.length || 0,
});
} }
} }
} }
+50 -15
View File
@@ -149,17 +149,24 @@ export class GlmtProxy {
// Info message (only show in verbose mode) // Info message (only show in verbose mode)
if (this.verbose) { if (this.verbose) {
console.error( logger.info(
`[glmt] Proxy listening on port ${this.port} (streaming with auto-fallback)` 'glmt.proxy.listening_verbose',
'GLMT proxy listening (streaming with auto-fallback)',
{
port: this.port,
}
); );
} }
// Debug mode notice // Debug mode notice
if ((this.transformer as unknown as { debugLog: boolean }).debugLog) { if ((this.transformer as unknown as { debugLog: boolean }).debugLog) {
console.error( logger.info('glmt.proxy.debug_log_enabled', 'Debug logging enabled', {
`[glmt] Debug logging enabled: ${(this.transformer as unknown as { debugLogDir: string }).debugLogDir}` debugLogDir: (this.transformer as unknown as { debugLogDir: string }).debugLogDir,
});
logger.warn(
'glmt.proxy.debug_log_warning',
'Debug logs contain full request/response data'
); );
console.error(`[glmt] WARNING: Debug logs contain full request/response data`);
} }
this.log(`Verbose logging enabled`); this.log(`Verbose logging enabled`);
@@ -167,7 +174,12 @@ export class GlmtProxy {
}); });
this.server.on('error', (error) => { this.server.on('error', (error) => {
console.error('[glmt-proxy] Server error:', error); logger.error('glmt.proxy.server_error', 'GLMT proxy server error', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
reject(error); reject(error);
}); });
}); });
@@ -240,7 +252,12 @@ export class GlmtProxy {
} }
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
console.error('[glmt-proxy] Request error:', err.message); logger.error('glmt.proxy.request_error', 'GLMT proxy request error', {
err: { name: err.name, message: err.message },
method: req.method,
url: req.url,
durationMs: Date.now() - startTime,
});
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
this.log(`Request failed after ${duration}ms: ${err.message}`); this.log(`Request failed after ${duration}ms: ${err.message}`);
@@ -464,9 +481,11 @@ export class GlmtProxy {
); );
if (this.verbose) { if (this.verbose) {
console.error( logger.warn('glmt.proxy.rate_limited_retry', 'Rate limited, retrying after backoff', {
`[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms` attempt: attempt + 1,
); maxRetries: this.retryConfig.maxRetries,
delayMs: Math.round(delay),
});
} }
await this.sleep(delay); await this.sleep(delay);
@@ -526,8 +545,14 @@ export class GlmtProxy {
const delay = this.calculateRetryDelay(attempt, retryAfter); const delay = this.calculateRetryDelay(attempt, retryAfter);
if (this.verbose) { if (this.verbose) {
console.error( logger.warn(
`[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms` 'glmt.proxy.rate_limited_retry_stream',
'Rate limited (streaming), retrying after backoff',
{
attempt: attempt + 1,
maxRetries: this.retryConfig.maxRetries,
delayMs: Math.round(delay),
}
); );
} }
@@ -766,7 +791,7 @@ export class GlmtProxy {
*/ */
private log(message: string): void { private log(message: string): void {
if (this.verbose) { if (this.verbose) {
console.error(`[glmt-proxy] ${message}`); logger.info('glmt.proxy.verbose', message);
} }
} }
@@ -878,7 +903,12 @@ if (require.main === module) {
const proxy = new GlmtProxy({ verbose }); const proxy = new GlmtProxy({ verbose });
proxy.start().catch((error) => { proxy.start().catch((error) => {
console.error('[glmt-proxy] Failed to start:', error); logger.error('glmt.proxy.start_failed', 'GLMT proxy failed to start', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
process.exit(1); process.exit(1);
}); });
@@ -895,7 +925,12 @@ if (require.main === module) {
// Keep process alive // Keep process alive
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
console.error('[glmt-proxy] Uncaught exception:', error); logger.error('glmt.proxy.uncaught_exception', 'GLMT proxy uncaught exception', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
proxy.stop(); proxy.stop();
process.exit(1); process.exit(1);
}); });
+1 -4
View File
@@ -135,7 +135,6 @@ export class GlmtTransformer {
undefined, undefined,
{ level: 'error', error: { name: err.name, message: err.message } } { level: 'error', error: { name: err.name, message: err.message } }
); );
console.error('[glmt-transformer] Response transformation error:', err);
return { return {
id: 'msg_error_' + Date.now(), id: 'msg_error_' + Date.now(),
type: 'message', type: 'message',
@@ -186,16 +185,14 @@ export class GlmtTransformer {
fs.writeFileSync(filepath, JSON.stringify(redacted, null, 2) + '\n', 'utf8'); fs.writeFileSync(filepath, JSON.stringify(redacted, null, 2) + '\n', 'utf8');
} catch (error) { } catch (error) {
this.logger.warn('debug-log.write_failed', 'GLMT debug log write failed', { this.logger.warn('debug-log.write_failed', 'GLMT debug log write failed', {
message: (error as Error).message, err: { name: (error as Error).name, message: (error as Error).message },
}); });
console.error(`[glmt-transformer] Debug log error: ${(error as Error).message}`);
} }
} }
private log(message: string): void { private log(message: string): void {
if (this.verbose) { if (this.verbose) {
this.logger.debug('transformer.verbose', message); this.logger.debug('transformer.verbose', message);
console.error(`[glmt-transformer] [${new Date().toTimeString().split(' ')[0]}] ${message}`);
} }
} }
+6 -1
View File
@@ -7,6 +7,7 @@
* - Map models and configure parameters * - Map models and configure parameters
*/ */
import { createLogger } from '../../services/logging';
import { LocaleEnforcer } from '../locale-enforcer'; import { LocaleEnforcer } from '../locale-enforcer';
import { ReasoningEnforcer } from '../reasoning-enforcer'; import { ReasoningEnforcer } from '../reasoning-enforcer';
import { ContentTransformer } from './content-transformer'; import { ContentTransformer } from './content-transformer';
@@ -18,6 +19,8 @@ import type {
TransformResult, TransformResult,
} from './types'; } from './types';
const logger = createLogger('glmt:pipeline:request-transformer');
export interface RequestTransformerConfig { export interface RequestTransformerConfig {
defaultThinking?: boolean; defaultThinking?: boolean;
verbose?: boolean; verbose?: boolean;
@@ -131,7 +134,9 @@ export class RequestTransformer {
return { openaiRequest, thinkingConfig }; return { openaiRequest, thinkingConfig };
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
console.error('[RequestTransformer] Transformation error:', err); logger.error('transform_failed', 'Anthropic to OpenAI request transformation failed', {
err: { name: err.name, message: err.message },
});
return { return {
openaiRequest: anthropicRequest, openaiRequest: anthropicRequest,
thinkingConfig: { thinking: false, effort: 'medium' }, thinkingConfig: { thinking: false, effort: 'medium' },
+11 -8
View File
@@ -9,9 +9,12 @@
*/ */
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { createLogger } from '../../services/logging';
import type { DeltaAccumulator } from '../delta-accumulator'; import type { DeltaAccumulator } from '../delta-accumulator';
import type { AccumulatorBlock, AnthropicSSEEvent, ThinkingSignature } from './types'; import type { AccumulatorBlock, AnthropicSSEEvent, ThinkingSignature } from './types';
const logger = createLogger('glmt:pipeline:response-builder');
export class ResponseBuilder { export class ResponseBuilder {
private verbose: boolean; private verbose: boolean;
@@ -101,11 +104,10 @@ export class ResponseBuilder {
// FIX: Guard against empty content (signature timing race) // FIX: Guard against empty content (signature timing race)
if (!block.content || block.content.length === 0) { if (!block.content || block.content.length === 0) {
if (this.verbose) { if (this.verbose) {
console.error( logger.warn(
`[ResponseBuilder] WARNING: Skipping signature for empty thinking block ${block.index}` 'signature_empty_thinking_block',
); 'Skipping signature for empty thinking block - possible race condition',
console.error( { blockIndex: block.index }
`This indicates a race condition - signature requested before content accumulated`
); );
} }
return null; return null;
@@ -114,9 +116,10 @@ export class ResponseBuilder {
const signature = this.generateThinkingSignature(block.content); const signature = this.generateThinkingSignature(block.content);
if (this.verbose) { if (this.verbose) {
console.error( logger.info('signature_generated', 'Generated thinking signature', {
`[ResponseBuilder] Generating signature for block ${block.index}: ${block.content.length} chars` blockIndex: block.index,
); contentLength: block.content.length,
});
} }
return { return {
+12 -7
View File
@@ -8,11 +8,14 @@
* - Generate appropriate Anthropic SSE events * - Generate appropriate Anthropic SSE events
*/ */
import { createLogger } from '../../services/logging';
import type { DeltaAccumulator } from '../delta-accumulator'; import type { DeltaAccumulator } from '../delta-accumulator';
import type { SSEEvent, AnthropicSSEEvent, AccumulatorBlock } from './types'; import type { SSEEvent, AnthropicSSEEvent, AccumulatorBlock } from './types';
import { ResponseBuilder } from './response-builder'; import { ResponseBuilder } from './response-builder';
import { ToolCallHandler } from './tool-call-handler'; import { ToolCallHandler } from './tool-call-handler';
const logger = createLogger('glmt:pipeline:stream-parser');
export interface StreamParserConfig { export interface StreamParserConfig {
verbose?: boolean; verbose?: boolean;
debugMode?: boolean; debugMode?: boolean;
@@ -144,10 +147,11 @@ export class StreamParser {
const currentBlock = accumulator.getCurrentBlock(); const currentBlock = accumulator.getCurrentBlock();
if (this.debugMode) { if (this.debugMode) {
console.error(`[StreamParser] Reasoning delta: ${reasoningContent.length} chars`); logger.info('reasoning_delta', 'Reasoning content delta received', {
console.error( deltaLength: reasoningContent.length,
`[StreamParser] Current block: ${currentBlock?.type || 'none'}, index: ${currentBlock?.index ?? 'N/A'}` currentBlockType: currentBlock?.type || 'none',
); currentBlockIndex: currentBlock?.index ?? null,
});
} }
if (!currentBlock || currentBlock.type !== 'thinking') { if (!currentBlock || currentBlock.type !== 'thinking') {
@@ -156,7 +160,9 @@ export class StreamParser {
events.push(this.responseBuilder.createContentBlockStartEvent(block)); events.push(this.responseBuilder.createContentBlockStartEvent(block));
if (this.debugMode) { if (this.debugMode) {
console.error(`[StreamParser] Started new thinking block ${block.index}`); logger.info('thinking_block_started', 'Started new thinking block', {
blockIndex: block.index,
});
} }
} }
@@ -296,8 +302,7 @@ export class StreamParser {
*/ */
private log(message: string): void { private log(message: string): void {
if (this.verbose) { if (this.verbose) {
const timestamp = new Date().toTimeString().split(' ')[0]; // HH:MM:SS logger.warn('stream_parser_verbose', message, {});
console.error(`[StreamParser] [${timestamp}] ${message}`);
} }
} }
} }
+12 -1
View File
@@ -7,9 +7,12 @@
* - Handle input_json_delta events * - Handle input_json_delta events
*/ */
import { createLogger } from '../../services/logging';
import type { DeltaAccumulator } from '../delta-accumulator'; import type { DeltaAccumulator } from '../delta-accumulator';
import type { OpenAIToolCallDelta, OpenAIToolCall, ContentBlock, AnthropicSSEEvent } from './types'; import type { OpenAIToolCallDelta, OpenAIToolCall, ContentBlock, AnthropicSSEEvent } from './types';
const logger = createLogger('glmt:pipeline:tool-call-handler');
export class ToolCallHandler { export class ToolCallHandler {
processToolCalls(toolCalls: OpenAIToolCall[]): ContentBlock[] { processToolCalls(toolCalls: OpenAIToolCall[]): ContentBlock[] {
const content: ContentBlock[] = []; const content: ContentBlock[] = [];
@@ -20,7 +23,15 @@ export class ToolCallHandler {
parsedInput = JSON.parse(toolCall.function.arguments || '{}'); parsedInput = JSON.parse(toolCall.function.arguments || '{}');
} catch (parseError) { } catch (parseError) {
const err = parseError as Error; const err = parseError as Error;
console.error(`[ToolCallHandler] Invalid JSON in tool arguments: ${err.message}`); logger.warn(
'tool_arguments_invalid_json',
'Tool call arguments contained invalid JSON, storing raw value',
{
err: { name: err.name, message: err.message },
toolCallId: toolCall.id,
toolCallName: toolCall.function.name,
}
);
parsedInput = { _error: 'Invalid JSON', _raw: toolCall.function.arguments }; parsedInput = { _error: 'Invalid JSON', _raw: toolCall.function.arguments };
} }
+8 -8
View File
@@ -17,6 +17,8 @@
* }); * });
*/ */
import { createLogger } from '../services/logging';
interface SSEParserOptions { interface SSEParserOptions {
maxBufferSize?: number; maxBufferSize?: number;
throwOnMalformedJson?: boolean; throwOnMalformedJson?: boolean;
@@ -36,6 +38,7 @@ export class SSEParser {
private maxBufferSize: number; private maxBufferSize: number;
private throwOnMalformedJson: boolean; private throwOnMalformedJson: boolean;
private pendingCR: boolean; private pendingCR: boolean;
private readonly logger = createLogger('glmt:sse-parser');
constructor(options: SSEParserOptions = {}) { constructor(options: SSEParserOptions = {}) {
this.buffer = ''; this.buffer = '';
@@ -118,14 +121,11 @@ export class SSEParser {
currentEvent.index = this.eventCount; currentEvent.index = this.eventCount;
events.push({ ...currentEvent }); events.push({ ...currentEvent });
} catch (e) { } catch (e) {
if (typeof console !== 'undefined' && console.error) { this.logger.warn('sse.malformed_json', 'Malformed JSON event in SSE stream', {
console.error( err: e instanceof Error ? { name: e.name, message: e.message } : { message: String(e) },
'[SSEParser] Malformed JSON event:', dataPreview: data.substring(0, 100),
(e as Error).message, eventIndex: this.eventCount,
'Data:', });
data.substring(0, 100)
);
}
if (this.throwOnMalformedJson) { if (this.throwOnMalformedJson) {
throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`); throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`);
} }
+1 -1
View File
@@ -98,7 +98,7 @@ function startRuntime(options: RuntimeOptions): void {
insecure: options.insecure, insecure: options.insecure,
}); });
server.once('error', (error) => { server.once('error', (error) => {
console.error((error as Error).message); process.stderr.write(String((error as Error).message) + '\n');
process.exit(1); process.exit(1);
}); });
const shutdown = () => server.close(); const shutdown = () => server.close();
@@ -2,6 +2,9 @@ import { DeltaAccumulator } from '../../glmt/delta-accumulator';
import { GlmtTransformer } from '../../glmt/glmt-transformer'; import { GlmtTransformer } from '../../glmt/glmt-transformer';
import { SSEParser } from '../../glmt/sse-parser'; import { SSEParser } from '../../glmt/sse-parser';
import type { OpenAIResponse, SSEEvent } from '../../glmt/pipeline'; import type { OpenAIResponse, SSEEvent } from '../../glmt/pipeline';
import { createLogger } from '../../services/logging';
const logger = createLogger('proxy:sse-stream-transformer');
const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate OpenAI-compatible JSON response'; const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate OpenAI-compatible JSON response';
const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate OpenAI-compatible SSE response'; const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate OpenAI-compatible SSE response';
@@ -26,20 +29,13 @@ function createAnthropicErrorPayload(type: string, message: string): AnthropicEr
}; };
} }
function formatErrorForLog(error: unknown): string { function logTranslationError(event: string, message: string, error: unknown): void {
if (error instanceof Error) { logger.error(event, message, {
return error.message; err:
} error instanceof Error
? { name: error.name, message: error.message }
try { : { message: String(error) },
return JSON.stringify(error); });
} catch {
return String(error);
}
}
function logTranslationError(context: string, error: unknown): void {
console.error(`[proxy-sse-transformer] ${context}: ${formatErrorForLog(error)}`);
} }
export function createAnthropicErrorResponse( export function createAnthropicErrorResponse(
@@ -130,7 +126,11 @@ async function createAnthropicErrorProxyResponse(response: Response): Promise<Re
} }
} }
} catch (error) { } catch (error) {
logTranslationError('Failed to parse upstream error response', error); logTranslationError(
'sse_transformer.upstream_error_parse_failed',
'Failed to parse upstream error response',
error
);
} }
return createAnthropicErrorResponse(response.status, type, message, headers); return createAnthropicErrorResponse(response.status, type, message, headers);
@@ -146,6 +146,7 @@ async function createAnthropicJsonResponse(response: Response): Promise<Response
const anthropicResponse = new GlmtTransformer().transformResponse(openAIResponse); const anthropicResponse = new GlmtTransformer().transformResponse(openAIResponse);
if (isSyntheticTransformationFallback(anthropicResponse)) { if (isSyntheticTransformationFallback(anthropicResponse)) {
logTranslationError( logTranslationError(
'sse_transformer.json_synthetic_fallback',
'OpenAI-compatible JSON translation produced synthetic fallback response', 'OpenAI-compatible JSON translation produced synthetic fallback response',
anthropicResponse anthropicResponse
); );
@@ -157,7 +158,11 @@ async function createAnthropicJsonResponse(response: Response): Promise<Response
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
} catch (error) { } catch (error) {
logTranslationError('OpenAI-compatible JSON translation failed', error); logTranslationError(
'sse_transformer.json_translation_failed',
'OpenAI-compatible JSON translation failed',
error
);
return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE);
} }
} }
@@ -210,7 +215,11 @@ function createAnthropicStreamingResponse(response: Response): Response {
} }
} }
} catch (error) { } catch (error) {
logTranslationError('OpenAI-compatible SSE translation failed', error); logTranslationError(
'sse_transformer.sse_translation_failed',
'OpenAI-compatible SSE translation failed',
error
);
controller.enqueue( controller.enqueue(
encoder.encode( encoder.encode(
formatSseEvent( formatSseEvent(
+25 -2
View File
@@ -31,6 +31,29 @@ function maskAuthSchemeValue(value: string): string {
return `${match[1]} [redacted]`; return `${match[1]} [redacted]`;
} }
/**
* Known credential token shapes that may appear anywhere in a string value
* (error messages, URLs, free-text). Applied IN ADDITION to sensitive-key
* redaction so a token routed through a non-sensitive field (e.g. an Error
* captured under `err`) is still scrubbed. Each branch requires a distinctive
* prefix plus an ample body to minimise false positives on ordinary prose.
*/
const SECRET_TOKEN_PATTERN =
/(?:Bearer|Basic|Token)\s+\S{8,}|sk-ant-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{32,}|xox[bpoa]-[A-Za-z0-9-]{10,}|gh[opsu]_[A-Za-z0-9]{36,}|glpat-[A-Za-z0-9_-]{18,}|AIza[0-9A-Za-z_-]{35}|eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|secret)(?:=|%3D)[A-Za-z0-9._~+/=-]{8,}/g;
/**
* Scrub known credential shapes from an arbitrary string. Preserves the
* Bearer/Basic/Token scheme prefix when present so the entry stays readable.
* Exported so the logger can also scrub the human-authored message string
* (defense-in-depth for the hotpath console.error sweep).
*/
export function maskSecretTokens(value: string): string {
return value.replace(SECRET_TOKEN_PATTERN, (match) => {
const scheme = /^(Bearer|Basic|Token)\s+/.exec(match);
return scheme ? `${scheme[1]} [redacted]` : '[redacted]';
});
}
function sanitizeValue(value: unknown, depth: number): unknown { function sanitizeValue(value: unknown, depth: number): unknown {
if (value === null || value === undefined) { if (value === null || value === undefined) {
return value; return value;
@@ -41,7 +64,7 @@ function sanitizeValue(value: unknown, depth: number): unknown {
} }
if (typeof value === 'string') { if (typeof value === 'string') {
return truncateString(maskAuthSchemeValue(value)); return truncateString(maskSecretTokens(maskAuthSchemeValue(value)));
} }
if (typeof value === 'number' || typeof value === 'boolean') { if (typeof value === 'number' || typeof value === 'boolean') {
@@ -51,7 +74,7 @@ function sanitizeValue(value: unknown, depth: number): unknown {
if (value instanceof Error) { if (value instanceof Error) {
return { return {
name: value.name, name: value.name,
message: truncateString(value.message), message: truncateString(maskSecretTokens(value.message)),
}; };
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import { getResolvedLoggingConfig } from './log-config'; import { getResolvedLoggingConfig } from './log-config';
import { getRequestContext } from './log-context'; import { getRequestContext } from './log-context';
import { redactContext } from './log-redaction'; import { maskSecretTokens, redactContext } from './log-redaction';
import { appendStructuredLogEntry } from './log-storage'; import { appendStructuredLogEntry } from './log-storage';
import type { LogEntry, LogStage, LoggingLevel } from './log-types'; import type { LogEntry, LogStage, LoggingLevel } from './log-types';
@@ -29,7 +29,7 @@ function createEntry(
level, level,
source, source,
event, event,
message, message: maskSecretTokens(message),
processId: process.pid, processId: process.pid,
runId: processRunId, runId: processRunId,
context: config.redact ? redactContext(context) : context, context: config.redact ? redactContext(context) : context,
+18 -11
View File
@@ -383,27 +383,30 @@ export class CodexAdapter implements TargetAdapter {
const codexPath = options?.binaryInfo?.path || detectCodexCli(); const codexPath = options?.binaryInfo?.path || detectCodexCli();
if (!codexPath) { if (!codexPath) {
console.error('[X] Codex CLI not found. Install a recent @openai/codex build first.'); process.stderr.write(
String('[X] Codex CLI not found. Install a recent @openai/codex build first.') + '\n'
);
return exitWithCleanup(1); return exitWithCleanup(1);
} }
try { try {
const stat = fs.statSync(codexPath); const stat = fs.statSync(codexPath);
if (!stat.isFile()) { if (!stat.isFile()) {
console.error(`[X] Codex CLI path is not a file: ${codexPath}`); process.stderr.write(`[X] Codex CLI path is not a file: ${codexPath}\n`);
return exitWithCleanup(1); return exitWithCleanup(1);
} }
} catch (err) { } catch (err) {
const error = err as NodeJS.ErrnoException; const error = err as NodeJS.ErrnoException;
console.error( process.stderr.write(
`[X] Codex CLI path is not accessible (${error.code || 'unknown'}): ${codexPath}` String(`[X] Codex CLI path is not accessible (${error.code || 'unknown'}): ${codexPath}`) +
'\n'
); );
return exitWithCleanup(1); return exitWithCleanup(1);
} }
const codexHomePreparation = prepareExplicitCodexHome(env, args); const codexHomePreparation = prepareExplicitCodexHome(env, args);
if (codexHomePreparation.error) { if (codexHomePreparation.error) {
console.error(codexHomePreparation.error); process.stderr.write(String(codexHomePreparation.error) + '\n');
return exitWithCleanup(1); return exitWithCleanup(1);
} }
const launchEnv = codexHomePreparation.env; const launchEnv = codexHomePreparation.env;
@@ -450,18 +453,22 @@ export class CodexAdapter implements TargetAdapter {
wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => { wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => {
if (err.code === 'EACCES') { if (err.code === 'EACCES') {
console.error(`[X] Codex CLI is not executable: ${codexPath}`); process.stderr.write(`[X] Codex CLI is not executable: ${codexPath}\n`);
console.error(' Check file permissions and executable bit.'); process.stderr.write(' Check file permissions and executable bit.\n');
} else if (err.code === 'ENOENT') { } else if (err.code === 'ENOENT') {
if (isPowerShellScript) { if (isPowerShellScript) {
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).'); process.stderr.write(
String('[X] PowerShell executable not found (required for .ps1 wrapper launch).') + '\n'
);
} else if (needsShell) { } else if (needsShell) {
console.error('[X] Windows command shell not found for Codex wrapper launch.'); process.stderr.write(
String('[X] Windows command shell not found for Codex wrapper launch.') + '\n'
);
} else { } else {
console.error(`[X] Codex CLI not found: ${codexPath}`); process.stderr.write(`[X] Codex CLI not found: ${codexPath}\n`);
} }
} else { } else {
console.error(`[X] Failed to start Codex CLI (${codexPath}): ${err.message}`); process.stderr.write(`[X] Failed to start Codex CLI (${codexPath}): ${err.message}\n`);
} }
return exitWithCleanup(1); return exitWithCleanup(1);
}); });
+15 -13
View File
@@ -104,19 +104,19 @@ export class DroidAdapter implements TargetAdapter {
const droidPath = options?.binaryInfo?.path || detectDroidCli(); const droidPath = options?.binaryInfo?.path || detectDroidCli();
if (!droidPath) { if (!droidPath) {
console.error('[X] Droid CLI not found. Install: npm i -g @factory/cli'); process.stderr.write('[X] Droid CLI not found. Install: npm i -g @factory/cli\n');
return exitWithCleanup(1); return exitWithCleanup(1);
} }
try { try {
const stat = fs.statSync(droidPath); const stat = fs.statSync(droidPath);
if (!stat.isFile()) { if (!stat.isFile()) {
console.error(`[X] Droid CLI path is not a file: ${droidPath}`); process.stderr.write(`[X] Droid CLI path is not a file: ${droidPath}\n`);
return exitWithCleanup(1); return exitWithCleanup(1);
} }
} catch (err) { } catch (err) {
const error = err as NodeJS.ErrnoException; const error = err as NodeJS.ErrnoException;
console.error( process.stderr.write(
`[X] Droid CLI path is not accessible (${error.code || 'unknown'}): ${droidPath}` `[X] Droid CLI path is not accessible (${error.code || 'unknown'}): ${droidPath}\n`
); );
return exitWithCleanup(1); return exitWithCleanup(1);
} }
@@ -171,21 +171,23 @@ export class DroidAdapter implements TargetAdapter {
wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => { wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => {
if (err.code === 'EACCES') { if (err.code === 'EACCES') {
console.error(`[X] Droid CLI is not executable: ${droidPath}`); process.stderr.write(`[X] Droid CLI is not executable: ${droidPath}\n`);
console.error(' Check file permissions and executable bit.'); process.stderr.write(' Check file permissions and executable bit.\n');
} else if (err.code === 'ENOENT') { } else if (err.code === 'ENOENT') {
if (isPowerShellScript) { if (isPowerShellScript) {
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).'); process.stderr.write(
console.error(' Ensure powershell.exe is available in PATH.'); '[X] PowerShell executable not found (required for .ps1 wrapper launch).\n'
);
process.stderr.write(' Ensure powershell.exe is available in PATH.\n');
} else if (needsShell) { } else if (needsShell) {
console.error('[X] Windows command shell not found for Droid wrapper launch.'); process.stderr.write('[X] Windows command shell not found for Droid wrapper launch.\n');
console.error(' Ensure cmd.exe is available and accessible.'); process.stderr.write(' Ensure cmd.exe is available and accessible.\n');
} else { } else {
console.error(`[X] Droid CLI not found: ${droidPath}`); process.stderr.write(`[X] Droid CLI not found: ${droidPath}\n`);
console.error(' Install: npm i -g @factory/cli'); process.stderr.write(' Install: npm i -g @factory/cli\n');
} }
} else { } else {
console.error(`[X] Failed to start Droid CLI (${droidPath}):`, err.message); process.stderr.write(`[X] Failed to start Droid CLI (${droidPath}): ${err.message}\n`);
} }
return exitWithCleanup(1); return exitWithCleanup(1);
}); });
+39 -16
View File
@@ -59,8 +59,10 @@ function hasMatchingContents(sourcePath: string, destinationPath: string): boole
return source.equals(destination); return source.equals(destination);
} catch (error) { } catch (error) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error( process.stderr.write(
String(
warn(`Existing Image Analysis MCP server is unreadable: ${(error as Error).message}`) warn(`Existing Image Analysis MCP server is unreadable: ${(error as Error).message}`)
) + '\n'
); );
} }
return false; return false;
@@ -170,7 +172,9 @@ function removeManagedServerConfig(configPath: string): boolean {
const config = readClaudeUserConfig(configPath); const config = readClaudeUserConfig(configPath);
if (config === null) { if (config === null) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`)); process.stderr.write(
String(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`)) + '\n'
);
} }
return false; return false;
} }
@@ -198,15 +202,19 @@ function removeManagedServerConfig(configPath: string): boolean {
try { try {
writeClaudeUserConfig(configPath, nextConfig); writeClaudeUserConfig(configPath, nextConfig);
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(info(`Removed Image Analysis MCP config from ${configPath}`)); process.stderr.write(
String(info(`Removed Image Analysis MCP config from ${configPath}`)) + '\n'
);
} }
return true; return true;
} catch (error) { } catch (error) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error( process.stderr.write(
String(
warn( warn(
`Failed to remove Image Analysis MCP config from ${configPath}: ${(error as Error).message}` `Failed to remove Image Analysis MCP config from ${configPath}: ${(error as Error).message}`
) )
) + '\n'
); );
} }
return false; return false;
@@ -215,10 +223,12 @@ function removeManagedServerConfig(configPath: string): boolean {
} catch (error) { } catch (error) {
if (isLockUnavailableError(error)) { if (isLockUnavailableError(error)) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error( process.stderr.write(
String(
warn( warn(
`Image Analysis MCP cleanup skipped because ${configPath} is locked by another process` `Image Analysis MCP cleanup skipped because ${configPath} is locked by another process`
) )
) + '\n'
); );
} }
return false; return false;
@@ -249,8 +259,9 @@ export function installImageAnalysisMcpServer(): boolean {
const missingArtifact = artifacts.find((artifact) => !artifact.sourcePath); const missingArtifact = artifacts.find((artifact) => !artifact.sourcePath);
if (missingArtifact) { if (missingArtifact) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error( process.stderr.write(
warn(`Image Analysis MCP runtime source not found: ${missingArtifact.fileName}`) String(warn(`Image Analysis MCP runtime source not found: ${missingArtifact.fileName}`)) +
'\n'
); );
} }
return false; return false;
@@ -301,8 +312,9 @@ export function installImageAnalysisMcpServer(): boolean {
return true; return true;
} catch (error) { } catch (error) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error( process.stderr.write(
warn(`Failed to install Image Analysis MCP server: ${(error as Error).message}`) String(warn(`Failed to install Image Analysis MCP server: ${(error as Error).message}`)) +
'\n'
); );
} }
return false; return false;
@@ -333,7 +345,9 @@ export function ensureImageAnalysisMcpConfig(): boolean {
if (config === null) { if (config === null) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(warn('Malformed ~/.claude.json prevents Image Analysis MCP provisioning')); process.stderr.write(
String(warn('Malformed ~/.claude.json prevents Image Analysis MCP provisioning')) + '\n'
);
} }
return false; return false;
} }
@@ -364,12 +378,16 @@ export function ensureImageAnalysisMcpConfig(): boolean {
try { try {
writeClaudeUserConfig(claudeUserConfigPath, nextConfig); writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(info(`Ensured Image Analysis MCP config in ${claudeUserConfigPath}`)); process.stderr.write(
String(info(`Ensured Image Analysis MCP config in ${claudeUserConfigPath}`)) + '\n'
);
} }
return true; return true;
} catch (error) { } catch (error) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`)); process.stderr.write(
String(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`)) + '\n'
);
} }
return false; return false;
} }
@@ -377,10 +395,12 @@ export function ensureImageAnalysisMcpConfig(): boolean {
} catch (error) { } catch (error) {
if (isLockUnavailableError(error)) { if (isLockUnavailableError(error)) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error( process.stderr.write(
String(
warn( warn(
`Image Analysis MCP provisioning skipped because ${claudeUserConfigPath} is locked by another process` `Image Analysis MCP provisioning skipped because ${claudeUserConfigPath} is locked by another process`
) )
) + '\n'
); );
} }
return false; return false;
@@ -426,8 +446,9 @@ export function uninstallImageAnalysisMcpServer(): boolean {
return removed; return removed;
} catch (error) { } catch (error) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error( process.stderr.write(
warn(`Failed to remove Image Analysis MCP server: ${(error as Error).message}`) String(warn(`Failed to remove Image Analysis MCP server: ${(error as Error).message}`)) +
'\n'
); );
} }
return false; return false;
@@ -461,10 +482,12 @@ export function ensureImageAnalysisMcpOrThrow(): boolean {
const ready = ensureImageAnalysisMcp(); const ready = ensureImageAnalysisMcp();
if (!ready) { if (!ready) {
console.error( process.stderr.write(
String(
warn( warn(
'Image Analysis is enabled, but CCS could not prepare the local ImageAnalysis tool. This session will fall back to native Read.' 'Image Analysis is enabled, but CCS could not prepare the local ImageAnalysis tool. This session will fall back to native Read.'
) )
) + '\n'
); );
} }
+36 -14
View File
@@ -55,8 +55,9 @@ function hasMatchingContents(sourcePath: string, destinationPath: string): boole
return source.equals(destination); return source.equals(destination);
} catch (error) { } catch (error) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error( process.stderr.write(
warn(`Existing WebSearch MCP server is unreadable: ${(error as Error).message}`) String(warn(`Existing WebSearch MCP server is unreadable: ${(error as Error).message}`)) +
'\n'
); );
} }
return false; return false;
@@ -127,7 +128,9 @@ function removeManagedServerConfig(configPath: string): boolean {
const config = readClaudeUserConfig(configPath); const config = readClaudeUserConfig(configPath);
if (config === null) { if (config === null) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`)); process.stderr.write(
String(warn(`Malformed Claude config prevents MCP cleanup: ${configPath}`)) + '\n'
);
} }
return false; return false;
} }
@@ -155,15 +158,19 @@ function removeManagedServerConfig(configPath: string): boolean {
try { try {
writeClaudeUserConfig(configPath, nextConfig); writeClaudeUserConfig(configPath, nextConfig);
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(info(`Removed WebSearch MCP config from ${configPath}`)); process.stderr.write(
String(info(`Removed WebSearch MCP config from ${configPath}`)) + '\n'
);
} }
return true; return true;
} catch (error) { } catch (error) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error( process.stderr.write(
String(
warn( warn(
`Failed to remove WebSearch MCP config from ${configPath}: ${(error as Error).message}` `Failed to remove WebSearch MCP config from ${configPath}: ${(error as Error).message}`
) )
) + '\n'
); );
} }
return false; return false;
@@ -192,8 +199,9 @@ export function installWebSearchMcpServer(): boolean {
if (!installWebSearchHook()) { if (!installWebSearchHook()) {
appendWebSearchTrace('websearch_mcp_install_failed', { reason: 'hook_unavailable' }); appendWebSearchTrace('websearch_mcp_install_failed', { reason: 'hook_unavailable' });
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error( process.stderr.write(
warn('WebSearch MCP server install skipped because hook runtime is unavailable') String(warn('WebSearch MCP server install skipped because hook runtime is unavailable')) +
'\n'
); );
} }
return false; return false;
@@ -203,7 +211,9 @@ export function installWebSearchMcpServer(): boolean {
if (!sourcePath) { if (!sourcePath) {
appendWebSearchTrace('websearch_mcp_install_failed', { reason: 'source_missing' }); appendWebSearchTrace('websearch_mcp_install_failed', { reason: 'source_missing' });
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(warn(`WebSearch MCP server source not found: ${WEBSEARCH_MCP_SERVER}`)); process.stderr.write(
String(warn(`WebSearch MCP server source not found: ${WEBSEARCH_MCP_SERVER}`)) + '\n'
);
} }
return false; return false;
} }
@@ -245,7 +255,9 @@ export function installWebSearchMcpServer(): boolean {
error: (error as Error).message, error: (error as Error).message,
}); });
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to install WebSearch MCP server: ${(error as Error).message}`)); process.stderr.write(
String(warn(`Failed to install WebSearch MCP server: ${(error as Error).message}`)) + '\n'
);
} }
return false; return false;
} finally { } finally {
@@ -271,7 +283,9 @@ export function ensureWebSearchMcpConfig(): boolean {
if (config === null) { if (config === null) {
appendWebSearchTrace('websearch_mcp_config_failed', { reason: 'malformed_user_config' }); appendWebSearchTrace('websearch_mcp_config_failed', { reason: 'malformed_user_config' });
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(warn('Malformed ~/.claude.json prevents WebSearch MCP provisioning')); process.stderr.write(
String(warn('Malformed ~/.claude.json prevents WebSearch MCP provisioning')) + '\n'
);
} }
return false; return false;
} }
@@ -311,7 +325,9 @@ export function ensureWebSearchMcpConfig(): boolean {
writeClaudeUserConfig(claudeUserConfigPath, nextConfig); writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
appendWebSearchTrace('websearch_mcp_config_ready', { configPath: claudeUserConfigPath }); appendWebSearchTrace('websearch_mcp_config_ready', { configPath: claudeUserConfigPath });
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(info(`Ensured WebSearch MCP config in ${claudeUserConfigPath}`)); process.stderr.write(
String(info(`Ensured WebSearch MCP config in ${claudeUserConfigPath}`)) + '\n'
);
} }
return true; return true;
} catch (error) { } catch (error) {
@@ -321,7 +337,9 @@ export function ensureWebSearchMcpConfig(): boolean {
error: (error as Error).message, error: (error as Error).message,
}); });
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`)); process.stderr.write(
String(warn(`Failed to update ~/.claude.json: ${(error as Error).message}`)) + '\n'
);
} }
return false; return false;
} }
@@ -334,10 +352,12 @@ export function ensureWebSearchMcpConfig(): boolean {
error: (error as Error).message, error: (error as Error).message,
}); });
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error( process.stderr.write(
String(
warn( warn(
`WebSearch MCP config skipped because ${claudeUserConfigPath} is locked by another process` `WebSearch MCP config skipped because ${claudeUserConfigPath} is locked by another process`
) )
) + '\n'
); );
} }
return false; return false;
@@ -381,7 +401,9 @@ export function uninstallWebSearchMcpServer(): boolean {
return true; return true;
} catch (error) { } catch (error) {
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to remove WebSearch MCP server: ${(error as Error).message}`)); process.stderr.write(
String(warn(`Failed to remove WebSearch MCP server: ${(error as Error).message}`)) + '\n'
);
} }
return false; return false;
} }
+12 -2
View File
@@ -23,6 +23,9 @@ import type { HealthReport } from '../health-service';
import type { CliproxyUsageHistoryDetail } from '../usage/cliproxy-usage-transformer'; import type { CliproxyUsageHistoryDetail } from '../usage/cliproxy-usage-transformer';
import { computeBarAnalyticsFromDaily } from '../usage/bar-analytics'; import { computeBarAnalyticsFromDaily } from '../usage/bar-analytics';
import type { DailyUsage, HourlyUsage } from '../usage/types'; import type { DailyUsage, HourlyUsage } from '../usage/types';
import { createLogger } from '../../services/logging';
const logger = createLogger('web-server:routes:bar');
// ============================================================================ // ============================================================================
// Types // Types
@@ -551,7 +554,11 @@ export function createBarRouter(deps: BarRouterDeps): Router {
res.json([...rows, ...nativeRows].map(serializeBarRow)); res.json([...rows, ...nativeRows].map(serializeBarRow));
} catch (err) { } catch (err) {
console.error('[bar-routes] /summary error:', (err as Error).message); const e = err as Error;
logger.error('bar.summary_failed', 'Failed to build bar summary payload', {
refresh: req.query['refresh'] === 'true',
err: { name: e.name, message: e.message },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -575,7 +582,10 @@ export function createBarRouter(deps: BarRouterDeps): Router {
const analytics = computeBarAnalyticsFromDaily(daily ?? [], hourly ?? [], new Date()); const analytics = computeBarAnalyticsFromDaily(daily ?? [], hourly ?? [], new Date());
res.json(analytics); res.json(analytics);
} catch (err) { } catch (err) {
console.error('[bar-routes] /analytics error:', (err as Error).message); const e = err as Error;
logger.error('bar.analytics_failed', 'Failed to compute bar analytics payload', {
err: { name: e.name, message: e.message },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
+20 -7
View File
@@ -1,4 +1,6 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { createLogger } from '../../services/logging';
import { import {
getAllAuthStatus, getAllAuthStatus,
getOAuthConfig, getOAuthConfig,
@@ -78,6 +80,7 @@ import { buildOAuthStartFailureGuidance } from '../../cliproxy/auth/oauth-start-
import { getStoredConfiguredBackend } from '../../cliproxy/binary-manager'; import { getStoredConfiguredBackend } from '../../cliproxy/binary-manager';
const router = Router(); const router = Router();
const logger = createLogger('web-server:routes:cliproxy-auth');
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000; const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 1000;
const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000; const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
@@ -1097,8 +1100,10 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
getStoredConfiguredBackend() getStoredConfiguredBackend()
); );
if (credentialError) { if (credentialError) {
console.error( logger.warn(
`[cliproxy-auth-routes] start-url credential guard fired for provider=${provider}: ${credentialError}` 'cliproxy_auth.start_url.credential_guard_fired',
'start-url credential guard fired for Plus-backend provider',
{ provider, reason: credentialError }
); );
res.status(400).json({ res.status(400).json({
error: 'plus_oauth_credentials_missing', error: 'plus_oauth_credentials_missing',
@@ -1154,8 +1159,10 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
const authUrlError = getPlusAuthUrlCredentialError(provider as CLIProxyProvider, authUrl); const authUrlError = getPlusAuthUrlCredentialError(provider as CLIProxyProvider, authUrl);
if (authUrlError) { if (authUrlError) {
const redactedUrl = authUrl.split('?')[0]; const redactedUrl = authUrl.split('?')[0];
console.error( logger.error(
`[cliproxy-auth-routes] Plus emitted OAuth URL without client_id for provider=${provider} url=${redactedUrl}` 'cliproxy_auth.start_url.missing_client_id',
'Plus emitted OAuth URL without client_id',
{ provider, urlOrigin: redactedUrl }
); );
res.status(502).json({ res.status(502).json({
error: 'plus_oauth_url_missing_client_id', error: 'plus_oauth_url_missing_client_id',
@@ -1192,8 +1199,10 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
}); });
} catch (error) { } catch (error) {
if (error instanceof SyntaxError) { if (error instanceof SyntaxError) {
console.error( logger.error(
`[cliproxy-auth-routes] Invalid OAuth start response for provider=${provider}: ${error.message}` 'cliproxy_auth.start_url.invalid_response',
'Invalid OAuth start response from CLIProxyAPI',
{ provider, err: { name: error.name, message: error.message } }
); );
res.status(502).json({ res.status(502).json({
error: 'cliproxy_oauth_start_invalid_response', error: 'cliproxy_oauth_start_invalid_response',
@@ -1211,7 +1220,11 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
startPath: startPath ?? `/v0/management/${authUrlProvider}-auth-url?is_webui=true`, startPath: startPath ?? `/v0/management/${authUrlProvider}-auth-url?is_webui=true`,
cause: error, cause: error,
}); });
console.error(`[cliproxy-auth-routes] ${guidance.message} ${guidance.details}`); logger.error(
'cliproxy_auth.start_url.request_failed',
guidance.message || 'OAuth start request failed',
{ provider, details: guidance.details }
);
res.status(503).json(guidance); res.status(503).json(guidance);
} }
}); });
+135 -22
View File
@@ -57,6 +57,9 @@ import {
import { installDashboardCliproxyVersion } from '../services/cliproxy-dashboard-install-service'; import { installDashboardCliproxyVersion } from '../services/cliproxy-dashboard-install-service';
import { restartDashboardCliproxy } from '../services/cliproxy-dashboard-restart-service'; import { restartDashboardCliproxy } from '../services/cliproxy-dashboard-restart-service';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
import { createLogger } from '../../services/logging';
const logger = createLogger('web-server:routes:cliproxy-stats');
const router = Router(); const router = Router();
type RestartDashboardCliproxyHandler = typeof restartDashboardCliproxy; type RestartDashboardCliproxyHandler = typeof restartDashboardCliproxy;
@@ -297,7 +300,12 @@ const handleStatsRequest = async (_req: Request, res: Response): Promise<void> =
res.json(stats); res.json(stats);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}; };
@@ -322,7 +330,12 @@ router.get('/status', async (_req: Request, res: Response): Promise<void> => {
const running = await isCliproxyRunning(resolveLifecyclePort()); const running = await isCliproxyRunning(resolveLifecyclePort());
res.json({ running }); res.json({ running });
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -360,7 +373,12 @@ router.get('/proxy-status', async (_req: Request, res: Response): Promise<void>
res.json(sessionStatus); res.json(sessionStatus);
} }
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -375,7 +393,12 @@ router.post('/proxy-start', async (_req: Request, res: Response): Promise<void>
const result = await ensureCliproxyService(resolveLifecyclePort()); const result = await ensureCliproxyService(resolveLifecyclePort());
res.json(result); res.json(result);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -389,7 +412,12 @@ router.post('/proxy-stop', async (_req: Request, res: Response): Promise<void> =
const result = await stopProxy(resolveLifecyclePort()); const result = await stopProxy(resolveLifecyclePort());
res.json(result); res.json(result);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -405,7 +433,12 @@ router.get('/update-check', async (_req: Request, res: Response): Promise<void>
res.json(result); res.json(result);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -438,7 +471,12 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
res.json(modelsResponse); res.json(modelsResponse);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -486,7 +524,12 @@ router.get('/error-logs', async (_req: Request, res: Response): Promise<void> =>
res.json({ files: filesWithMetadata }); res.json({ files: filesWithMetadata });
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -526,7 +569,12 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise<voi
res.type('text/plain').send(content); res.type('text/plain').send(content);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -548,7 +596,12 @@ router.get('/config.yaml', async (_req: Request, res: Response): Promise<void> =
const content = fs.readFileSync(configPath, 'utf8'); const content = fs.readFileSync(configPath, 'utf8');
res.type('text/yaml').send(content); res.type('text/yaml').send(content);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -582,7 +635,12 @@ router.put('/config.yaml', async (req: Request, res: Response): Promise<void> =>
res.json({ success: true, path: configPath }); res.json({ success: true, path: configPath });
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -617,7 +675,12 @@ router.get('/auth-files', async (_req: Request, res: Response): Promise<void> =>
res.json({ files, directory: authDir }); res.json({ files, directory: authDir });
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -654,7 +717,12 @@ router.get('/auth-files/download', async (req: Request, res: Response): Promise<
res.setHeader('Content-Disposition', `attachment; filename="${name}"`); res.setHeader('Content-Disposition', `attachment; filename="${name}"`);
res.type('application/octet-stream').send(content); res.type('application/octet-stream').send(content);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -742,7 +810,12 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise<voi
res.json({ success: true, provider, model: canonicalModel }); res.json({ success: true, provider, model: canonicalModel });
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -794,7 +867,12 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi
res.json(result); res.json(result);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -842,7 +920,12 @@ router.get('/quota/claude/:accountId', async (req: Request, res: Response): Prom
res.json(result); res.json(result);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -890,7 +973,12 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom
res.json(result); res.json(result);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -938,7 +1026,12 @@ router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promis
res.json(result); res.json(result);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -997,7 +1090,12 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P
res.json(result); res.json(result);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -1013,7 +1111,12 @@ router.get('/versions', async (_req: Request, res: Response): Promise<void> => {
const backend = getStoredConfiguredBackend(); const backend = getStoredConfiguredBackend();
res.json(await resolveCliproxyVersionsPayload(backend)); res.json(await resolveCliproxyVersionsPayload(backend));
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -1074,7 +1177,12 @@ router.post('/install', async (req: Request, res: Response): Promise<void> => {
...installResult, ...installResult,
}); });
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -1092,7 +1200,12 @@ export function registerCliproxyRestartRoute(
const result = await restartHandler(); const result = await restartHandler();
res.json(result); res.json(result);
} catch (error) { } catch (error) {
console.error(`[cliproxy-stats] ${(error as Error).message}`); logger.error('stats.route.error', 'CLIProxy stats route failed to handle request', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}); });
@@ -13,8 +13,10 @@ import {
getLocalSyncStatus, getLocalSyncStatus,
} from '../../cliproxy/sync'; } from '../../cliproxy/sync';
import { mutateConfig } from '../../config/config-loader-facade'; import { mutateConfig } from '../../config/config-loader-facade';
import { createLogger } from '../../services/logging';
const router = Router(); const router = Router();
const logger = createLogger('web-server:routes:cliproxy-sync');
/** /**
* GET /api/cliproxy/sync/status - Get local sync status * GET /api/cliproxy/sync/status - Get local sync status
@@ -147,7 +149,11 @@ router.put('/auto-sync', async (req: Request, res: Response): Promise<void> => {
await restartAutoSyncWatcher(); await restartAutoSyncWatcher();
} catch (watcherError) { } catch (watcherError) {
// Log but don't fail - config was saved successfully // Log but don't fail - config was saved successfully
console.warn('Watcher restart failed:', (watcherError as Error).message); const e = watcherError as Error;
logger.warn('auto_sync.watcher_restart_failed', 'Watcher restart failed after config save', {
enabled,
err: { name: e.name, message: e.message },
});
} }
res.json({ success: true, enabled }); res.json({ success: true, enabled });
+12 -1
View File
@@ -7,8 +7,10 @@ import rateLimit from 'express-rate-limit';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { getClaudeSettingsPath } from '../../utils/claude-config-path'; import { getClaudeSettingsPath } from '../../utils/claude-config-path';
import { createLogger } from '../../services/logging';
const router = Router(); const router = Router();
const logger = createLogger('web-server:routes:persist');
/** Rate limiter for restore endpoint - prevents abuse */ /** Rate limiter for restore endpoint - prevents abuse */
const restoreRateLimiter = rateLimit({ const restoreRateLimiter = rateLimit({
@@ -263,7 +265,16 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response):
fs.unlinkSync(tempPath); fs.unlinkSync(tempPath);
} }
} catch (rollbackErr) { } catch (rollbackErr) {
console.error('[persist-routes] Rollback failed:', rollbackErr); const e = rollbackErr as Error;
logger.error(
'persist.restore_rollback_failed',
'Restore failed and rollback unsuccessful - manual recovery may be needed',
{
timestamp: backup.timestamp,
settingsPath,
err: { name: e.name, message: e.message },
}
);
res.status(500).json({ res.status(500).json({
error: 'Restore failed and rollback unsuccessful - manual recovery may be needed', error: 'Restore failed and rollback unsuccessful - manual recovery may be needed',
}); });
+43 -5
View File
@@ -9,6 +9,7 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { createLogger } from '../../services/logging';
import { testConnection } from '../../cliproxy/services/remote-proxy-client'; import { testConnection } from '../../cliproxy/services/remote-proxy-client';
import { isProxyRunning } from '../../cliproxy/services/proxy-lifecycle-service'; import { isProxyRunning } from '../../cliproxy/services/proxy-lifecycle-service';
import { DEFAULT_BACKEND } from '../../cliproxy/binary/platform-detector'; import { DEFAULT_BACKEND } from '../../cliproxy/binary/platform-detector';
@@ -28,6 +29,8 @@ import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loa
const router = Router(); const router = Router();
const logger = createLogger('web-server:routes:cliproxy-server');
router.use((req: Request, res: Response, next) => { router.use((req: Request, res: Response, next) => {
if ( if (
requireLocalAccessWhenAuthDisabled( requireLocalAccessWhenAuthDisabled(
@@ -48,7 +51,12 @@ router.get('/', async (_req: Request, res: Response) => {
const config = await loadOrCreateUnifiedConfig(); const config = await loadOrCreateUnifiedConfig();
res.json(config.cliproxy_server || DEFAULT_CLIPROXY_SERVER_CONFIG); res.json(config.cliproxy_server || DEFAULT_CLIPROXY_SERVER_CONFIG);
} catch (error) { } catch (error) {
console.error('[cliproxy-server-routes] Failed to load proxy config:', error); logger.error('cliproxy_server.route.load_config_failed', 'Failed to load proxy config', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Failed to load proxy config' }); res.status(500).json({ error: 'Failed to load proxy config' });
} }
}); });
@@ -113,7 +121,14 @@ router.put('/', (req: Request, res: Response) => {
res.json(updated.cliproxy_server); res.json(updated.cliproxy_server);
} catch (error) { } catch (error) {
console.error('[cliproxy-server-routes] Failed to save proxy config:', error); logger.error('cliproxy_server.route.save_config_failed', 'Failed to save proxy config', {
path: req.path,
method: req.method,
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Failed to save proxy config' }); res.status(500).json({ error: 'Failed to save proxy config' });
} }
}); });
@@ -130,7 +145,12 @@ router.get('/backend', async (_req: Request, res: Response) => {
managementPanelRepository: getManagementPanelRepository(), managementPanelRepository: getManagementPanelRepository(),
}); });
} catch (error) { } catch (error) {
console.error('[cliproxy-server-routes] Failed to load backend config:', error); logger.error('cliproxy_server.route.load_backend_failed', 'Failed to load backend config', {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Failed to load backend config' }); res.status(500).json({ error: 'Failed to load backend config' });
} }
}); });
@@ -187,7 +207,14 @@ router.put('/backend', (req: Request, res: Response) => {
res.json({ backend, managementPanelRepository: getManagementPanelRepository() }); res.json({ backend, managementPanelRepository: getManagementPanelRepository() });
} catch (error) { } catch (error) {
console.error('[cliproxy-server-routes] Failed to save backend config:', error); logger.error('cliproxy_server.route.save_backend_failed', 'Failed to save backend config', {
path: req.path,
method: req.method,
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
res.status(500).json({ error: 'Failed to save backend config' }); res.status(500).json({ error: 'Failed to save backend config' });
} }
}); });
@@ -221,7 +248,18 @@ router.post('/test', async (req: Request, res: Response) => {
res.json(status); res.json(status);
} catch (error) { } catch (error) {
console.error('[cliproxy-server-routes] Failed to test connection:', error); logger.error(
'cliproxy_server.route.test_connection_failed',
'Failed to test remote proxy connection',
{
path: req.path,
method: req.method,
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
}
);
res.status(500).json({ error: 'Failed to test connection' }); res.status(500).json({ error: 'Failed to test connection' });
} }
}); });
+11 -5
View File
@@ -21,6 +21,9 @@ import type { TargetType } from '../../targets/target-adapter';
import { isPersistedTargetType } from '../../targets/target-metadata'; import { isPersistedTargetType } from '../../targets/target-metadata';
import { ValidationError } from '../../errors/error-types'; import { ValidationError } from '../../errors/error-types';
import { getCcsDir, loadConfigSafe, loadSettings } from '../../config/config-loader-facade'; import { getCcsDir, loadConfigSafe, loadSettings } from '../../config/config-loader-facade';
import { createLogger } from '../../services/logging';
const logger = createLogger('web-server:routes:helpers');
/** Model mapping for API profiles */ /** Model mapping for API profiles */
export interface ModelMapping { export interface ModelMapping {
@@ -498,11 +501,14 @@ export function createRouteErrorHelpers(prefix: string): {
) => void; ) => void;
} { } {
function logRouteError(context: string, error: unknown): void { function logRouteError(context: string, error: unknown): void {
if (error instanceof Error) { logger.error('route.error', `${prefix}: ${context}`, {
console.error(`[${prefix}] ${context}: ${error.message}`); prefix,
return; context,
} err:
console.error(`[${prefix}] ${context}: unknown error`); error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
} }
function respondInternalError( function respondInternalError(
+10 -8
View File
@@ -85,7 +85,7 @@ function getInstancePaths(): string[] {
return fs.existsSync(projectsPath); return fs.existsSync(projectsPath);
}); });
} catch { } catch {
console.error(fail('Failed to read CCS instances directory')); process.stderr.write(String(fail('Failed to read CCS instances directory')) + '\n');
return []; return [];
} }
} }
@@ -436,7 +436,7 @@ async function refreshFromSource(): Promise<{
instanceDataResults.push(data); instanceDataResults.push(data);
} catch (err) { } catch (err) {
const instanceName = path.basename(instancePath); const instanceName = path.basename(instancePath);
console.error(fail(`Failed to load instance ${instanceName}: ${err}`)); process.stderr.write(String(fail(`Failed to load instance ${instanceName}: ${err}`)) + '\n');
} }
} }
@@ -467,7 +467,7 @@ async function refreshFromSource(): Promise<{
console.log(info(`Included native Codex usage data (${codexEntries.length} event(s))`)); console.log(info(`Included native Codex usage data (${codexEntries.length} event(s))`));
} }
} catch (err) { } catch (err) {
console.error(fail(`Failed to load native Codex usage data: ${err}`)); process.stderr.write(String(fail(`Failed to load native Codex usage data: ${err}`)) + '\n');
} }
try { try {
@@ -480,7 +480,7 @@ async function refreshFromSource(): Promise<{
console.log(info(`Included native Droid usage data (${droidEntries.length} event(s))`)); console.log(info(`Included native Droid usage data (${droidEntries.length} event(s))`));
} }
} catch (err) { } catch (err) {
console.error(fail(`Failed to load native Droid usage data: ${err}`)); process.stderr.write(String(fail(`Failed to load native Droid usage data: ${err}`)) + '\n');
} }
// Load CLIProxy usage data (from local snapshot cache) // Load CLIProxy usage data (from local snapshot cache)
@@ -493,7 +493,7 @@ async function refreshFromSource(): Promise<{
console.log(info('Included CLIProxy usage data')); console.log(info('Included CLIProxy usage data'));
} }
} catch (err) { } catch (err) {
console.error(fail(`Failed to load CLIProxy usage data: ${err}`)); process.stderr.write(String(fail(`Failed to load CLIProxy usage data: ${err}`)) + '\n');
} }
// Merge all data sources // Merge all data sources
@@ -595,7 +595,7 @@ async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<
persistCacheIfComplete(); persistCacheIfComplete();
}) })
.catch((err) => { .catch((err) => {
console.error(fail(`Background refresh failed for ${key}: ${err}`)); process.stderr.write(String(fail(`Background refresh failed for ${key}: ${err}`)) + '\n');
}) })
.finally(() => { .finally(() => {
pendingRequests.delete(key); pendingRequests.delete(key);
@@ -731,7 +731,9 @@ export async function prewarmUsageCache(): Promise<{
isRefreshing = true; isRefreshing = true;
refreshFromSourceCoalesced() refreshFromSourceCoalesced()
.then(() => console.log(ok('Background refresh complete'))) .then(() => console.log(ok('Background refresh complete')))
.catch((err) => console.error(fail(`Background refresh failed: ${err}`))) .catch((err) =>
process.stderr.write(String(fail(`Background refresh failed: ${err}`)) + '\n')
)
.finally(() => { .finally(() => {
isRefreshing = false; isRefreshing = false;
}); });
@@ -748,7 +750,7 @@ export async function prewarmUsageCache(): Promise<{
console.log(ok(`Usage cache ready (${elapsed}ms)`)); console.log(ok(`Usage cache ready (${elapsed}ms)`));
return { timestamp: Date.now(), elapsed, source: 'fresh' }; return { timestamp: Date.now(), elapsed, source: 'fresh' };
} catch (err) { } catch (err) {
console.error(fail(`Failed to prewarm usage cache: ${err}`)); process.stderr.write(String(fail(`Failed to prewarm usage cache: ${err}`)) + '\n');
throw err; throw err;
} }
} }
+3 -1
View File
@@ -121,7 +121,9 @@ export function filterByDateRange<
} }
export function errorResponse(res: Response, error: unknown, defaultMessage: string): void { export function errorResponse(res: Response, error: unknown, defaultMessage: string): void {
console.error(defaultMessage + ':', error); process.stderr.write(
String(`${defaultMessage}: ${error instanceof Error ? error.message : String(error)}`) + '\n'
);
const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const errorMessage = error instanceof Error ? error.message : 'Unknown error';
const isValidationError = const isValidationError =
errorMessage.includes('Invalid') || errorMessage.includes('Invalid') ||
@@ -10,11 +10,12 @@ import { DelegationHandler } from '../../../src/delegation/delegation-handler';
describe('DelegationHandler', () => { describe('DelegationHandler', () => {
let handler: DelegationHandler; let handler: DelegationHandler;
// Validation warnings are written to stderr (process.stderr.write), not console.error.
let consoleErrorSpy: ReturnType<typeof spyOn>; let consoleErrorSpy: ReturnType<typeof spyOn>;
beforeEach(() => { beforeEach(() => {
handler = new DelegationHandler(); handler = new DelegationHandler();
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); consoleErrorSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
}); });
afterEach(() => { afterEach(() => {
@@ -0,0 +1,126 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { createEmptyUnifiedConfig } from '../../../../src/config/unified-config-types';
import { saveUnifiedConfig } from '../../../../src/config/unified-config-loader';
import {
clearRecentLogEntries,
createLogger,
getRecentLogEntries,
invalidateLoggingConfigCache,
} from '../../../../src/services/logging';
/**
* MR1 hard gate: proves token-laden payloads routed through the structured
* logger are scrubbed. Guards the P3 hotpath console.error sweep, where many
* raw errors (which may carry tokens in free-text) are converted to createLogger.
*
* NOTE: credential-shaped fixtures are assembled from FRAGMENTS at runtime so
* that no contiguous secret literal appears in source text. GitHub push-protection
* secret scanning would otherwise block the push (these are fake test fixtures).
* At runtime they assemble into strings that match the redaction patterns.
*/
function anthropicToken(): string {
return ['s', 'k-ant-api0', '3-', 'x'.repeat(40)].join('');
}
function openaiToken(): string {
return ['s', 'k-proj-', 'y'.repeat(40)].join('');
}
function slackToken(): string {
return ['xo', 'xb-', '1'.repeat(24), '-', 'z'.repeat(24)].join('');
}
function githubToken(): string {
return ['gh', 'p_', '0'.repeat(36)].join('');
}
function gitlabToken(): string {
return ['gl', 'pat-', '9'.repeat(20)].join('');
}
function googleToken(): string {
return ['AI', 'za', '8'.repeat(35)].join('');
}
function jwtToken(): string {
return ['e', 'yJ', 'a'.repeat(12), '.', 'e', 'yJ', 'b'.repeat(12), '.', 'c'.repeat(12)].join('');
}
function apiKeyEqToken(): string {
return ['api_', 'key=', 's', 'k-live-', 'd'.repeat(20)].join('');
}
function bearerToken(): string {
return 'Authorization: Bearer ' + jwtToken();
}
describe('hotpath redaction regression (token-laden payloads)', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
beforeEach(() => {
originalCcsHome = process.env.CCS_HOME;
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-redact-'));
process.env.CCS_HOME = tempHome;
clearRecentLogEntries();
invalidateLoggingConfigCache();
const config = createEmptyUnifiedConfig();
config.logging = { ...config.logging, enabled: true, level: 'debug', redact: true };
saveUnifiedConfig(config);
invalidateLoggingConfigCache();
});
afterEach(() => {
if (originalCcsHome === undefined) delete process.env.CCS_HOME;
else process.env.CCS_HOME = originalCcsHome;
fs.rmSync(tempHome, { recursive: true, force: true });
clearRecentLogEntries();
invalidateLoggingConfigCache();
});
const cases: Array<[string, () => string]> = [
['bearer', bearerToken],
['anthropic', anthropicToken],
['openai', openaiToken],
['slack', slackToken],
['github', githubToken],
['gitlab', gitlabToken],
['google', googleToken],
['jwt-body', jwtToken],
['api_key_eq', apiKeyEqToken],
];
for (const [name, buildToken] of cases) {
test(`scrubs ${name} token in a context value (under a non-sensitive key)`, () => {
const token = buildToken();
const logger = createLogger('test:redaction');
logger.error('test.token.in.value', `token shape ${name}`, { detail: `request failed: ${token}` });
const entry = getRecentLogEntries().find((e) => e.event === 'test.token.in.value');
expect(entry).toBeDefined();
expect(JSON.stringify(entry)).not.toContain(token);
});
}
test('scrubs token embedded in an Error.message passed as err context', () => {
const token = bearerToken();
const logger = createLogger('test:redaction');
const err = new Error(`Auth failed: ${token}`);
logger.error('test.err.message', 'error carrying token', { err });
const entry = getRecentLogEntries().find((e) => e.event === 'test.err.message');
expect(entry).toBeDefined();
expect(JSON.stringify(entry)).not.toContain(token);
});
test('scrubs token shapes that leak into the message string (defense-in-depth)', () => {
const token = anthropicToken();
const logger = createLogger('test:redaction');
logger.error('test.message.scrub', `boom ${token} in prose`, {});
const entry = getRecentLogEntries().find((e) => e.event === 'test.message.scrub');
expect(entry).toBeDefined();
expect(entry?.message).not.toContain(token);
});
test('preserves non-sensitive prose messages unchanged', () => {
const logger = createLogger('test:redaction');
logger.error('test.prose', 'Delegation failed: target adapter not found', { provider: 'codex' });
const entry = getRecentLogEntries().find((e) => e.event === 'test.prose');
expect(entry?.message).toBe('Delegation failed: target adapter not found');
expect((entry?.context as Record<string, unknown>)?.provider).toBe('codex');
});
});