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
+1710 -896

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;
+92 -76
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(
info( String(
`Restored ${session.accounts.length} auto-paused account(s) from crashed ${session.initiator} session` info(
) `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);
+37 -33
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(
fail( String(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro' fail(
) '--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(
fail( String(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc' fail(
) '--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;
} }
+24 -17
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(
fail( String(
`Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.` fail(
) `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);
} }
+26 -15
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(
warn( String(
`Port ${port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})` warn(
) `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();
} }
}); });
+80 -99
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,14 +424,22 @@ 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 }
);
}
if (this.config.verbose) {
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,
}
); );
} }
this.log(
`Sanitized ${schemaResult.totalRemoved} schema properties across ${schemaResult.removedByTool.length} tool(s)`
);
} }
let rewrittenTools = schemaResult.tools as Tool[]; let rewrittenTools = schemaResult.tools as Tool[];
@@ -501,14 +453,26 @@ 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,
}
);
}
if (this.config.verbose) {
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,
}
); );
} }
this.log(
`Stripped ${fieldResult.totalRemoved} unsupported top-level tool field(s) across ${fieldResult.removedByTool.length} tool(s)`
);
} }
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();
for (const change of changes) { if (this.config.warnOnSanitize) {
this.warn(`"${change.original}" → "${change.sanitized}"`); for (const change of changes) {
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);
} }
} }
+41 -25
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(
info( String(
`Continuity inheritance active: profile "${profile}" -> account "${continuityInheritance.sourceAccount}"` info(
) `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(
info( String(
`${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This delegation will use native Read.` info(
) `${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(
warn( String(
`Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This delegation will use native Read.` warn(
) `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(
timedOut String(
? ui.warn(`Timed out after ${durationSec}s`) timedOut
: ui.info(`Completed in ${durationSec}s`) ? ui.warn(`Timed out after ${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));
} }
} }
+37 -30
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(
warn( String(
`Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.` warn(
) `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(
warn( String(
`[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}` warn(
) `[!] 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);
} }
+35 -18
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(
fail( String(
`${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target` fail(
) `${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(
fail( String(
`Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)` fail(
) `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;
+51 -37
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(
info( String(
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"` info(
) `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(
fail( String(
compatibility.reason || fail(
`${targetAdapter?.displayName || resolvedTarget} does not support this profile.` compatibility.reason ||
) `${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(
info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`) String(
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(
info( String(
`Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}` info(
) `Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}`
)
) + '\n'
); );
const proxyEnv = { const proxyEnv = {
+47 -29
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(
fail( String(
compatibility.reason || `${targetAdapter.displayName} does not support this profile.` fail(
) 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(
fail( String(
compatibility.reason || `${targetAdapter.displayName} does not support this profile.` fail(
) 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(
fail( String(
`${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN` fail(
) `${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(
warn( String(
`[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || '<first-flag>'}` warn(
) `[!] 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(
info('Detected Droid exec-only flags. Routing as: droid exec <flags> [prompt]') String(
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);
}); });
+52 -29
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(
warn(`Existing Image Analysis MCP server is unreadable: ${(error as Error).message}`) String(
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(
warn( String(
`Failed to remove Image Analysis MCP config from ${configPath}: ${(error as Error).message}` warn(
) `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(
warn( String(
`Image Analysis MCP cleanup skipped because ${configPath} is locked by another process` warn(
) `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(
warn( String(
`Image Analysis MCP provisioning skipped because ${claudeUserConfigPath} is locked by another process` warn(
) `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(
warn( String(
'Image Analysis is enabled, but CCS could not prepare the local ImageAnalysis tool. This session will fall back to native Read.' warn(
) 'Image Analysis is enabled, but CCS could not prepare the local ImageAnalysis tool. This session will fall back to native Read.'
)
) + '\n'
); );
} }
+42 -20
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(
warn( String(
`Failed to remove WebSearch MCP config from ${configPath}: ${(error as Error).message}` warn(
) `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(
warn( String(
`WebSearch MCP config skipped because ${claudeUserConfigPath} is locked by another process` warn(
) `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');
});
});