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/executor/arg-parser.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}",
"syncFs": {
"totalOccurrences": 2304,
"filesAffected": 243,
"hotpathOccurrences": 1949,
"hotpathFilesAffected": 193,
"totalOccurrences": 2301,
"filesAffected": 242,
"hotpathOccurrences": 1946,
"hotpathFilesAffected": 192,
"topHotpathFiles": [
{
"file": "src/cliproxy/__tests__/pool-routing-phase3.test.ts",
@@ -561,9 +561,9 @@
"targetRatio": 0.4
},
"loggerCoverage": {
"filesWithCreateLogger": 35,
"filesWithCreateLogger": 64,
"totalSourceFiles": 685,
"coverageRatio": 0.0511,
"coverageRatio": 0.0934,
"subdomainsWithZeroCreateLogger": [
"api",
"bin",
@@ -573,16 +573,11 @@
"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"
],
@@ -590,7 +585,7 @@
{
"subdomain": "web-server",
"count": 103,
"withLogger": 3
"withLogger": 10
},
{
"subdomain": "utils",
@@ -635,75 +630,75 @@
{
"subdomain": "cliproxy/executor",
"count": 17,
"withLogger": 0
"withLogger": 4
}
]
},
"hotpathConsoleErrors": {
"totalOccurrences": 1091,
"exemptOccurrences": 160,
"hotpathOccurrences": 931,
"filesAffected": 134,
"totalOccurrences": 569,
"exemptOccurrences": 302,
"hotpathOccurrences": 267,
"filesAffected": 82,
"topFiles": [
{
"file": "src/utils/error-manager.ts",
"count": 142
"file": "src/errors/error-handler.ts",
"count": 11
},
{
"file": "src/cliproxy/accounts/account-safety.ts",
"count": 56
"file": "src/utils/prompt.ts",
"count": 11
},
{
"file": "src/cliproxy/config/model-config.ts",
"count": 32
"file": "src/utils/websearch/profile-hook-injector.ts",
"count": 10
},
{
"file": "src/cliproxy/executor/arg-parser.ts",
"count": 26
"file": "src/cliproxy/accounts/account-safety-cross-lane.ts",
"count": 9
},
{
"file": "src/dispatcher/flows/settings-flow.ts",
"count": 26
"file": "src/utils/hooks/image-analyzer-profile-hook-injector.ts",
"count": 9
},
{
"file": "src/copilot/copilot-executor.ts",
"count": 24
"file": "src/utils/websearch/hook-installer.ts",
"count": 8
},
{
"file": "src/delegation/delegation-handler.ts",
"count": 23
"file": "src/cliproxy/auth/token-manager.ts",
"count": 7
},
{
"file": "src/dispatcher/cli-argument-parser.ts",
"count": 22
"file": "src/cliproxy/binary/downloader.ts",
"count": 7
},
{
"file": "src/web-server/routes/cliproxy-stats-routes.ts",
"count": 22
"file": "src/cliproxy/executor/account-resolution.ts",
"count": 7
},
{
"file": "src/cliproxy/executor/lifecycle-manager.ts",
"count": 16
"file": "src/config/unified-config-loader.ts",
"count": 7
},
{
"file": "src/cursor/cursor-profile-executor.ts",
"count": 16
"file": "src/targets/claude-adapter.ts",
"count": 7
},
{
"file": "src/dispatcher/profile-resolver.ts",
"count": 16
"file": "src/utils/shell-executor.ts",
"count": 7
},
{
"file": "src/cliproxy/auth/antigravity-responsibility.ts",
"count": 15
"file": "src/utils/websearch/hook-config.ts",
"count": 7
},
{
"file": "src/cliproxy/executor/auth-coordinator.ts",
"count": 14
"file": "src/targets/droid-detector.ts",
"count": 6
},
{
"file": "src/cliproxy/executor/model-warnings.ts",
"count": 13
"file": "src/utils/hooks/image-analyzer-hook-installer.ts",
"count": 6
}
]
},
@@ -717,31 +712,31 @@
},
{
"file": "src/web-server/routes/cliproxy-auth-routes.ts",
"loc": 1502
"loc": 1515
},
{
"file": "src/cliproxy/auth/oauth-handler.ts",
"loc": 1453
},
{
"file": "src/web-server/routes/cliproxy-stats-routes.ts",
"loc": 1238
},
{
"file": "src/cursor/cursor-executor.ts",
"loc": 1234
},
{
"file": "src/cliproxy/quota/quota-fetcher-gemini-cli.ts",
"loc": 1130
"loc": 1183
},
{
"file": "src/commands/cliproxy/quota-subcommand.ts",
"loc": 1130
},
{
"file": "src/web-server/routes/cliproxy-stats-routes.ts",
"loc": 1103
},
{
"file": "src/cliproxy/quota/quota-fetcher.ts",
"loc": 1087
"loc": 1094
},
{
"file": "src/commands/persist-command.ts",
@@ -755,14 +750,14 @@
"file": "src/web-server/routes/settings-routes.ts",
"loc": 1040
},
{
"file": "src/cliproxy/proxy/tool-sanitization-proxy.ts",
"loc": 1039
},
{
"file": "src/cliproxy/config/env-builder.ts",
"loc": 1037
},
{
"file": "src/cliproxy/proxy/tool-sanitization-proxy.ts",
"loc": 1022
},
{
"file": "src/cliproxy/auth/oauth-process.ts",
"loc": 1018
+28 -28
View File
@@ -6,10 +6,10 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
| Metric | Value |
|---|---:|
| Sync fs occurrences (all) | 2304 |
| Sync fs files affected (all) | 243 |
| Sync fs occurrences (runtime hotpaths) | 1949 |
| Sync fs files affected (runtime hotpaths) | 193 |
| Sync fs occurrences (all) | 2301 |
| Sync fs files affected (all) | 242 |
| Sync fs occurrences (runtime hotpaths) | 1946 |
| Sync fs files affected (runtime hotpaths) | 192 |
| Legacy shim markers | 424 |
| 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 (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 files | 134 |
| files with createLogger | 35/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) |
| hotpath console.error/warn occurrences | 267 (569 total, 302 CLI-UX exempt) |
| hotpath console.error/warn files | 82 |
| files with createLogger | 64/685 |
| 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 > 600 LOC | 45 |
@@ -69,39 +69,39 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}`
| File | console.error/warn |
|---|---:|
| `src/utils/error-manager.ts` | 142 |
| `src/cliproxy/accounts/account-safety.ts` | 56 |
| `src/cliproxy/config/model-config.ts` | 32 |
| `src/cliproxy/executor/arg-parser.ts` | 26 |
| `src/dispatcher/flows/settings-flow.ts` | 26 |
| `src/copilot/copilot-executor.ts` | 24 |
| `src/delegation/delegation-handler.ts` | 23 |
| `src/dispatcher/cli-argument-parser.ts` | 22 |
| `src/web-server/routes/cliproxy-stats-routes.ts` | 22 |
| `src/cliproxy/executor/lifecycle-manager.ts` | 16 |
| `src/cursor/cursor-profile-executor.ts` | 16 |
| `src/dispatcher/profile-resolver.ts` | 16 |
| `src/cliproxy/auth/antigravity-responsibility.ts` | 15 |
| `src/cliproxy/executor/auth-coordinator.ts` | 14 |
| `src/cliproxy/executor/model-warnings.ts` | 13 |
| `src/errors/error-handler.ts` | 11 |
| `src/utils/prompt.ts` | 11 |
| `src/utils/websearch/profile-hook-injector.ts` | 10 |
| `src/cliproxy/accounts/account-safety-cross-lane.ts` | 9 |
| `src/utils/hooks/image-analyzer-profile-hook-injector.ts` | 9 |
| `src/utils/websearch/hook-installer.ts` | 8 |
| `src/cliproxy/auth/token-manager.ts` | 7 |
| `src/cliproxy/binary/downloader.ts` | 7 |
| `src/cliproxy/executor/account-resolution.ts` | 7 |
| `src/config/unified-config-loader.ts` | 7 |
| `src/targets/claude-adapter.ts` | 7 |
| `src/utils/shell-executor.ts` | 7 |
| `src/utils/websearch/hook-config.ts` | 7 |
| `src/targets/droid-detector.ts` | 6 |
| `src/utils/hooks/image-analyzer-hook-installer.ts` | 6 |
### Files > 400 LOC (top 15)
| File | LOC |
|---|---:|
| `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/web-server/routes/cliproxy-stats-routes.ts` | 1238 |
| `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/web-server/routes/cliproxy-stats-routes.ts` | 1103 |
| `src/cliproxy/quota/quota-fetcher.ts` | 1087 |
| `src/cliproxy/quota/quota-fetcher.ts` | 1094 |
| `src/commands/persist-command.ts` | 1071 |
| `src/web-server/model-pricing.ts` | 1070 |
| `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/proxy/tool-sanitization-proxy.ts` | 1022 |
| `src/cliproxy/auth/oauth-process.ts` | 1018 |
| `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).
// Diagnostics here are legitimate user-facing terminal output, not loggable
// errors, and stay on stdout/stderr via utils/ui.
const CLI_UX_EXEMPT_PREFIXES = ['src/commands/', 'src/management/', 'src/utils/ui/'];
// errors, and stay on stdout/stderr via utils/ui. src/utils/error-manager.ts is
// 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 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();
if (duplicates.size === 0) return false;
console.error('');
console.error(warn('Account safety: cross-provider duplicate detected'));
console.error(
' Same Google account across "ccs gemini" + "ccs agy" is a known suspension/ban risk (ref: #509).'
process.stderr.write('\n');
process.stderr.write(String(warn('Account safety: cross-provider duplicate detected')) + '\n');
process.stderr.write(
' 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".');
console.error(
' If provider requests start returning 403/Forbidden, treat it as a possible account disable/ban.'
process.stderr.write(
' This risk applies to both CLI sessions and accounts added from "ccs config".\n'
);
console.error(
' If you want to keep Google AI access on this account, do not continue this shared-account setup.'
process.stderr.write(
' If provider requests start returning 403/Forbidden, treat it as a possible account disable/ban.\n'
);
console.error(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.'
process.stderr.write(
' 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}`);
console.error('');
process.stderr.write(
' 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) {
console.error(` ${maskEmail(email)} -> ${providers.join(', ')}`);
process.stderr.write(` ${maskEmail(email)} -> ${providers.join(', ')}\n`);
}
console.error('');
console.error(' Immediate action: pause duplicate account and use separate Google accounts.');
console.error(' Fix command: "ccs cliproxy pause <account> --provider <provider>"');
console.error('');
process.stderr.write('\n');
process.stderr.write(
' Immediate action: pause duplicate account and use separate Google accounts.\n'
);
process.stderr.write(' Fix command: "ccs cliproxy pause <account> --provider <provider>"\n');
process.stderr.write('\n');
return true;
}
@@ -289,27 +293,31 @@ export function warnNewAccountConflict(
email: string,
conflictingProviders: CLIProxyProvider[]
): void {
console.error('');
console.error(warn('Account safety: this email is used by another provider'));
console.error(
` ${maskEmail(email)} is also registered under: ${conflictingProviders.join(', ')}`
process.stderr.write('\n');
process.stderr.write(
String(warn('Account safety: this email is used by another provider')) + '\n'
);
console.error(
' Reusing one Google account between "ccs gemini" and "ccs agy" can trigger bans.'
process.stderr.write(
` ${maskEmail(email)} is also registered under: ${conflictingProviders.join(', ')}\n`
);
console.error(
' This applies to both CLI auth and "ccs config" dashboard auth for these providers.'
process.stderr.write(
' 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.');
console.error(
' If you want to keep Google AI access, do not continue with this shared-account setup.'
process.stderr.write(
' This applies to both CLI auth and "ccs config" dashboard auth for these providers.\n'
);
console.error(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.'
process.stderr.write(
' 403/Forbidden responses can be an early sign of account disablement.\n'
);
console.error(' Consider pausing the duplicate or using a different account.');
console.error(` Details: ${ISSUE_509_URL}`);
console.error('');
process.stderr.write(
' If you want to keep Google AI access, do not continue with this shared-account setup.\n'
);
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 {
@@ -324,27 +332,29 @@ export function warnOAuthBanRisk(provider: CLIProxyProvider): void {
shownBanWarnings.add(provider);
const isAgy = provider === 'agy';
console.error('');
console.error(warn('Account safety warning (#509 - read before continuing)'));
console.error(
' Known risk: one Google account shared by "ccs gemini" + "ccs agy" can be disabled/banned.'
process.stderr.write('\n');
process.stderr.write(
String(warn('Account safety warning (#509 - read before continuing)')) + '\n'
);
process.stderr.write(
' Known risk: one Google account shared by "ccs gemini" + "ccs agy" can be disabled/banned.\n'
);
if (isAgy) {
console.error(
' Antigravity-specific warning: OAuth usage can still trigger suspension/ban patterns.'
process.stderr.write(
' Antigravity-specific warning: OAuth usage can still trigger suspension/ban patterns.\n'
);
}
console.error(
' This risk applies whether auth was done from CLI or from "ccs config" dashboard.'
process.stderr.write(
' This risk applies whether auth was done from CLI or from "ccs config" dashboard.\n'
);
console.error(
' If you want to keep Google AI access, do not continue with this shared-account setup.'
process.stderr.write(
' If you want to keep Google AI access, do not continue with this shared-account setup.\n'
);
console.error(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.'
process.stderr.write(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.\n'
);
console.error(` Details: ${ISSUE_509_URL}`);
console.error('');
process.stderr.write(` Details: ${ISSUE_509_URL}\n`);
process.stderr.write('\n');
}
/**
@@ -364,20 +374,22 @@ export function warnPossible403Ban(provider: CLIProxyProvider, errorMessage: str
return false;
}
console.error('');
console.error(warn(`Account safety: ${provider} returned 403/Forbidden (possible disable/ban)`));
console.error(
' For gemini/agy flows this often means Google blocked or disabled the account.'
process.stderr.write('\n');
process.stderr.write(
String(warn(`Account safety: ${provider} returned 403/Forbidden (possible disable/ban)`)) + '\n'
);
console.error(
' If you want to keep Google AI access, stop using this account/provider pairing immediately.'
process.stderr.write(
' For gemini/agy flows this often means Google blocked or disabled the account.\n'
);
console.error(
' CCS is provided as-is and cannot take responsibility for suspension/ban/access-loss decisions.'
process.stderr.write(
' If you want to keep Google AI access, stop using this account/provider pairing immediately.\n'
);
console.error(` Details: ${ISSUE_509_URL}`);
console.error(` Error: "${truncate(errorMessage, 160)}"`);
console.error('');
process.stderr.write(
' 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(` Error: "${truncate(errorMessage, 160)}"\n`);
process.stderr.write('\n');
return true;
}
@@ -402,10 +414,12 @@ export function cleanupStaleAutoPauses(): void {
for (const { provider, accountId } of session.accounts) {
resumeAccount(provider, accountId);
}
console.error(
info(
`Restored ${session.accounts.length} auto-paused account(s) from crashed ${session.initiator} session`
)
process.stderr.write(
String(
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);
console.error('');
console.error(info(`Account safety: auto-paused ${toPause.length} conflicting account(s)`));
process.stderr.write('\n');
process.stderr.write(
String(info(`Account safety: auto-paused ${toPause.length} conflicting account(s)`)) + '\n'
);
for (const { provider: p, accountId } of toPause) {
const acct = registry.providers[p]?.accounts[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.');
console.error('');
process.stderr.write(' Will restore on session exit.\n');
process.stderr.write('\n');
return toPause.length;
}
@@ -646,14 +662,14 @@ export function handleBanDetection(
if (!isBanResponse(errorMessage, provider)) return false;
const actor = banActor(provider);
console.error('');
console.error(warn(`Account safety: account appears disabled by ${actor}`));
console.error(` Account "${maskEmail(accountId)}" (${provider}) returned:`);
console.error(` "${truncate(errorMessage, 120)}"`);
console.error('');
console.error(info('Auto-pausing this account to prevent further issues.'));
console.error(` Resume later: ccs ${provider} --resume ${accountId}`);
console.error('');
process.stderr.write('\n');
process.stderr.write(String(warn(`Account safety: account appears disabled by ${actor}`)) + '\n');
process.stderr.write(` Account "${maskEmail(accountId)}" (${provider}) returned:\n`);
process.stderr.write(` "${truncate(errorMessage, 120)}"\n`);
process.stderr.write('\n');
process.stderr.write(String(info('Auto-pausing this account to prevent further issues.')) + '\n');
process.stderr.write(` Resume later: ccs ${provider} --resume ${accountId}\n`);
process.stderr.write('\n');
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();
if (normalized === 'YES') return true;
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) {
return true;
}
console.error(warn('Phrase mismatch. Try again.'));
process.stderr.write(String(warn('Phrase mismatch. Try again.')) + '\n');
}
return false;
}
@@ -119,14 +119,22 @@ function printResponsibilityHeader(context: AgyRiskContext): void {
? 'You are starting Antigravity OAuth account authorization.'
: 'You are starting a live Antigravity CLI session (ccs agy).';
console.error('');
console.error('╔══════════════════════════════════════════════════════════════════════╗');
console.error('║ Antigravity Responsibility Confirmation (Mandatory) ║');
console.error('╚══════════════════════════════════════════════════════════════════════╝');
console.error(` ${contextLine}`);
console.error(' Antigravity has active ban/suspension patterns for risky OAuth usage.');
console.error(` Policy issue: ${ANTIGRAVITY_RISK_ISSUE_URL}`);
console.error('');
process.stderr.write('' + '\n');
process.stderr.write(
'╔══════════════════════════════════════════════════════════════════════╗' + '\n'
);
process.stderr.write(
'║ Antigravity Responsibility Confirmation (Mandatory) ║' + '\n'
);
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 {
@@ -178,9 +186,11 @@ export async function ensureCliAntigravityResponsibility(
}
if (!process.stdin.isTTY || !process.stderr.isTTY) {
console.error(fail('Antigravity responsibility acknowledgement required.'));
console.error(' Re-run interactively and complete the 4-step confirmation.');
console.error(' Non-interactive override: --accept-agr-risk');
process.stderr.write(
String(fail('Antigravity responsibility acknowledgement required.')) + '\n'
);
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;
}
@@ -216,8 +226,10 @@ export async function ensureCliAntigravityResponsibility(
const step4 = await askResponsibilityPhrase(rl);
if (!step4) return false;
console.error(ok('Antigravity responsibility acknowledgement accepted for this command.'));
console.error(info('Proceeding with Antigravity flow...'));
process.stderr.write(
String(ok('Antigravity responsibility acknowledgement accepted for this command.')) + '\n'
);
process.stderr.write(String(info('Proceeding with Antigravity flow...')) + '\n');
return true;
} finally {
rl.close();
+41 -33
View File
@@ -135,13 +135,17 @@ export async function configureProviderModel(
const safeDefaultIdx = defaultIdx >= 0 ? defaultIdx : 0;
// Show header with context (gradient like ccs doctor)
console.error('');
console.error(header(`Configure ${catalog.displayName} Model`));
console.error('');
console.error(dim(' Select which model to use for this provider.'));
console.error(dim(' Models marked [Pro]/[Ultra] require a paid provider plan.'));
console.error(dim(' Models marked [DEPRECATED] are not recommended for use.'));
console.error('');
process.stderr.write('\n');
process.stderr.write(String(header(`Configure ${catalog.displayName} Model`)) + '\n');
process.stderr.write('\n');
process.stderr.write(String(dim(' Select which model to use for this provider.')) + '\n');
process.stderr.write(
String(dim(' Models marked [Pro]/[Ultra] require a paid provider plan.')) + '\n'
);
process.stderr.write(
String(dim(' Models marked [DEPRECATED] are not recommended for use.')) + '\n'
);
process.stderr.write('\n');
// Interactive selection
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 displayName = selectedEntry?.name || selectedModel;
console.error('');
console.error(ok(`Model set to: ${bold(displayName)}`));
console.error(dim(` Config saved: ${settingsPath}`));
process.stderr.write('\n');
process.stderr.write(String(ok(`Model set to: ${bold(displayName)}`)) + '\n');
process.stderr.write(String(dim(` Config saved: ${settingsPath}`)) + '\n');
// Show deprecation warning if model is deprecated
if (selectedEntry?.deprecated) {
console.error('');
console.error(color('[!] DEPRECATION WARNING', 'warning'));
process.stderr.write('\n');
process.stderr.write(String(color('[!] DEPRECATION WARNING', 'warning')) + '\n');
const reason = selectedEntry.deprecationReason || 'This model is deprecated';
console.error(dim(` ${reason}`));
console.error(dim(' Consider using a non-deprecated model for better compatibility.'));
process.stderr.write(String(dim(` ${reason}`)) + '\n');
process.stderr.write(
String(dim(' Consider using a non-deprecated model for better compatibility.')) + '\n'
);
}
console.error('');
process.stderr.write('\n');
return true;
}
@@ -231,7 +237,9 @@ export async function configureProviderModel(
*/
export async function showCurrentConfig(provider: CLIProxyProvider): Promise<void> {
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;
}
@@ -247,33 +255,33 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise<voi
? canonicalizeModelForProvider(provider, currentModel)
: undefined;
console.error('');
console.error(header(`${catalog.displayName} Model Configuration`));
console.error('');
process.stderr.write('\n');
process.stderr.write(String(header(`${catalog.displayName} Model Configuration`)) + '\n');
process.stderr.write('\n');
if (currentModel) {
const entry = catalog.models.find((m) => m.id === normalizedCurrentModel);
const displayName = entry?.name || 'Unknown';
console.error(
` ${bold('Current:')} ${color(displayName, 'success')} ${dim(`(${currentModel})`)}`
process.stderr.write(
` ${bold('Current:')} ${color(displayName, 'success')} ${dim(`(${currentModel})`)}\n`
);
console.error(` ${bold('Config:')} ${dim(settingsPath)}`);
process.stderr.write(` ${bold('Config:')} ${dim(settingsPath)}\n`);
} else {
console.error(` ${bold('Current:')} ${dim('(using defaults)')}`);
console.error(` ${bold('Default:')} ${catalog.defaultModel}`);
process.stderr.write(` ${bold('Current:')} ${dim('(using defaults)')}\n`);
process.stderr.write(` ${bold('Default:')} ${catalog.defaultModel}\n`);
}
console.error('');
console.error(bold('Available models:'));
console.error(dim(' [Pro]/[Ultra] = Requires a paid provider plan'));
console.error(dim(' [DEPRECATED] = Not recommended for use'));
console.error('');
process.stderr.write('\n');
process.stderr.write(String(bold('Available models:')) + '\n');
process.stderr.write(String(dim(' [Pro]/[Ultra] = Requires a paid provider plan')) + '\n');
process.stderr.write(String(dim(' [DEPRECATED] = Not recommended for use')) + '\n');
process.stderr.write('\n');
catalog.models.forEach((m) => {
const isCurrent = m.id === normalizedCurrentModel;
console.error(formatModelDetailed(m, isCurrent));
process.stderr.write(String(formatModelDetailed(m, isCurrent)) + '\n');
});
console.error('');
console.error(dim(`Run "ccs ${provider} --config" to change`));
console.error('');
process.stderr.write('\n');
process.stderr.write(String(dim(`Run "ccs ${provider} --config" to change`)) + '\n');
process.stderr.write('\n');
}
@@ -167,7 +167,7 @@ describe('parseExecutorFlags', () => {
beforeEach(() => {
originalExitCode = process.exitCode as number | undefined;
process.exitCode = 0;
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
errorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
exitSpy = jest
.spyOn(process, 'exit')
.mockImplementation((() => undefined as never) as typeof process.exit);
@@ -290,7 +290,7 @@ describe('validateFlagCombinations', () => {
beforeEach(() => {
originalExitCode = process.exitCode as number | undefined;
process.exitCode = 0;
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
errorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
});
afterEach(() => {
@@ -57,7 +57,7 @@ describe('warnBrokenModels', () => {
let errorSpy: ReturnType<typeof jest.spyOn>;
beforeEach(() => {
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
errorSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
mockGetCurrentModel.mockReset();
mockIsModelBroken.mockReturnValue(false);
mockGetModelIssueUrl.mockReturnValue(undefined);
+37 -33
View File
@@ -207,9 +207,9 @@ export function parseExecutorFlags(
const forceHeadless = args.includes('--headless');
if (pasteCallback && portForward) {
console.error(fail('Cannot use --paste-callback with --port-forward'));
console.error(' --paste-callback: Manually paste OAuth redirect URL');
console.error(' --port-forward: Use SSH port forwarding for callback');
process.stderr.write(String(fail('Cannot use --paste-callback with --port-forward')) + '\n');
process.stderr.write(' --paste-callback: Manually paste OAuth redirect URL\n');
process.stderr.write(' --port-forward: Use SSH port forwarding for callback\n');
process.exit(1);
}
@@ -247,8 +247,8 @@ export function parseExecutorFlags(
if (kiroMethodValue.present) {
const rawMethod = kiroMethodValue.value;
if (kiroMethodValue.missingValue || !rawMethod) {
console.error(fail('--kiro-auth-method requires a value'));
console.error(' Supported values: aws, aws-authcode, google, github, idc');
process.stderr.write(String(fail('--kiro-auth-method requires a value')) + '\n');
process.stderr.write(' Supported values: aws, aws-authcode, google, github, idc\n');
process.exitCode = 1;
// Caller must check parseFailed and bail — matching original return behavior
return buildPartialFlags({
@@ -280,8 +280,8 @@ export function parseExecutorFlags(
}
const normalized = rawMethod.trim().toLowerCase();
if (!isKiroAuthMethod(normalized)) {
console.error(fail(`Invalid --kiro-auth-method value: ${rawMethod}`));
console.error(' Supported values: aws, aws-authcode, google, github, idc');
process.stderr.write(String(fail(`Invalid --kiro-auth-method value: ${rawMethod}`)) + '\n');
process.stderr.write(' Supported values: aws, aws-authcode, google, github, idc\n');
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
@@ -318,7 +318,7 @@ export function parseExecutorFlags(
if (kiroIDCStartUrlValue.present && kiroIDCStartUrlValue.value) {
kiroIDCStartUrl = kiroIDCStartUrlValue.value;
} 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;
return buildPartialFlags({
forceAuth,
@@ -353,7 +353,7 @@ export function parseExecutorFlags(
if (kiroIDCRegionValue.present && kiroIDCRegionValue.value) {
kiroIDCRegion = kiroIDCRegionValue.value;
} 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;
return buildPartialFlags({
forceAuth,
@@ -388,8 +388,8 @@ export function parseExecutorFlags(
if (kiroIDCFlowValue.present) {
const rawFlow = kiroIDCFlowValue.value;
if (kiroIDCFlowValue.missingValue || !rawFlow) {
console.error(fail('--kiro-idc-flow requires a value'));
console.error(' Supported values: authcode, device');
process.stderr.write(String(fail('--kiro-idc-flow requires a value')) + '\n');
process.stderr.write(' Supported values: authcode, device\n');
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
@@ -420,8 +420,8 @@ export function parseExecutorFlags(
}
const normalized = rawFlow.trim().toLowerCase();
if (!isKiroIDCFlow(normalized)) {
console.error(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`));
console.error(' Supported values: authcode, device');
process.stderr.write(String(fail(`Invalid --kiro-idc-flow value: ${rawFlow}`)) + '\n');
process.stderr.write(' Supported values: authcode, device\n');
process.exitCode = 1;
return buildPartialFlags({
forceAuth,
@@ -458,7 +458,7 @@ export function parseExecutorFlags(
if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) {
gitlabBaseUrl = gitlabBaseUrlValue.value.trim();
} 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;
return buildPartialFlags({
forceAuth,
@@ -492,14 +492,14 @@ export function parseExecutorFlags(
const thinkingParse = parseThinkingOverride(args);
if (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') {
console.error(' Codex examples: --effort xhigh, --effort high, --effort medium');
console.error(' Alias: --thinking xhigh (same behavior)');
process.stderr.write(' Codex examples: --effort xhigh, --effort high, --effort medium\n');
process.stderr.write(' Alias: --thinking xhigh (same behavior)\n');
} else {
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
console.error(' Levels: minimal, low, medium, high, xhigh, max, auto');
process.stderr.write(' Examples: --thinking low, --thinking 8192, --thinking off\n');
process.stderr.write(' Levels: minimal, low, medium, high, xhigh, max, auto\n');
}
process.exit(1);
@@ -511,7 +511,7 @@ export function parseExecutorFlags(
const hasNo1mFlag = args.includes('--no-1m') || args.some((arg) => arg.startsWith('--no-1m='));
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);
} else if (has1mFlag) {
extendedContextOverride = true;
@@ -584,7 +584,7 @@ export function validateFlagCombinations(
} = parsed;
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;
return false;
}
@@ -594,19 +594,21 @@ export function validateFlagCombinations(
provider !== 'kiro' &&
!compositeProviders.includes('kiro')
) {
console.error(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro'
)
process.stderr.write(
String(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow are only valid for ccs kiro'
)
) + '\n'
);
process.exitCode = 1;
return false;
}
if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) {
console.error(fail('Kiro IDC login requires --kiro-idc-start-url'));
console.error(
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start'
process.stderr.write(String(fail('Kiro IDC login requires --kiro-idc-start-url')) + '\n');
process.stderr.write(
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start\n'
);
process.exitCode = 1;
return false;
@@ -617,10 +619,12 @@ export function validateFlagCombinations(
kiroAuthMethod !== 'idc' &&
(kiroIDCStartUrl || kiroIDCRegion || kiroIDCFlow)
) {
console.error(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc'
)
process.stderr.write(
String(
fail(
'--kiro-idc-start-url, --kiro-idc-region, and --kiro-idc-flow require --kiro-auth-method idc'
)
) + '\n'
);
process.exitCode = 1;
return false;
@@ -628,7 +632,7 @@ export function validateFlagCombinations(
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
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;
return false;
}
+24 -17
View File
@@ -95,18 +95,18 @@ export async function handleImport(context: AuthCoordinationContext): Promise<bo
if (!forceImport) return false;
if (provider !== 'kiro') {
console.error(fail('--import is only available for Kiro'));
console.error(` Run "ccs ${provider} --auth" to authenticate`);
process.stderr.write(String(fail('--import is only available for Kiro')) + '\n');
process.stderr.write(` Run "ccs ${provider} --auth" to authenticate` + '\n');
process.exit(1);
}
if (forceAuth) {
console.error(fail('Cannot use --import with --auth'));
console.error(' --import: Import existing token from Kiro IDE');
console.error(' --auth: Trigger new OAuth flow in browser');
process.stderr.write(String(fail('Cannot use --import with --auth')) + '\n');
process.stderr.write(' --import: Import existing token from Kiro IDE' + '\n');
process.stderr.write(' --auth: Trigger new OAuth flow in browser' + '\n');
process.exit(1);
}
if (forceLogout) {
console.error(fail('Cannot use --import with --logout'));
process.stderr.write(String(fail('Cannot use --import with --logout')) + '\n');
process.exit(1);
}
@@ -121,8 +121,8 @@ export async function handleImport(context: AuthCoordinationContext): Promise<bo
...(setNickname ? { nickname: setNickname } : {}),
});
if (!authSuccess) {
console.error(fail('Failed to import Kiro token from IDE'));
console.error(' Make sure you are logged into Kiro IDE first');
process.stderr.write(String(fail('Failed to import Kiro token from IDE')) + '\n');
process.stderr.write(' Make sure you are logged into Kiro IDE first' + '\n');
process.exit(1);
}
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]}.`
);
}
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 };
}
@@ -189,10 +192,12 @@ export async function runAntigravityGate(
acceptedByFlag: acceptAgyRisk,
});
if (!acknowledged) {
console.error(
fail(
`Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
)
process.stderr.write(
String(
fail(
`Antigravity session blocked. Re-run after completing confirmation or pass ${ANTIGRAVITY_ACCEPT_RISK_FLAGS[0]}.`
)
) + '\n'
);
process.exit(1);
}
@@ -262,9 +267,9 @@ export async function ensureProviderAuthentication(
}
if (failures.length > 0) {
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) {
console.error(info(`Succeeded: ${succeeded.join(', ')}`));
process.stderr.write(String(info(`Succeeded: ${succeeded.join(', ')}`)) + '\n');
}
process.exit(1);
}
@@ -279,9 +284,11 @@ export async function ensureProviderAuthentication(
}
}
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) {
console.error(` - ${p} (run "ccs ${p} --auth")`);
process.stderr.write(` - ${p} (run "ccs ${p} --auth")` + '\n');
}
process.exit(1);
}
+22 -17
View File
@@ -13,6 +13,9 @@
import { ChildProcess } from 'child_process';
import * as fs from 'fs';
import { fail, info, warn } from '../../utils/ui';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:executor');
import {
generateConfig,
getProviderConfig,
@@ -107,14 +110,14 @@ export async function execClaudeWithCLIProxy(
// Validate Claude CLI exists before proceeding
if (!fs.existsSync(claudeCli)) {
console.error(fail(`Claude CLI not found at: ${claudeCli}`));
console.error(' Run "ccs doctor --fix" to reinstall or check your PATH');
process.stderr.write(`${fail(`Claude CLI not found at: ${claudeCli}`)}\n`);
process.stderr.write(' Run "ccs doctor --fix" to reinstall or check your PATH\n');
process.exit(1);
}
const log = (msg: string) => {
if (verbose) {
console.error(`[cliproxy] ${msg}`);
logger.info('verbose', msg);
}
};
@@ -203,16 +206,16 @@ export async function execClaudeWithCLIProxy(
const thinkingCfg = getThinkingConfig();
if (thinkingParse.duplicateDisplays.length > 0) {
console.warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${thinkingParse.sourceDisplay}`
process.stderr.write(
`[!] Multiple reasoning flags detected. Using first occurrence: ${thinkingParse.sourceDisplay}\n`
);
}
if (thinkingParse.sourceFlag === '--effort' && provider !== 'codex') {
console.warn(
warn(
process.stderr.write(
`${warn(
'`--effort` is primarily for codex. Continuing as alias of `--thinking` for compatibility.'
)
)}\n`
);
}
@@ -227,7 +230,9 @@ export async function execClaudeWithCLIProxy(
console.log(
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);
} else {
// Run the one-time stale-pin migration on the pre-existing settings file
@@ -369,7 +374,7 @@ export async function execClaudeWithCLIProxy(
);
} catch (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}`);
}
} else if (useRemoteProxy && proxyConfig.protocol === 'https' && provider === 'codex') {
@@ -526,16 +531,16 @@ export async function execClaudeWithCLIProxy(
const webSearchEnv = getWebSearchHookEnv();
if (process.env.CCS_DEBUG) {
console.error(
`[cliproxy-browser-debug] keys=${Object.keys(env)
logger.info('browser-env-keys', 'CCS_BROWSER_* keys in environment', {
keys: Object.keys(env)
.filter((key) => key.startsWith('CCS_BROWSER_'))
.sort()
.join(',')} ws=${env.CCS_BROWSER_DEVTOOLS_WS_URL || ''}`
);
.sort(),
ws: env.CCS_BROWSER_DEVTOOLS_WS_URL || '',
});
}
logEnvironment(env, webSearchEnv, verbose);
if (imageAnalysisWarning) {
console.error(info(imageAnalysisWarning));
process.stderr.write(`${info(imageAnalysisWarning)}\n`);
}
// 11b. Print thinking status feedback (TTY only, non-piped sessions)
@@ -547,7 +552,7 @@ export async function execClaudeWithCLIProxy(
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
+19 -16
View File
@@ -14,6 +14,9 @@ import { fail } from '../../utils/ui';
import { getCliproxyWritablePath } from '../config/config-generator';
import { getPortCheckCommand, getCatCommand } from '../../utils/platform-commands';
import { CLIProxyBackend } from '../types';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:executor:lifecycle-manager');
/**
* 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 {
const log = (msg: string) => {
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.on('error', (error) => {
console.error(fail(`CLIProxy spawn error: ${error.message}`));
process.stderr.write(String(fail(`CLIProxy spawn error: ${error.message}`)) + '\n');
});
return proxy;
@@ -108,20 +111,20 @@ export async function waitForProxyReadyWithSpinner(
readySpinner.fail(`${backendLabel} startup failed`);
const err = error as Error;
console.error('');
console.error(fail(`${backendLabel} failed to start`));
console.error('');
console.error('Possible causes:');
console.error(` 1. Port ${port} already in use`);
console.error(' 2. Binary crashed on startup');
console.error(' 3. Invalid configuration');
console.error('');
console.error('Troubleshooting:');
console.error(` - Check port: ${getPortCheckCommand(port)}`);
console.error(' - Run with --verbose for detailed logs');
console.error(` - View config: ${getCatCommand(configPath)}`);
console.error(' - Try: ccs doctor --fix');
console.error('');
process.stderr.write('' + '\n');
process.stderr.write(String(fail(`${backendLabel} failed to start`)) + '\n');
process.stderr.write('' + '\n');
process.stderr.write('Possible causes:' + '\n');
process.stderr.write(` 1. Port ${port} already in use` + '\n');
process.stderr.write(' 2. Binary crashed on startup' + '\n');
process.stderr.write(' 3. Invalid configuration' + '\n');
process.stderr.write('' + '\n');
process.stderr.write('Troubleshooting:' + '\n');
process.stderr.write(` - Check port: ${getPortCheckCommand(port)}` + '\n');
process.stderr.write(' - Run with --verbose for detailed logs' + '\n');
process.stderr.write(` - View config: ${getCatCommand(configPath)}` + '\n');
process.stderr.write(' - Try: ccs doctor --fix' + '\n');
process.stderr.write('' + '\n');
throw new Error(`CLIProxy startup failed: ${err.message}`);
}
+25 -13
View File
@@ -23,6 +23,18 @@ export interface ModelWarningsContext {
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.
*
@@ -40,17 +52,17 @@ export function warnBrokenModels(context: ModelWarningsContext): void {
if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) {
const modelEntry = findModel(tierConfig.provider, tierConfig.model);
const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model);
console.error('');
console.error(
stderr('');
stderr(
warn(
`${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) {
console.error(` Tracking: ${issueUrl}`);
stderr(` Tracking: ${issueUrl}`);
}
console.error('');
stderr('');
}
}
} else {
@@ -59,22 +71,22 @@ export function warnBrokenModels(context: ModelWarningsContext): void {
const modelEntry = findModel(provider, currentModel);
const issueUrl = getModelIssueUrl(provider, currentModel);
const replacementModel = getSuggestedReplacementModel(provider, currentModel);
console.error('');
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
stderr('');
stderr(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
if (replacementModel) {
console.error(` Tool calls will fail. Use "${replacementModel}" instead.`);
stderr(` Tool calls will fail. Use "${replacementModel}" instead.`);
} 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) {
console.error(` Tracking: ${issueUrl}`);
stderr(` Tracking: ${issueUrl}`);
}
if (skipLocalAuth) {
console.error(' Note: Model may be overridden by remote proxy configuration.');
stderr(' Note: Model may be overridden by remote proxy configuration.');
} 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 { handleBanDetection, warnPossible403Ban } from '../accounts/account-safety';
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
@@ -32,12 +35,12 @@ export function isNetworkError(error: Error): boolean {
* Handle network error with user-friendly message
*/
export function handleNetworkError(_error: Error): never {
console.error('');
console.error(fail('No network connection detected'));
console.error('');
console.error('CLIProxy binary download requires internet access.');
console.error('Please check your network connection and try again.');
console.error('');
process.stderr.write(String('') + '\n');
process.stderr.write(String(fail('No network connection detected')) + '\n');
process.stderr.write(String('') + '\n');
process.stderr.write(String('CLIProxy binary download requires internet access.') + '\n');
process.stderr.write(String('Please check your network connection and try again.') + '\n');
process.stderr.write(String('') + '\n');
process.exit(1);
}
@@ -63,16 +66,16 @@ export async function handleTokenExpiration(
}
// 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) {
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);
}
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);
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);
}
+26 -15
View File
@@ -26,6 +26,9 @@ import {
import { withStartupLock } from '../services/startup-lock';
import { killProcessOnPort } from '../../utils/platform-commands';
import { stopQuotaMonitor } from '../quota/quota-manager';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:executor:session-bridge');
export interface ProxySessionResult {
sessionId?: string;
@@ -43,7 +46,7 @@ export async function checkOrJoinProxy(
): Promise<ProxySessionResult> {
const log = (msg: string) => {
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
const { getPortCheckCommand } = await import('../../utils/platform-commands');
console.error('');
console.error(
warn(
`Port ${port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})`
)
process.stderr.write('\n');
process.stderr.write(
String(
warn(
`Port ${port} is blocked by ${proxyStatus.blocker.processName} (PID ${proxyStatus.blocker.pid})`
)
) + '\n'
);
console.error('');
console.error('To fix this, close the blocking application or run:');
console.error(` ${getPortCheckCommand(port)}`);
console.error('');
process.stderr.write('\n');
process.stderr.write('To fix this, close the blocking application or run:\n');
process.stderr.write(` ${getPortCheckCommand(port)}\n`);
process.stderr.write('\n');
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);
if (verbose) {
console.error(
`[cliproxy] Registered session ${sessionId} with new proxy (PID ${pid}, version ${installedVersion})`
);
logger.info('proxy.session.registered', 'Registered session with new proxy', {
sessionId,
port,
pid,
version: installedVersion,
backend,
});
}
return sessionId;
@@ -184,7 +193,7 @@ export function setupCleanupHandlers(
): void {
const log = (msg: string) => {
if (verbose) {
console.error(`[cliproxy] ${msg}`);
logger.info('proxy.cleanup.trace', msg);
}
};
@@ -228,7 +237,9 @@ export function setupCleanupHandlers(
});
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();
process.exit(1);
});
@@ -38,7 +38,7 @@ export async function uploadTokenToRemote(
if (!target.isRemote) {
if (verbose) {
console.error('[upload] Remote mode not enabled, skipping upload');
process.stderr.write('[upload] Remote mode not enabled, skipping upload\n');
}
return false;
}
@@ -48,7 +48,9 @@ export async function uploadTokenToRemote(
try {
tokenContent = fs.readFileSync(tokenFilePath, 'utf-8');
} 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;
}
@@ -56,7 +58,7 @@ export async function uploadTokenToRemote(
try {
JSON.parse(tokenContent);
} catch {
console.error(fail('Invalid token file: not valid JSON'));
process.stderr.write(String(fail('Invalid token file: not valid JSON')) + '\n');
return false;
}
@@ -67,7 +69,7 @@ export async function uploadTokenToRemote(
const authKey = target.managementKey ?? target.authToken;
if (verbose) {
console.error(`[upload] Uploading ${fileName} to ${target.host}`);
process.stderr.write(`[upload] Uploading ${fileName} to ${target.host}\n`);
}
const controller = new AbortController();
@@ -95,7 +97,7 @@ export async function uploadTokenToRemote(
if (!response.ok) {
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;
}
@@ -105,16 +107,18 @@ export async function uploadTokenToRemote(
console.log(ok(`Token uploaded to remote server: ${fileName}`));
return true;
} 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;
}
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
console.error(fail('Upload timed out'));
process.stderr.write(String(fail('Upload timed out')) + '\n');
} else {
console.error(fail(`Upload failed: ${(error as Error).message}`));
process.stderr.write(String(fail(`Upload failed: ${(error as Error).message}`)) + '\n');
}
return false;
}
@@ -132,14 +136,14 @@ export async function uploadAllTokensToRemote(tokenDir: string, verbose = false)
if (!target.isRemote) {
if (verbose) {
console.error('[upload] Remote mode not enabled, skipping upload');
process.stderr.write('[upload] Remote mode not enabled, skipping upload\n');
}
return 0;
}
if (!fs.existsSync(tokenDir)) {
if (verbose) {
console.error(`[upload] Token directory does not exist: ${tokenDir}`);
process.stderr.write(`[upload] Token directory does not exist: ${tokenDir}\n`);
}
return 0;
}
@@ -148,7 +152,7 @@ export async function uploadAllTokensToRemote(tokenDir: string, verbose = false)
if (files.length === 0) {
if (verbose) {
console.error('[upload] No token files found');
process.stderr.write('[upload] No token files found\n');
}
return 0;
}
+23 -10
View File
@@ -16,6 +16,9 @@
import * as http from 'http';
import * as https from 'https';
import type { Socket } from 'net';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:https-tunnel-proxy');
export interface HttpsTunnelConfig {
/** 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) {
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'));
return;
}
this.log(
this.trace(
`Started on port ${this.port}, tunneling to https://${this.config.remoteHost}:${this.config.remotePort}`
);
resolve(this.port);
@@ -132,7 +139,7 @@ export class HttpsTunnelProxy {
this.server = null;
this.port = null;
this.startingPromise = null;
this.log('Stopped');
this.trace('Stopped');
}
getPort(): number | null {
@@ -182,7 +189,7 @@ export class HttpsTunnelProxy {
const method = req.method || 'GET';
const requestPath = req.url || '/';
this.log(
this.trace(
`${method} ${requestPath} → https://${this.config.remoteHost}:${this.config.remotePort}${requestPath}`
);
@@ -190,7 +197,9 @@ export class HttpsTunnelProxy {
await this.forwardRequest(req, res, requestPath);
} catch (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) {
res.writeHead(502, { 'Content-Type': 'application/json' });
}
@@ -231,26 +240,30 @@ export class HttpsTunnelProxy {
upstreamReq.on('timeout', () => {
const timeoutError = new Error('Upstream request timeout');
this.log(`Timeout: ${timeoutError.message}`);
logger.warn('tunnel.upstream_timeout', timeoutError.message);
upstreamReq.destroy();
reject(timeoutError);
});
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);
});
// Handle client disconnect (premature close)
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();
reject(err);
});
originalReq.on('close', () => {
if (!originalReq.complete) {
this.log('Client disconnected prematurely');
logger.warn('tunnel.client_premature_close', 'Client disconnected prematurely');
upstreamReq.destroy();
}
});
+80 -99
View File
@@ -12,9 +12,6 @@
import * as http from 'http';
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 { ToolNameMapper, type Tool, type ContentBlock } from '../ai-providers/tool-name-mapper';
import { sanitizeToolSchemas } from '../ai-providers/schema-sanitizer';
@@ -28,7 +25,6 @@ import {
import { getModelMaxLevel } from '../model-catalog';
import { createLogger } from '../../services/logging';
import { getCcsDir } from '../../config/config-loader-facade';
import {
attachUpstreamResponseTimeout,
writeForwardResponseHead,
@@ -273,8 +269,6 @@ export class ToolSanitizationProxy {
private server: http.Server | null = null;
private port: number | null = null;
private readonly config: Required<ToolSanitizationProxyConfig>;
private readonly logFilePath: string;
private readonly debugMode: boolean;
private readonly logger = createLogger('cliproxy:tool-sanitization-proxy');
constructor(config: ToolSanitizationProxyConfig) {
@@ -285,69 +279,6 @@ export class ToolSanitizationProxy {
timeoutMs: config.timeoutMs ?? 120000,
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', () => {
const address = this.server?.address();
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);
});
@@ -418,7 +352,12 @@ export class ToolSanitizationProxy {
const fullUpstreamUrl = new URL(requestPath, upstreamBase);
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
const contentType = String(req.headers['content-type'] || '');
@@ -458,9 +397,14 @@ export class ToolSanitizationProxy {
}
const normalizedModel = normalizeModelIdForRouting(modifiedBody.model, providerFromPath);
if (normalizedModel !== modifiedBody.model) {
this.writeLog(
'warn',
`[tool-sanitization-proxy] Model normalized for provider routing (${providerFromPath ?? 'root'}): "${modifiedBody.model}" → "${normalizedModel}"`
this.logger.warn(
'tool-sanitization.proxy.model-normalized',
`Model normalized for provider routing (${providerFromPath ?? 'root'}): "${modifiedBody.model}" → "${normalizedModel}"`,
{
provider: providerFromPath ?? 'root',
from: modifiedBody.model,
to: normalizedModel,
}
);
modifiedBody = { ...modifiedBody, model: normalizedModel };
}
@@ -480,14 +424,22 @@ export class ToolSanitizationProxy {
if (schemaResult.totalRemoved > 0) {
for (const entry of schemaResult.removedByTool) {
this.writeLog(
'warn',
`[tool-sanitization-proxy] Schema sanitized for "${entry.name}": removed ${entry.removed.length} Gemini-unsupported properties`
this.logger.warn(
'tool-sanitization.proxy.schema-sanitized',
`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[];
@@ -501,14 +453,26 @@ export class ToolSanitizationProxy {
if (fieldResult.totalRemoved > 0) {
for (const entry of fieldResult.removedByTool) {
this.writeLog(
'warn',
`[tool-sanitization-proxy] Tool fields stripped for "${entry.name}" (${providerFromPath ?? 'model-routed'}): ${entry.removed.join(', ')}`
this.logger.warn(
'tool-sanitization.proxy.fields-stripped',
`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;
@@ -521,19 +485,32 @@ export class ToolSanitizationProxy {
// Log sanitization warnings
if (mapper.hasChanges()) {
const changes = mapper.getChanges();
for (const change of changes) {
this.warn(`"${change.original}" → "${change.sanitized}"`);
if (this.config.warnOnSanitize) {
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)
if (mapper.hasCollisions()) {
const collisions = mapper.getCollisions();
for (const collision of collisions) {
this.writeLog(
'warn',
`[tool-sanitization-proxy] Hash collision detected: ${collision.originals.join(', ')} → "${collision.sanitized}"`
this.logger.warn(
'tool-sanitization.proxy.hash-collision',
`Hash collision detected: ${collision.originals.join(', ')} → "${collision.sanitized}"`,
{ originals: collision.originals, sanitized: collision.sanitized }
);
}
}
@@ -549,7 +526,11 @@ export class ToolSanitizationProxy {
}
} catch (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) {
res.writeHead(502, { 'Content-Type': 'application/json' });
}
@@ -845,9 +826,9 @@ export class ToolSanitizationProxy {
clearUpstreamResponseTimeout();
try {
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
this.writeLog(
'warn',
'[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
this.logger.warn(
'tool-sanitization.proxy.empty-response',
'Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
);
clientRes.write(
this.buildSyntheticErrorResponse(
@@ -903,9 +884,9 @@ export class ToolSanitizationProxy {
// Safety net: if upstream sent data but no content blocks, inject synthetic response
if (!lifecycle.hasContent && isSuccessResponse && lifecycle.hasData) {
this.writeLog(
'warn',
'[tool-sanitization-proxy] Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
this.logger.warn(
'tool-sanitization.proxy.empty-response',
'Empty response detected from upstream (no content blocks). Injecting synthetic response to prevent client crash.'
);
clientRes.write(
this.buildSyntheticErrorResponse(
+19 -5
View File
@@ -14,6 +14,9 @@ import {
buildClaudeQuotaWindows,
buildClaudeCoreUsageSummary,
} from './quota-fetcher-claude-normalizer';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:quota:claude');
export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary };
@@ -250,7 +253,11 @@ async function runClaudeUsageFetch(
});
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) {
@@ -331,10 +338,17 @@ async function runClaudeUsageFetch(
: 'Unknown error';
if (verbose) {
const errorDetails =
error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error);
console.error(
`[!] Claude OAuth usage failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}`
logger.warn(
'quota.fetch.failed',
`Claude OAuth usage failed (attempt ${attempt}): ${lastError}`,
{
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 { sanitizeCodexFeatureLabel } from './quota-label-sanitizer';
import { extractCanonicalEmailFromAccountId } from '../accounts/email-account-identity';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:quota:codex');
/** ChatGPT backend API base URL */
const CODEX_API_BASE = 'https://chatgpt.com/backend-api';
@@ -628,12 +631,17 @@ export async function fetchCodexQuota(
accountId: string,
verbose = false
): 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);
if (!authData) {
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, {
error,
errorCode: 'auth_file_missing',
@@ -644,7 +652,11 @@ export async function fetchCodexQuota(
if (authData.isExpired) {
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, {
error,
errorCode: 'token_expired',
@@ -656,7 +668,11 @@ export async function fetchCodexQuota(
if (!authData.accountId) {
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, {
error,
errorCode: 'missing_account_id',
@@ -685,7 +701,12 @@ export async function fetchCodexQuota(
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) {
const bodyText = await response.text();
@@ -696,10 +717,14 @@ export async function fetchCodexQuota(
const windows = buildCodexQuotaWindows(data);
const unknownWindowLabels = getUnknownCodexWindowLabels(windows);
if (unknownWindowLabels.length > 0 && shouldLogCodexWindowWarnings(verbose)) {
console.error(
`[!] Codex quota detected unknown window labels: ${unknownWindowLabels.join(', ')}`
logger.warn(
'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);
@@ -714,7 +739,11 @@ export async function fetchCodexQuota(
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 {
success: true,
@@ -734,7 +763,19 @@ export async function fetchCodexQuota(
: 'Unknown error';
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.
+60 -11
View File
@@ -28,6 +28,12 @@ import {
normalizeProviderTierId,
} from '../auth/provider-entitlement-evidence';
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 */
const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com';
@@ -620,8 +626,10 @@ async function fetchGeminiCliSupplementary(
if (response.status !== 200) {
if (verbose) {
const source = response.viaManagement ? 'managed' : 'direct';
console.error(
`[i] Gemini CLI supplementary metadata unavailable via ${source}: HTTP ${response.status}`
logger.info(
'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' };
@@ -637,7 +645,15 @@ async function fetchGeminiCliSupplementary(
} catch (error) {
if (verbose) {
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' };
}
@@ -954,7 +970,12 @@ async function fetchWithAuthData(
): Promise<GeminiCliQuotaResult> {
if (!authData.projectId) {
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, {
error,
errorCode: 'missing_project_id',
@@ -985,7 +1006,11 @@ async function fetchWithAuthData(
if (verbose) {
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) {
@@ -1002,7 +1027,13 @@ async function fetchWithAuthData(
const buckets = buildGeminiCliBuckets(rawBuckets);
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') {
setAccountTier('gemini', accountId, supplementary.normalizedTier);
@@ -1045,7 +1076,13 @@ async function fetchWithAuthData(
? err.message
: '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, {
error: errorMsg,
@@ -1069,12 +1106,22 @@ export async function fetchGeminiCliQuota(
accountId: string,
verbose = false
): 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);
if (!authData) {
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, {
error,
errorCode: 'auth_file_missing',
@@ -1086,8 +1133,10 @@ export async function fetchGeminiCliQuota(
if (authData.isExpired && verbose) {
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
const expiryLabel = expiresAt ? new Date(expiresAt).toISOString() : 'unknown';
console.error(
`[i] Gemini access token is expired (${expiryLabel}); quota requests will defer to managed auth when available.`
logger.info(
'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 type { GhcpQuotaResult, GhcpQuotaSnapshot } from './quota-types';
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_TIMEOUT_MS = 10000;
@@ -174,11 +181,22 @@ export async function fetchGhcpQuota(accountId: string, verbose = false): Promis
const { accessToken, error } = readGhcpAccessToken(accountId);
if (!accessToken) {
// 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);
}
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 timeoutId = setTimeout(() => controller.abort(), GHCP_USAGE_TIMEOUT_MS);
+26 -7
View File
@@ -28,6 +28,9 @@ import {
buildProxyUrl,
getProxyTarget,
} from '../proxy/proxy-target-resolver';
import { createLogger } from '../../services/logging';
const logger = createLogger('cliproxy:quota:fetcher');
/** Individual model quota info */
export interface ModelQuota {
@@ -829,12 +832,13 @@ export async function fetchAccountQuota(
accountId: string,
verbose = false
): 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
if (provider !== 'agy') {
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.
// This is "no quota API for this provider", which is healthy — distinct
// from a transient fetch failure or an expired token.
@@ -851,7 +855,7 @@ export async function fetchAccountQuota(
const authData = readAuthData(provider, accountId);
if (!authData) {
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 {
success: false,
models: [],
@@ -869,7 +873,10 @@ export async function fetchAccountQuota(
: authData.expiresAt
? `expires ${authData.expiresAt}`
: '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
@@ -884,7 +891,12 @@ export async function fetchAccountQuota(
if (!lastProjectResult.projectId && !projectId) {
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 {
success: false,
models: [],
@@ -909,12 +921,19 @@ export async function fetchAccountQuota(
rawTierId = lastProjectResult.rawTierId || 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
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.projectId = projectId || undefined;
+15 -4
View File
@@ -12,6 +12,12 @@ import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loa
import { getInstalledCliproxyVersion } from '../binary-manager';
import { compareVersions } from '../../utils/update-checker';
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_SESSION_AFFINITY_ENABLED = false;
@@ -205,10 +211,15 @@ export function enablePoolRouting(
try {
const installedVersion = getInstalledCliproxyVersion();
if (compareVersions(installedVersion, POOL_ROUTING_MIN_VERSION) < 0) {
console.warn(
`[!] CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}).\n` +
` The max-retry-credentials and cooling keys may be silently ignored by the running binary.\n` +
` Run 'ccs cliproxy --latest' to update CLIProxy, then restart with 'ccs cliproxy restart'.`
logger.warn(
'pool_routing.binary_below_minimum',
`CLIProxy v${installedVersion} is older than the pool routing minimum (v${POOL_ROUTING_MIN_VERSION}). ` +
`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 {
+24 -24
View File
@@ -207,20 +207,20 @@ export async function executeCopilotProfile(
try {
await ensureCopilotApi();
} catch (error) {
console.error(fail('Failed to install copilot-api.'));
console.error('');
console.error(`Error: ${(error as Error).message}`);
console.error('');
console.error('Try installing manually:');
console.error(' npm install -g copilot-api');
process.stderr.write(String(fail('Failed to install copilot-api.')) + '\n');
process.stderr.write('\n');
process.stderr.write(String(`Error: ${(error as Error).message}`) + '\n');
process.stderr.write('\n');
process.stderr.write('Try installing manually:\n');
process.stderr.write(' npm install -g copilot-api\n');
return 1;
}
// Check if copilot-api is installed (should be after ensureCopilotApi)
if (!isCopilotApiInstalled()) {
console.error(fail('copilot-api is not installed.'));
console.error('');
console.error('Install/repair by running: ccs copilot start');
process.stderr.write(String(fail('copilot-api is not installed.')) + '\n');
process.stderr.write('\n');
process.stderr.write('Install/repair by running: ccs copilot start\n');
return 1;
}
@@ -231,10 +231,10 @@ export async function executeCopilotProfile(
authenticated: authStatus.authenticated,
});
if (!authStatus.authenticated) {
console.error(fail('Not authenticated with GitHub.'));
console.error('');
console.error('Run: npx copilot-api auth');
console.error('Or: ccs copilot auth');
process.stderr.write(String(fail('Not authenticated with GitHub.')) + '\n');
process.stderr.write('\n');
process.stderr.write('Run: npx copilot-api auth\n');
process.stderr.write('Or: ccs copilot auth\n');
return 1;
}
@@ -246,21 +246,21 @@ export async function executeCopilotProfile(
console.log(info('Starting copilot-api daemon...'));
const result = await startDaemon(normalizedConfig);
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;
}
console.log(ok(`Daemon started on port ${normalizedConfig.port}`));
daemonRunning = true;
} else {
console.error(fail('copilot-api daemon is not running.'));
console.error('');
console.error('Start the daemon:');
console.error(' ccs copilot start');
console.error('Fallback manual command:');
console.error(` npx copilot-api start --port ${normalizedConfig.port}`);
console.error('');
console.error('Or enable auto_start in config:');
console.error(' ccs config (then enable auto_start in Copilot section)');
process.stderr.write(String(fail('copilot-api daemon is not running.')) + '\n');
process.stderr.write('\n');
process.stderr.write('Start the daemon:\n');
process.stderr.write(' ccs copilot start\n');
process.stderr.write('Fallback manual command:\n');
process.stderr.write(` npx copilot-api start --port ${normalizedConfig.port}\n`);
process.stderr.write('\n');
process.stderr.write('Or enable auto_start in config:\n');
process.stderr.write(' ccs config (then enable auto_start in Copilot section)\n');
return 1;
}
}
@@ -351,7 +351,7 @@ export async function executeCopilotProfile(
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);
});
});
+16 -16
View File
@@ -113,23 +113,23 @@ export async function executeCursorProfile(
claudeCliPath = 'claude'
): Promise<number> {
if (!config.enabled) {
console.error(fail('Cursor integration is not enabled.'));
console.error('');
console.error('Enable it first: ccs legacy cursor enable');
process.stderr.write(fail('Cursor integration is not enabled.') + '\n');
process.stderr.write('\n');
process.stderr.write('Enable it first: ccs legacy cursor enable\n');
return 1;
}
const authStatus = checkAuthStatus();
if (!authStatus.authenticated) {
console.error(fail('Cursor credentials not found.'));
console.error('');
console.error('Authenticate first: ccs legacy cursor auth');
process.stderr.write(fail('Cursor credentials not found.') + '\n');
process.stderr.write('\n');
process.stderr.write('Authenticate first: ccs legacy cursor auth\n');
return 1;
}
if (authStatus.expired) {
console.error(fail('Cursor credentials have expired.'));
console.error('');
console.error('Refresh them with: ccs legacy cursor auth');
process.stderr.write(fail('Cursor credentials have expired.') + '\n');
process.stderr.write('\n');
process.stderr.write('Refresh them with: ccs legacy cursor auth\n');
return 1;
}
@@ -145,17 +145,17 @@ export async function executeCursorProfile(
daemon_token: daemonToken,
});
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;
}
console.log(ok(`Daemon started on port ${config.port}`));
daemonRunning = true;
} else {
console.error(fail('Cursor daemon is not running.'));
console.error('');
console.error('Start the daemon:');
console.error(' ccs legacy cursor start');
console.error('Or enable auto_start in the Cursor config section.');
process.stderr.write(fail('Cursor daemon is not running.') + '\n');
process.stderr.write('\n');
process.stderr.write('Start the daemon:\n');
process.stderr.write(' ccs legacy cursor start\n');
process.stderr.write('Or enable auto_start in the Cursor config section.\n');
return 1;
}
}
@@ -204,7 +204,7 @@ export async function executeCursorProfile(
});
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);
});
});
+35 -23
View File
@@ -7,6 +7,9 @@ import { DelegationValidator } from '../utils/delegation-validator';
import { SettingsParser } from './settings-parser';
import { fail, warn } from '../utils/ui';
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 PROMPT_FLAGS_WITH_VALUE = new Set(['-p', '--prompt']);
@@ -85,13 +88,13 @@ function parseStringFlag(
// Reject dash-prefixed values (likely another flag)
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;
}
// Reject empty/whitespace-only
if (!value.trim()) {
console.error(warn(`${flagName} value is empty. Ignoring.`));
process.stderr.write(warn(`${flagName} value is empty. Ignoring.`) + '\n');
return undefined;
}
@@ -149,9 +152,14 @@ export class DelegationHandler {
// 6. Exit with proper code
process.exit(result.exitCode || 0);
} 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) {
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);
}
@@ -169,8 +177,10 @@ export class DelegationHandler {
const lastSession = sessionMgr.getLastSession(baseProfile);
if (!lastSession) {
console.error(fail(`No previous session found for ${baseProfile}`));
console.error(` Start a new session first with: ccs ${baseProfile} -p "task"`);
process.stderr.write(fail(`No previous session found for ${baseProfile}`) + '\n');
process.stderr.write(
` Start a new session first with: ccs ${baseProfile} -p "task"` + '\n'
);
process.exit(1);
}
@@ -256,8 +266,8 @@ export class DelegationHandler {
}
if (index === -1 || index === args.length - 1) {
console.error(fail('Missing prompt after -p flag'));
console.error(' Usage: ccs glm -p "task description"');
process.stderr.write(fail('Missing prompt after -p flag') + '\n');
process.stderr.write(' Usage: ccs glm -p "task description"' + '\n');
process.exit(1);
}
@@ -301,11 +311,13 @@ export class DelegationHandler {
if (!isNaN(val) && val > 0 && val <= 600000) {
options.timeout = 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) {
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) {
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) {
options.maxTurns = 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) {
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) {
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;
}
}
@@ -342,7 +354,7 @@ export class DelegationHandler {
JSON.parse(agentsValue);
options.agents = agentsValue;
} 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 {
if (!profile) {
console.error(fail('No profile specified'));
console.error(' Usage: ccs <profile> -p "task"');
console.error(' Examples: ccs glm -p "task", ccs km -p "task"');
process.stderr.write(fail('No profile specified') + '\n');
process.stderr.write(' Usage: ccs <profile> -p "task"' + '\n');
process.stderr.write(' Examples: ccs glm -p "task", ccs km -p "task"' + '\n');
process.exit(1);
}
// Use DelegationValidator to check profile
const validation = DelegationValidator.validate(profile);
if (!validation.valid) {
console.error(fail(`Profile '${profile}' is not configured for delegation`));
console.error(` ${validation.error}`);
console.error('');
console.error(' Run: ccs doctor');
console.error(` Or configure: ${getCcsDir()}/${profile}.settings.json`);
process.stderr.write(fail(`Profile '${profile}' is not configured for delegation`) + '\n');
process.stderr.write(` ${validation.error}` + '\n');
process.stderr.write('' + '\n');
process.stderr.write(' Run: ccs doctor' + '\n');
process.stderr.write(` Or configure: ${getCcsDir()}/${profile}.settings.json` + '\n');
process.exit(1);
}
}
+41 -25
View File
@@ -8,7 +8,7 @@
import { spawn } from 'child_process';
import * as path from 'path';
import { killWithEscalation } from '../utils/process-utils';
import { forwardRequestIdEnv } from '../services/logging';
import { createLogger, forwardRequestIdEnv } from '../services/logging';
import * as fs from 'fs';
import { SessionManager } from './session-manager';
import { SettingsParser } from './settings-parser';
@@ -64,6 +64,8 @@ import { getCcsDir, getGlobalEnvConfig, loadSettings } from '../config/config-lo
// Re-export types for consumers
export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types';
const logger = createLogger('delegation:headless-executor');
/**
* Headless executor for Claude CLI delegation
*/
@@ -129,10 +131,12 @@ export class HeadlessExecutor {
});
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
console.error(
info(
`Continuity inheritance active: profile "${profile}" -> account "${continuityInheritance.sourceAccount}"`
)
process.stderr.write(
String(
info(
`Continuity inheritance active: profile "${profile}" -> account "${continuityInheritance.sourceAccount}"`
)
) + '\n'
);
}
@@ -198,10 +202,12 @@ export class HeadlessExecutor {
imageAnalysisProvider &&
imageAnalysisStatus.effectiveRuntimeMode === 'native-read'
) {
console.error(
info(
`${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This delegation will use native Read.`
)
process.stderr.write(
String(
info(
`${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This delegation will use native Read.`
)
) + '\n'
);
imageAnalysisEnv = {
...imageAnalysisEnv,
@@ -215,10 +221,12 @@ export class HeadlessExecutor {
) {
const ensureServiceResult = await ensureCliproxyService(resolveLifecyclePort(), false);
if (!ensureServiceResult.started) {
console.error(
warn(
`Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This delegation will use native Read.`
)
process.stderr.write(
String(
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,
@@ -273,7 +281,9 @@ export class HeadlessExecutor {
if (permissionMode === 'bypassPermissions') {
args.push('--dangerously-skip-permissions');
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 {
args.push('--permission-mode', permissionMode);
@@ -287,12 +297,16 @@ export class HeadlessExecutor {
args.push('--resume', lastSession.sessionId);
if (process.env.CCS_DEBUG) {
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) {
args.push('--resume', sessionId);
} 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) {
args.push('--resume', sessionId);
@@ -357,7 +371,7 @@ export class HeadlessExecutor {
});
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
@@ -420,7 +434,7 @@ export class HeadlessExecutor {
if (showProgress) {
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
@@ -525,12 +539,14 @@ export class HeadlessExecutor {
if (showProgress) {
const durationSec = (duration / 1000).toFixed(1);
console.error(
timedOut
? ui.warn(`Timed out after ${durationSec}s`)
: ui.info(`Completed in ${durationSec}s`)
process.stderr.write(
String(
timedOut
? ui.warn(`Timed out after ${durationSec}s`)
: ui.info(`Completed in ${durationSec}s`)
) + '\n'
);
console.error('');
process.stderr.write('\n');
}
const result = buildExecutionResult({
@@ -664,7 +680,7 @@ export class HeadlessExecutor {
const result = await this.execute(profile, enhancedPrompt, execOptions);
if (result.success) return result;
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));
continue;
}
@@ -672,7 +688,7 @@ export class HeadlessExecutor {
} catch (error) {
lastError = error as Error;
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));
}
}
+37 -30
View File
@@ -2,7 +2,7 @@
* CLI argument parsing and normalization utilities.
*
* 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
* 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('-')) {
console.error(fail('--config-dir requires a path argument'));
process.stderr.write(String(fail('--config-dir requires a path argument')) + '\n');
process.exit(1);
}
try {
const stat = fs.statSync(configDirValue);
if (!stat.isDirectory()) {
console.error(fail(`Not a directory: ${configDirValue}`));
process.stderr.write(String(fail(`Not a directory: ${configDirValue}`)) + '\n');
process.exit(1);
}
} catch {
console.error(fail(`Config directory not found: ${configDirValue}`));
console.error(info('Create the directory first, then copy your config files into it.'));
process.stderr.write(String(fail(`Config directory not found: ${configDirValue}`)) + '\n');
process.stderr.write(
String(info('Create the directory first, then copy your config files into it.')) + '\n'
);
process.exit(1);
}
@@ -95,9 +97,9 @@ export async function bootstrapAndParseEarlyCli(rawArgs: string[]): Promise<Disp
// Security warning: cloud sync paths expose OAuth tokens
const cloudService = detectCloudSyncPath(configDirValue);
if (!isCompletionCommand && cloudService) {
console.error(warn(`CCS directory is under ${cloudService}.`));
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
process.stderr.write(String(warn(`CCS directory is under ${cloudService}.`)) + '\n');
process.stderr.write(' OAuth tokens in cliproxy/auth/ will be synced to cloud.\n');
process.stderr.write(' Consider: CCS_DIR=/path/outside/cloud ccs ...\n');
}
// 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
const cloudService = detectCloudSyncPath(process.env.CCS_DIR);
if (!isCompletionCommand && cloudService) {
console.error(warn(`CCS directory is under ${cloudService}.`));
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
process.stderr.write(String(warn(`CCS directory is under ${cloudService}.`)) + '\n');
process.stderr.write(' OAuth tokens in cliproxy/auth/ will be synced to cloud.\n');
process.stderr.write(' Consider: CCS_DIR=/path/outside/cloud ccs ...\n');
}
} else if (process.env.CCS_HOME) {
// Also warn for CCS_HOME env var pointing to cloud sync
const cloudService = detectCloudSyncPath(process.env.CCS_HOME);
if (!isCompletionCommand && cloudService) {
console.error(warn(`CCS directory is under ${cloudService}.`));
console.error(' OAuth tokens in cliproxy/auth/ will be synced to cloud.');
console.error(' Consider: CCS_DIR=/path/outside/cloud ccs ...');
process.stderr.write(String(warn(`CCS directory is under ${cloudService}.`)) + '\n');
process.stderr.write(' OAuth tokens in cliproxy/auth/ will be synced to cloud.\n');
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;
args = browserLaunchFlags.argsWithoutFlags;
} catch (error) {
console.error(fail((error as Error).message));
process.stderr.write(String(fail((error as Error).message)) + '\n');
process.exit(1);
// process.exit never returns but TypeScript needs the unreachable return
return { args, isCompletionCommand, browserLaunchOverride: undefined, exitNow: true };
@@ -228,15 +230,18 @@ export function normalizeLegacyCursorArgs(args: string[]): string[] {
}
export function printCursorLegacySubcommandDeprecation(subcommand: string): void {
console.error(
warn(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`)
process.stderr.write(
String(warn(`\`ccs cursor ${subcommand}\` is deprecated for the legacy Cursor IDE bridge.`)) +
'\n'
);
console.error(
warn(
`Use \`ccs legacy cursor ${subcommand}\` for the old bridge, or \`ccs cursor --auth|--accounts|--config\` for the CLIProxy provider.`
)
process.stderr.write(
String(
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 ==========
@@ -248,10 +253,12 @@ export function resolveRuntimeReasoningFlags(
const runtime = resolveDroidReasoningRuntime(args, envThinkingValue);
if (runtime.duplicateDisplays.length > 0) {
console.error(
warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}`
)
process.stderr.write(
String(
warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${runtime.sourceDisplay || '<first-flag>'}`
)
) + '\n'
);
}
@@ -280,11 +287,11 @@ export function exitWithRuntimeReasoningFlagError(
includeDroidExecExample?: boolean;
}
): never {
console.error(fail(message));
console.error(' Examples: --thinking low, --thinking 8192, --thinking off');
console.error(` Codex alias: --effort ${options.codexAliasLevels}`);
process.stderr.write(String(fail(message)) + '\n');
process.stderr.write(' Examples: --thinking low, --thinking 8192, --thinking off\n');
process.stderr.write(` Codex alias: --effort ${options.codexAliasLevels}\n`);
if (options.includeDroidExecExample) {
console.error(' Droid exec: --reasoning-effort high');
process.stderr.write(' Droid exec: --reasoning-effort high\n');
}
process.exit(1);
}
+35 -18
View File
@@ -67,12 +67,14 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
if (resolvedTarget !== 'claude') {
const adapter = targetAdapter;
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;
return;
}
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;
return;
}
@@ -112,12 +114,16 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
targetRemainingArgs.some((arg) => arg.startsWith(`${flag}=`))
);
if (providedUnsupportedFlag) {
console.error(
fail(
`${providedUnsupportedFlag} is only supported when running CLIProxy profiles on Claude target`
)
process.stderr.write(
String(
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;
return;
}
@@ -129,14 +135,20 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
] as CLIProxyProvider[];
const missingProvider = compositeProviders.find((p) => !isAuthenticated(p));
if (missingProvider) {
console.error(fail(`Missing OAuth auth for composite tier provider: ${missingProvider}`));
console.error(info(`Authenticate first: ccs ${missingProvider} --auth`));
process.stderr.write(
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;
return;
}
} else if (!isAuthenticated(provider)) {
console.error(fail(`No OAuth authentication found for provider: ${provider}`));
console.error(info(`Authenticate first: ccs ${provider} --auth`));
process.stderr.write(
String(fail(`No OAuth authentication found for provider: ${provider}`)) + '\n'
);
process.stderr.write(String(info(`Authenticate first: ccs ${provider} --auth`)) + '\n');
process.exitCode = 1;
return;
}
@@ -146,7 +158,9 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
targetRemainingArgs.includes('--verbose') || targetRemainingArgs.includes('-v')
);
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;
return;
}
@@ -177,13 +191,16 @@ export async function runCliproxyFlow(ctx: ProfileDispatchContext): Promise<void
};
if (!creds.baseUrl || !creds.apiKey) {
console.error(
fail(
`Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)`
)
process.stderr.write(
String(
fail(
`Missing CLIProxy runtime credentials for ${profileInfo.name} (ANTHROPIC_BASE_URL/AUTH_TOKEN)`
)
) + '\n'
);
console.error(
info('Reconfigure with: ccs config > CLIProxy, or run ccs <provider> --config')
process.stderr.write(
String(info('Reconfigure with: ccs config > CLIProxy, or run ccs <provider> --config')) +
'\n'
);
process.exitCode = 1;
return;
+51 -37
View File
@@ -113,10 +113,12 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
})
: {};
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
console.error(
info(
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
)
process.stderr.write(
String(
info(
`Continuity inheritance active: profile "${profileInfo.name}" -> account "${continuityInheritance.sourceAccount}"`
)
) + '\n'
);
}
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
@@ -165,14 +167,16 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
cliproxyBridgeProvider: cliproxyBridge?.provider ?? null,
});
if (!compatibility.supported) {
console.error(
fail(
compatibility.reason ||
`${targetAdapter?.displayName || resolvedTarget} does not support this profile.`
)
process.stderr.write(
String(
fail(
compatibility.reason ||
`${targetAdapter?.displayName || resolvedTarget} does not support this profile.`
)
) + '\n'
);
if (compatibility.suggestion) {
console.error(info(compatibility.suggestion));
process.stderr.write(String(info(compatibility.suggestion)) + '\n');
}
process.exit(1);
}
@@ -187,7 +191,7 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
if (glmtNormalization) {
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) {
const validation = await validateGlmKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']);
if (!validation.valid) {
console.error('');
console.error(fail(validation.error || 'API key validation failed'));
process.stderr.write('\n');
process.stderr.write(String(fail(validation.error || 'API key validation failed')) + '\n');
if (validation.suggestion) {
console.error('');
console.error(validation.suggestion);
process.stderr.write('\n');
process.stderr.write(String(validation.suggestion) + '\n');
}
console.error('');
console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"'));
process.stderr.write('\n');
process.stderr.write(
String(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"')) + '\n'
);
process.exit(1);
}
}
@@ -215,14 +221,16 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
if (apiKey) {
const validation = await validateMiniMaxKey(apiKey, settingsEnv['ANTHROPIC_BASE_URL']);
if (!validation.valid) {
console.error('');
console.error(fail(validation.error || 'API key validation failed'));
process.stderr.write('\n');
process.stderr.write(String(fail(validation.error || 'API key validation failed')) + '\n');
if (validation.suggestion) {
console.error('');
console.error(validation.suggestion);
process.stderr.write('\n');
process.stderr.write(String(validation.suggestion) + '\n');
}
console.error('');
console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"'));
process.stderr.write('\n');
process.stderr.write(
String(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs mm "prompt"')) + '\n'
);
process.exit(1);
}
}
@@ -235,15 +243,17 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
if (anthropicApiKey && !hasBaseUrl) {
const validation = await validateAnthropicKey(anthropicApiKey);
if (!validation.valid) {
console.error('');
console.error(fail(validation.error || 'API key validation failed'));
process.stderr.write('\n');
process.stderr.write(String(fail(validation.error || 'API key validation failed')) + '\n');
if (validation.suggestion) {
console.error('');
console.error(validation.suggestion);
process.stderr.write('\n');
process.stderr.write(String(validation.suggestion) + '\n');
}
console.error('');
console.error(
info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`)
process.stderr.write('\n');
process.stderr.write(
String(
info(`To skip validation: CCS_SKIP_PREFLIGHT=1 ccs ${profileInfo.name} "prompt"`)
) + '\n'
);
process.exit(1);
}
@@ -271,7 +281,7 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
// Log global env injection for visibility (debug mode only)
if (globalEnvConfig.enabled && Object.keys(globalEnv).length > 0 && process.env.CCS_DEBUG) {
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
@@ -302,7 +312,7 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
if (resolvedTarget !== 'claude') {
const adapter = targetAdapter;
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);
}
const directAnthropicBaseUrl =
@@ -349,14 +359,18 @@ export async function runSettingsFlow(ctx: ProfileDispatchContext): Promise<void
insecure: openAICompatProfile.insecure,
});
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);
}
console.error(
info(
`Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}`
)
process.stderr.write(
String(
info(
`Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}`
)
) + '\n'
);
const proxyEnv = {
+47 -29
View File
@@ -190,7 +190,7 @@ export async function resolveProfileAndTarget(
profileInfo.target ? { target: profileInfo.target } : undefined
);
} catch (error) {
console.error(fail((error as Error).message));
process.stderr.write(String(fail((error as Error).message)) + '\n');
process.exit(1);
// Unreachable; needed so TS knows resolvedTarget is always assigned below
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.
if (resolvedTarget !== 'claude') {
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);
}
@@ -235,13 +235,15 @@ export async function resolveProfileAndTarget(
cliproxyBridgeProvider: resolvedCliproxyBridge?.provider ?? null,
});
if (!compatibility.supported) {
console.error(
fail(
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
)
process.stderr.write(
String(
fail(
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
)
) + '\n'
);
if (compatibility.suggestion) {
console.error(info(compatibility.suggestion));
process.stderr.write(String(info(compatibility.suggestion)) + '\n');
}
process.exit(1);
}
@@ -255,13 +257,15 @@ export async function resolveProfileAndTarget(
isComposite: profileInfo.type === 'cliproxy' ? Boolean(profileInfo.isComposite) : undefined,
});
if (!compatibility.supported) {
console.error(
fail(
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
)
process.stderr.write(
String(
fail(
compatibility.reason || `${targetAdapter.displayName} does not support this profile.`
)
) + '\n'
);
if (compatibility.suggestion) {
console.error(info(compatibility.suggestion));
process.stderr.write(String(info(compatibility.suggestion)) + '\n');
}
process.exit(1);
}
@@ -269,7 +273,9 @@ export async function resolveProfileAndTarget(
if (profileInfo.type === '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);
}
@@ -278,12 +284,16 @@ export async function resolveProfileAndTarget(
const baseUrl = process.env['ANTHROPIC_BASE_URL'] || '';
const apiKey = process.env['ANTHROPIC_AUTH_TOKEN'] || '';
if (!baseUrl.trim() || !apiKey.trim()) {
console.error(
fail(
`${targetAdapter.displayName} default mode requires ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN`
)
process.stderr.write(
String(
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);
}
}
@@ -324,15 +334,17 @@ export async function resolveProfileAndTarget(
? getBlockedBrowserOverrideWarning('Codex Browser Tools', codexBrowserExposure)
: undefined;
if (blockedBrowserOverrideWarning) {
console.error(warn(blockedBrowserOverrideWarning));
process.stderr.write(String(warn(blockedBrowserOverrideWarning)) + '\n');
}
if (resolvedTarget !== 'claude' && !targetBinaryInfo) {
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') {
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') {
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);
}
@@ -344,7 +356,9 @@ export async function resolveProfileAndTarget(
const activeProfiles = allProfiles.settings.filter((name) => /^[a-zA-Z0-9._-]+$/.test(name));
await pruneOrphanedModels(activeProfiles);
} 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;
} else {
if (droidRoute.duplicateReasoningDisplays.length > 0) {
console.error(
warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || '<first-flag>'}`
)
process.stderr.write(
String(
warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${droidRoute.reasoningSourceDisplay || '<first-flag>'}`
)
) + '\n'
);
}
if (droidRoute.autoPrependedExec && process.stdout.isTTY) {
console.error(
info('Detected Droid exec-only flags. Routing as: droid exec <flags> [prompt]')
process.stderr.write(
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);
*/
import { createLogger } from '../services/logging';
interface ThinkingConfig {
[key: string]: unknown;
}
@@ -94,6 +96,7 @@ export class DeltaAccumulator {
private finalized: boolean;
private inputTokens: number;
private outputTokens: number;
private readonly logger = createLogger('glmt:delta-accumulator');
constructor(_thinkingConfig: ThinkingConfig = {}, options: DeltaAccumulatorOptions = {}) {
this.messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(7);
@@ -179,7 +182,11 @@ export class DeltaAccumulator {
const block = this.getCurrentBlock();
if (!block) {
// 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;
}
@@ -193,8 +200,15 @@ export class DeltaAccumulator {
// FIX: Verify assignment succeeded (paranoid check for race conditions)
if (block.content.length !== this.thinkingBuffer.length) {
console.error('[DeltaAccumulator] ERROR: Block content assignment failed');
console.error(`Expected: ${this.thinkingBuffer.length}, Got: ${block.content.length}`);
this.logger.error(
'delta.assignment_failed',
'DeltaAccumulator block content assignment failed',
{
blockIndex: block.index,
expected: this.thinkingBuffer.length,
actual: block.content.length,
}
);
}
} else if (block.type === 'text') {
// C-02 Fix: Enforce buffer size limit
@@ -216,9 +230,10 @@ export class DeltaAccumulator {
// FIX: Log block closure for debugging (helps diagnose timing issues)
if (block.type === 'thinking' && process.env.CCS_DEBUG === '1') {
console.error(
`[DeltaAccumulator] Stopped thinking block ${block.index}: ${block.content?.length || 0} chars`
);
this.logger.debug('delta.stopped_thinking_block', 'Stopped thinking block', {
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)
if (this.verbose) {
console.error(
`[glmt] Proxy listening on port ${this.port} (streaming with auto-fallback)`
logger.info(
'glmt.proxy.listening_verbose',
'GLMT proxy listening (streaming with auto-fallback)',
{
port: this.port,
}
);
}
// Debug mode notice
if ((this.transformer as unknown as { debugLog: boolean }).debugLog) {
console.error(
`[glmt] Debug logging enabled: ${(this.transformer as unknown as { debugLogDir: string }).debugLogDir}`
logger.info('glmt.proxy.debug_log_enabled', 'Debug logging enabled', {
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`);
@@ -167,7 +174,12 @@ export class GlmtProxy {
});
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);
});
});
@@ -240,7 +252,12 @@ export class GlmtProxy {
}
} catch (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;
this.log(`Request failed after ${duration}ms: ${err.message}`);
@@ -464,9 +481,11 @@ export class GlmtProxy {
);
if (this.verbose) {
console.error(
`[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms`
);
logger.warn('glmt.proxy.rate_limited_retry', 'Rate limited, retrying after backoff', {
attempt: attempt + 1,
maxRetries: this.retryConfig.maxRetries,
delayMs: Math.round(delay),
});
}
await this.sleep(delay);
@@ -526,8 +545,14 @@ export class GlmtProxy {
const delay = this.calculateRetryDelay(attempt, retryAfter);
if (this.verbose) {
console.error(
`[glmt-proxy] Rate limited, retry ${attempt + 1}/${this.retryConfig.maxRetries} after ${Math.round(delay)}ms`
logger.warn(
'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 {
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 });
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);
});
@@ -895,7 +925,12 @@ if (require.main === module) {
// Keep process alive
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();
process.exit(1);
});
+1 -4
View File
@@ -135,7 +135,6 @@ export class GlmtTransformer {
undefined,
{ level: 'error', error: { name: err.name, message: err.message } }
);
console.error('[glmt-transformer] Response transformation error:', err);
return {
id: 'msg_error_' + Date.now(),
type: 'message',
@@ -186,16 +185,14 @@ export class GlmtTransformer {
fs.writeFileSync(filepath, JSON.stringify(redacted, null, 2) + '\n', 'utf8');
} catch (error) {
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 {
if (this.verbose) {
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
*/
import { createLogger } from '../../services/logging';
import { LocaleEnforcer } from '../locale-enforcer';
import { ReasoningEnforcer } from '../reasoning-enforcer';
import { ContentTransformer } from './content-transformer';
@@ -18,6 +19,8 @@ import type {
TransformResult,
} from './types';
const logger = createLogger('glmt:pipeline:request-transformer');
export interface RequestTransformerConfig {
defaultThinking?: boolean;
verbose?: boolean;
@@ -131,7 +134,9 @@ export class RequestTransformer {
return { openaiRequest, thinkingConfig };
} catch (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 {
openaiRequest: anthropicRequest,
thinkingConfig: { thinking: false, effort: 'medium' },
+11 -8
View File
@@ -9,9 +9,12 @@
*/
import * as crypto from 'crypto';
import { createLogger } from '../../services/logging';
import type { DeltaAccumulator } from '../delta-accumulator';
import type { AccumulatorBlock, AnthropicSSEEvent, ThinkingSignature } from './types';
const logger = createLogger('glmt:pipeline:response-builder');
export class ResponseBuilder {
private verbose: boolean;
@@ -101,11 +104,10 @@ export class ResponseBuilder {
// FIX: Guard against empty content (signature timing race)
if (!block.content || block.content.length === 0) {
if (this.verbose) {
console.error(
`[ResponseBuilder] WARNING: Skipping signature for empty thinking block ${block.index}`
);
console.error(
`This indicates a race condition - signature requested before content accumulated`
logger.warn(
'signature_empty_thinking_block',
'Skipping signature for empty thinking block - possible race condition',
{ blockIndex: block.index }
);
}
return null;
@@ -114,9 +116,10 @@ export class ResponseBuilder {
const signature = this.generateThinkingSignature(block.content);
if (this.verbose) {
console.error(
`[ResponseBuilder] Generating signature for block ${block.index}: ${block.content.length} chars`
);
logger.info('signature_generated', 'Generated thinking signature', {
blockIndex: block.index,
contentLength: block.content.length,
});
}
return {
+12 -7
View File
@@ -8,11 +8,14 @@
* - Generate appropriate Anthropic SSE events
*/
import { createLogger } from '../../services/logging';
import type { DeltaAccumulator } from '../delta-accumulator';
import type { SSEEvent, AnthropicSSEEvent, AccumulatorBlock } from './types';
import { ResponseBuilder } from './response-builder';
import { ToolCallHandler } from './tool-call-handler';
const logger = createLogger('glmt:pipeline:stream-parser');
export interface StreamParserConfig {
verbose?: boolean;
debugMode?: boolean;
@@ -144,10 +147,11 @@ export class StreamParser {
const currentBlock = accumulator.getCurrentBlock();
if (this.debugMode) {
console.error(`[StreamParser] Reasoning delta: ${reasoningContent.length} chars`);
console.error(
`[StreamParser] Current block: ${currentBlock?.type || 'none'}, index: ${currentBlock?.index ?? 'N/A'}`
);
logger.info('reasoning_delta', 'Reasoning content delta received', {
deltaLength: reasoningContent.length,
currentBlockType: currentBlock?.type || 'none',
currentBlockIndex: currentBlock?.index ?? null,
});
}
if (!currentBlock || currentBlock.type !== 'thinking') {
@@ -156,7 +160,9 @@ export class StreamParser {
events.push(this.responseBuilder.createContentBlockStartEvent(block));
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 {
if (this.verbose) {
const timestamp = new Date().toTimeString().split(' ')[0]; // HH:MM:SS
console.error(`[StreamParser] [${timestamp}] ${message}`);
logger.warn('stream_parser_verbose', message, {});
}
}
}
+12 -1
View File
@@ -7,9 +7,12 @@
* - Handle input_json_delta events
*/
import { createLogger } from '../../services/logging';
import type { DeltaAccumulator } from '../delta-accumulator';
import type { OpenAIToolCallDelta, OpenAIToolCall, ContentBlock, AnthropicSSEEvent } from './types';
const logger = createLogger('glmt:pipeline:tool-call-handler');
export class ToolCallHandler {
processToolCalls(toolCalls: OpenAIToolCall[]): ContentBlock[] {
const content: ContentBlock[] = [];
@@ -20,7 +23,15 @@ export class ToolCallHandler {
parsedInput = JSON.parse(toolCall.function.arguments || '{}');
} catch (parseError) {
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 };
}
+8 -8
View File
@@ -17,6 +17,8 @@
* });
*/
import { createLogger } from '../services/logging';
interface SSEParserOptions {
maxBufferSize?: number;
throwOnMalformedJson?: boolean;
@@ -36,6 +38,7 @@ export class SSEParser {
private maxBufferSize: number;
private throwOnMalformedJson: boolean;
private pendingCR: boolean;
private readonly logger = createLogger('glmt:sse-parser');
constructor(options: SSEParserOptions = {}) {
this.buffer = '';
@@ -118,14 +121,11 @@ export class SSEParser {
currentEvent.index = this.eventCount;
events.push({ ...currentEvent });
} catch (e) {
if (typeof console !== 'undefined' && console.error) {
console.error(
'[SSEParser] Malformed JSON event:',
(e as Error).message,
'Data:',
data.substring(0, 100)
);
}
this.logger.warn('sse.malformed_json', 'Malformed JSON event in SSE stream', {
err: e instanceof Error ? { name: e.name, message: e.message } : { message: String(e) },
dataPreview: data.substring(0, 100),
eventIndex: this.eventCount,
});
if (this.throwOnMalformedJson) {
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,
});
server.once('error', (error) => {
console.error((error as Error).message);
process.stderr.write(String((error as Error).message) + '\n');
process.exit(1);
});
const shutdown = () => server.close();
@@ -2,6 +2,9 @@ import { DeltaAccumulator } from '../../glmt/delta-accumulator';
import { GlmtTransformer } from '../../glmt/glmt-transformer';
import { SSEParser } from '../../glmt/sse-parser';
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 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 {
if (error instanceof Error) {
return error.message;
}
try {
return JSON.stringify(error);
} catch {
return String(error);
}
}
function logTranslationError(context: string, error: unknown): void {
console.error(`[proxy-sse-transformer] ${context}: ${formatErrorForLog(error)}`);
function logTranslationError(event: string, message: string, error: unknown): void {
logger.error(event, message, {
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
}
export function createAnthropicErrorResponse(
@@ -130,7 +126,11 @@ async function createAnthropicErrorProxyResponse(response: Response): Promise<Re
}
}
} 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);
@@ -146,6 +146,7 @@ async function createAnthropicJsonResponse(response: Response): Promise<Response
const anthropicResponse = new GlmtTransformer().transformResponse(openAIResponse);
if (isSyntheticTransformationFallback(anthropicResponse)) {
logTranslationError(
'sse_transformer.json_synthetic_fallback',
'OpenAI-compatible JSON translation produced synthetic fallback response',
anthropicResponse
);
@@ -157,7 +158,11 @@ async function createAnthropicJsonResponse(response: Response): Promise<Response
headers: { 'Content-Type': 'application/json' },
});
} 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);
}
}
@@ -210,7 +215,11 @@ function createAnthropicStreamingResponse(response: Response): Response {
}
}
} catch (error) {
logTranslationError('OpenAI-compatible SSE translation failed', error);
logTranslationError(
'sse_transformer.sse_translation_failed',
'OpenAI-compatible SSE translation failed',
error
);
controller.enqueue(
encoder.encode(
formatSseEvent(
+25 -2
View File
@@ -31,6 +31,29 @@ function maskAuthSchemeValue(value: string): string {
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 {
if (value === null || value === undefined) {
return value;
@@ -41,7 +64,7 @@ function sanitizeValue(value: unknown, depth: number): unknown {
}
if (typeof value === 'string') {
return truncateString(maskAuthSchemeValue(value));
return truncateString(maskSecretTokens(maskAuthSchemeValue(value)));
}
if (typeof value === 'number' || typeof value === 'boolean') {
@@ -51,7 +74,7 @@ function sanitizeValue(value: unknown, depth: number): unknown {
if (value instanceof Error) {
return {
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 { getResolvedLoggingConfig } from './log-config';
import { getRequestContext } from './log-context';
import { redactContext } from './log-redaction';
import { maskSecretTokens, redactContext } from './log-redaction';
import { appendStructuredLogEntry } from './log-storage';
import type { LogEntry, LogStage, LoggingLevel } from './log-types';
@@ -29,7 +29,7 @@ function createEntry(
level,
source,
event,
message,
message: maskSecretTokens(message),
processId: process.pid,
runId: processRunId,
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();
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);
}
try {
const stat = fs.statSync(codexPath);
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);
}
} catch (err) {
const error = err as NodeJS.ErrnoException;
console.error(
`[X] Codex CLI path is not accessible (${error.code || 'unknown'}): ${codexPath}`
process.stderr.write(
String(`[X] Codex CLI path is not accessible (${error.code || 'unknown'}): ${codexPath}`) +
'\n'
);
return exitWithCleanup(1);
}
const codexHomePreparation = prepareExplicitCodexHome(env, args);
if (codexHomePreparation.error) {
console.error(codexHomePreparation.error);
process.stderr.write(String(codexHomePreparation.error) + '\n');
return exitWithCleanup(1);
}
const launchEnv = codexHomePreparation.env;
@@ -450,18 +453,22 @@ export class CodexAdapter implements TargetAdapter {
wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => {
if (err.code === 'EACCES') {
console.error(`[X] Codex CLI is not executable: ${codexPath}`);
console.error(' Check file permissions and executable bit.');
process.stderr.write(`[X] Codex CLI is not executable: ${codexPath}\n`);
process.stderr.write(' Check file permissions and executable bit.\n');
} else if (err.code === 'ENOENT') {
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) {
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 {
console.error(`[X] Codex CLI not found: ${codexPath}`);
process.stderr.write(`[X] Codex CLI not found: ${codexPath}\n`);
}
} 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);
});
+15 -13
View File
@@ -104,19 +104,19 @@ export class DroidAdapter implements TargetAdapter {
const droidPath = options?.binaryInfo?.path || detectDroidCli();
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);
}
try {
const stat = fs.statSync(droidPath);
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);
}
} catch (err) {
const error = err as NodeJS.ErrnoException;
console.error(
`[X] Droid CLI path is not accessible (${error.code || 'unknown'}): ${droidPath}`
process.stderr.write(
`[X] Droid CLI path is not accessible (${error.code || 'unknown'}): ${droidPath}\n`
);
return exitWithCleanup(1);
}
@@ -171,21 +171,23 @@ export class DroidAdapter implements TargetAdapter {
wireChildProcessSignals(child, (err: NodeJS.ErrnoException) => {
if (err.code === 'EACCES') {
console.error(`[X] Droid CLI is not executable: ${droidPath}`);
console.error(' Check file permissions and executable bit.');
process.stderr.write(`[X] Droid CLI is not executable: ${droidPath}\n`);
process.stderr.write(' Check file permissions and executable bit.\n');
} else if (err.code === 'ENOENT') {
if (isPowerShellScript) {
console.error('[X] PowerShell executable not found (required for .ps1 wrapper launch).');
console.error(' Ensure powershell.exe is available in PATH.');
process.stderr.write(
'[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) {
console.error('[X] Windows command shell not found for Droid wrapper launch.');
console.error(' Ensure cmd.exe is available and accessible.');
process.stderr.write('[X] Windows command shell not found for Droid wrapper launch.\n');
process.stderr.write(' Ensure cmd.exe is available and accessible.\n');
} else {
console.error(`[X] Droid CLI not found: ${droidPath}`);
console.error(' Install: npm i -g @factory/cli');
process.stderr.write(`[X] Droid CLI not found: ${droidPath}\n`);
process.stderr.write(' Install: npm i -g @factory/cli\n');
}
} 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);
});
+52 -29
View File
@@ -59,8 +59,10 @@ function hasMatchingContents(sourcePath: string, destinationPath: string): boole
return source.equals(destination);
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Existing Image Analysis MCP server is unreadable: ${(error as Error).message}`)
process.stderr.write(
String(
warn(`Existing Image Analysis MCP server is unreadable: ${(error as Error).message}`)
) + '\n'
);
}
return false;
@@ -170,7 +172,9 @@ function removeManagedServerConfig(configPath: string): boolean {
const config = readClaudeUserConfig(configPath);
if (config === null) {
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;
}
@@ -198,15 +202,19 @@ function removeManagedServerConfig(configPath: string): boolean {
try {
writeClaudeUserConfig(configPath, nextConfig);
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;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(
`Failed to remove Image Analysis MCP config from ${configPath}: ${(error as Error).message}`
)
process.stderr.write(
String(
warn(
`Failed to remove Image Analysis MCP config from ${configPath}: ${(error as Error).message}`
)
) + '\n'
);
}
return false;
@@ -215,10 +223,12 @@ function removeManagedServerConfig(configPath: string): boolean {
} catch (error) {
if (isLockUnavailableError(error)) {
if (process.env.CCS_DEBUG) {
console.error(
warn(
`Image Analysis MCP cleanup skipped because ${configPath} is locked by another process`
)
process.stderr.write(
String(
warn(
`Image Analysis MCP cleanup skipped because ${configPath} is locked by another process`
)
) + '\n'
);
}
return false;
@@ -249,8 +259,9 @@ export function installImageAnalysisMcpServer(): boolean {
const missingArtifact = artifacts.find((artifact) => !artifact.sourcePath);
if (missingArtifact) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Image Analysis MCP runtime source not found: ${missingArtifact.fileName}`)
process.stderr.write(
String(warn(`Image Analysis MCP runtime source not found: ${missingArtifact.fileName}`)) +
'\n'
);
}
return false;
@@ -301,8 +312,9 @@ export function installImageAnalysisMcpServer(): boolean {
return true;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Failed to install Image Analysis MCP server: ${(error as Error).message}`)
process.stderr.write(
String(warn(`Failed to install Image Analysis MCP server: ${(error as Error).message}`)) +
'\n'
);
}
return false;
@@ -333,7 +345,9 @@ export function ensureImageAnalysisMcpConfig(): boolean {
if (config === null) {
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;
}
@@ -364,12 +378,16 @@ export function ensureImageAnalysisMcpConfig(): boolean {
try {
writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
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;
} catch (error) {
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;
}
@@ -377,10 +395,12 @@ export function ensureImageAnalysisMcpConfig(): boolean {
} catch (error) {
if (isLockUnavailableError(error)) {
if (process.env.CCS_DEBUG) {
console.error(
warn(
`Image Analysis MCP provisioning skipped because ${claudeUserConfigPath} is locked by another process`
)
process.stderr.write(
String(
warn(
`Image Analysis MCP provisioning skipped because ${claudeUserConfigPath} is locked by another process`
)
) + '\n'
);
}
return false;
@@ -426,8 +446,9 @@ export function uninstallImageAnalysisMcpServer(): boolean {
return removed;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Failed to remove Image Analysis MCP server: ${(error as Error).message}`)
process.stderr.write(
String(warn(`Failed to remove Image Analysis MCP server: ${(error as Error).message}`)) +
'\n'
);
}
return false;
@@ -461,10 +482,12 @@ export function ensureImageAnalysisMcpOrThrow(): boolean {
const ready = ensureImageAnalysisMcp();
if (!ready) {
console.error(
warn(
'Image Analysis is enabled, but CCS could not prepare the local ImageAnalysis tool. This session will fall back to native Read.'
)
process.stderr.write(
String(
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);
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(`Existing WebSearch MCP server is unreadable: ${(error as Error).message}`)
process.stderr.write(
String(warn(`Existing WebSearch MCP server is unreadable: ${(error as Error).message}`)) +
'\n'
);
}
return false;
@@ -127,7 +128,9 @@ function removeManagedServerConfig(configPath: string): boolean {
const config = readClaudeUserConfig(configPath);
if (config === null) {
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;
}
@@ -155,15 +158,19 @@ function removeManagedServerConfig(configPath: string): boolean {
try {
writeClaudeUserConfig(configPath, nextConfig);
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;
} catch (error) {
if (process.env.CCS_DEBUG) {
console.error(
warn(
`Failed to remove WebSearch MCP config from ${configPath}: ${(error as Error).message}`
)
process.stderr.write(
String(
warn(
`Failed to remove WebSearch MCP config from ${configPath}: ${(error as Error).message}`
)
) + '\n'
);
}
return false;
@@ -192,8 +199,9 @@ export function installWebSearchMcpServer(): boolean {
if (!installWebSearchHook()) {
appendWebSearchTrace('websearch_mcp_install_failed', { reason: 'hook_unavailable' });
if (process.env.CCS_DEBUG) {
console.error(
warn('WebSearch MCP server install skipped because hook runtime is unavailable')
process.stderr.write(
String(warn('WebSearch MCP server install skipped because hook runtime is unavailable')) +
'\n'
);
}
return false;
@@ -203,7 +211,9 @@ export function installWebSearchMcpServer(): boolean {
if (!sourcePath) {
appendWebSearchTrace('websearch_mcp_install_failed', { reason: 'source_missing' });
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;
}
@@ -245,7 +255,9 @@ export function installWebSearchMcpServer(): boolean {
error: (error as Error).message,
});
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;
} finally {
@@ -271,7 +283,9 @@ export function ensureWebSearchMcpConfig(): boolean {
if (config === null) {
appendWebSearchTrace('websearch_mcp_config_failed', { reason: 'malformed_user_config' });
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;
}
@@ -311,7 +325,9 @@ export function ensureWebSearchMcpConfig(): boolean {
writeClaudeUserConfig(claudeUserConfigPath, nextConfig);
appendWebSearchTrace('websearch_mcp_config_ready', { configPath: claudeUserConfigPath });
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;
} catch (error) {
@@ -321,7 +337,9 @@ export function ensureWebSearchMcpConfig(): boolean {
error: (error as Error).message,
});
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;
}
@@ -334,10 +352,12 @@ export function ensureWebSearchMcpConfig(): boolean {
error: (error as Error).message,
});
if (process.env.CCS_DEBUG) {
console.error(
warn(
`WebSearch MCP config skipped because ${claudeUserConfigPath} is locked by another process`
)
process.stderr.write(
String(
warn(
`WebSearch MCP config skipped because ${claudeUserConfigPath} is locked by another process`
)
) + '\n'
);
}
return false;
@@ -381,7 +401,9 @@ export function uninstallWebSearchMcpServer(): boolean {
return true;
} catch (error) {
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;
}
+12 -2
View File
@@ -23,6 +23,9 @@ import type { HealthReport } from '../health-service';
import type { CliproxyUsageHistoryDetail } from '../usage/cliproxy-usage-transformer';
import { computeBarAnalyticsFromDaily } from '../usage/bar-analytics';
import type { DailyUsage, HourlyUsage } from '../usage/types';
import { createLogger } from '../../services/logging';
const logger = createLogger('web-server:routes:bar');
// ============================================================================
// Types
@@ -551,7 +554,11 @@ export function createBarRouter(deps: BarRouterDeps): Router {
res.json([...rows, ...nativeRows].map(serializeBarRow));
} 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' });
}
});
@@ -575,7 +582,10 @@ export function createBarRouter(deps: BarRouterDeps): Router {
const analytics = computeBarAnalyticsFromDaily(daily ?? [], hourly ?? [], new Date());
res.json(analytics);
} 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' });
}
});
+20 -7
View File
@@ -1,4 +1,6 @@
import { Router, Request, Response } from 'express';
import { createLogger } from '../../services/logging';
import {
getAllAuthStatus,
getOAuthConfig,
@@ -78,6 +80,7 @@ import { buildOAuthStartFailureGuidance } from '../../cliproxy/auth/oauth-start-
import { getStoredConfiguredBackend } from '../../cliproxy/binary-manager';
const router = Router();
const logger = createLogger('web-server:routes:cliproxy-auth');
const MANUAL_AUTH_STATE_TTL_MS = 10 * 60 * 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()
);
if (credentialError) {
console.error(
`[cliproxy-auth-routes] start-url credential guard fired for provider=${provider}: ${credentialError}`
logger.warn(
'cliproxy_auth.start_url.credential_guard_fired',
'start-url credential guard fired for Plus-backend provider',
{ provider, reason: credentialError }
);
res.status(400).json({
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);
if (authUrlError) {
const redactedUrl = authUrl.split('?')[0];
console.error(
`[cliproxy-auth-routes] Plus emitted OAuth URL without client_id for provider=${provider} url=${redactedUrl}`
logger.error(
'cliproxy_auth.start_url.missing_client_id',
'Plus emitted OAuth URL without client_id',
{ provider, urlOrigin: redactedUrl }
);
res.status(502).json({
error: 'plus_oauth_url_missing_client_id',
@@ -1192,8 +1199,10 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
});
} catch (error) {
if (error instanceof SyntaxError) {
console.error(
`[cliproxy-auth-routes] Invalid OAuth start response for provider=${provider}: ${error.message}`
logger.error(
'cliproxy_auth.start_url.invalid_response',
'Invalid OAuth start response from CLIProxyAPI',
{ provider, err: { name: error.name, message: error.message } }
);
res.status(502).json({
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`,
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);
}
});
+135 -22
View File
@@ -57,6 +57,9 @@ import {
import { installDashboardCliproxyVersion } from '../services/cliproxy-dashboard-install-service';
import { restartDashboardCliproxy } from '../services/cliproxy-dashboard-restart-service';
import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware';
import { createLogger } from '../../services/logging';
const logger = createLogger('web-server:routes:cliproxy-stats');
const router = Router();
type RestartDashboardCliproxyHandler = typeof restartDashboardCliproxy;
@@ -297,7 +300,12 @@ const handleStatsRequest = async (_req: Request, res: Response): Promise<void> =
res.json(stats);
} 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' });
}
};
@@ -322,7 +330,12 @@ router.get('/status', async (_req: Request, res: Response): Promise<void> => {
const running = await isCliproxyRunning(resolveLifecyclePort());
res.json({ running });
} 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' });
}
});
@@ -360,7 +373,12 @@ router.get('/proxy-status', async (_req: Request, res: Response): Promise<void>
res.json(sessionStatus);
}
} 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' });
}
});
@@ -375,7 +393,12 @@ router.post('/proxy-start', async (_req: Request, res: Response): Promise<void>
const result = await ensureCliproxyService(resolveLifecyclePort());
res.json(result);
} 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' });
}
});
@@ -389,7 +412,12 @@ router.post('/proxy-stop', async (_req: Request, res: Response): Promise<void> =
const result = await stopProxy(resolveLifecyclePort());
res.json(result);
} 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' });
}
});
@@ -405,7 +433,12 @@ router.get('/update-check', async (_req: Request, res: Response): Promise<void>
res.json(result);
} 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' });
}
});
@@ -438,7 +471,12 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
res.json(modelsResponse);
} 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' });
}
});
@@ -486,7 +524,12 @@ router.get('/error-logs', async (_req: Request, res: Response): Promise<void> =>
res.json({ files: filesWithMetadata });
} 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' });
}
});
@@ -526,7 +569,12 @@ router.get('/error-logs/:name', async (req: Request, res: Response): Promise<voi
res.type('text/plain').send(content);
} 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' });
}
});
@@ -548,7 +596,12 @@ router.get('/config.yaml', async (_req: Request, res: Response): Promise<void> =
const content = fs.readFileSync(configPath, 'utf8');
res.type('text/yaml').send(content);
} 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' });
}
});
@@ -582,7 +635,12 @@ router.put('/config.yaml', async (req: Request, res: Response): Promise<void> =>
res.json({ success: true, path: configPath });
} 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' });
}
});
@@ -617,7 +675,12 @@ router.get('/auth-files', async (_req: Request, res: Response): Promise<void> =>
res.json({ files, directory: authDir });
} 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' });
}
});
@@ -654,7 +717,12 @@ router.get('/auth-files/download', async (req: Request, res: Response): Promise<
res.setHeader('Content-Disposition', `attachment; filename="${name}"`);
res.type('application/octet-stream').send(content);
} 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' });
}
});
@@ -742,7 +810,12 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise<voi
res.json({ success: true, provider, model: canonicalModel });
} 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' });
}
});
@@ -794,7 +867,12 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi
res.json(result);
} 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' });
}
});
@@ -842,7 +920,12 @@ router.get('/quota/claude/:accountId', async (req: Request, res: Response): Prom
res.json(result);
} 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' });
}
});
@@ -890,7 +973,12 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom
res.json(result);
} 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' });
}
});
@@ -938,7 +1026,12 @@ router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promis
res.json(result);
} 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' });
}
});
@@ -997,7 +1090,12 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P
res.json(result);
} 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' });
}
});
@@ -1013,7 +1111,12 @@ router.get('/versions', async (_req: Request, res: Response): Promise<void> => {
const backend = getStoredConfiguredBackend();
res.json(await resolveCliproxyVersionsPayload(backend));
} 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' });
}
});
@@ -1074,7 +1177,12 @@ router.post('/install', async (req: Request, res: Response): Promise<void> => {
...installResult,
});
} 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' });
}
});
@@ -1092,7 +1200,12 @@ export function registerCliproxyRestartRoute(
const result = await restartHandler();
res.json(result);
} 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' });
}
});
@@ -13,8 +13,10 @@ import {
getLocalSyncStatus,
} from '../../cliproxy/sync';
import { mutateConfig } from '../../config/config-loader-facade';
import { createLogger } from '../../services/logging';
const router = Router();
const logger = createLogger('web-server:routes:cliproxy-sync');
/**
* 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();
} catch (watcherError) {
// 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 });
+12 -1
View File
@@ -7,8 +7,10 @@ import rateLimit from 'express-rate-limit';
import * as fs from 'fs';
import * as path from 'path';
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
import { createLogger } from '../../services/logging';
const router = Router();
const logger = createLogger('web-server:routes:persist');
/** Rate limiter for restore endpoint - prevents abuse */
const restoreRateLimiter = rateLimit({
@@ -263,7 +265,16 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response):
fs.unlinkSync(tempPath);
}
} 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({
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 { createLogger } from '../../services/logging';
import { testConnection } from '../../cliproxy/services/remote-proxy-client';
import { isProxyRunning } from '../../cliproxy/services/proxy-lifecycle-service';
import { DEFAULT_BACKEND } from '../../cliproxy/binary/platform-detector';
@@ -28,6 +29,8 @@ import { loadOrCreateUnifiedConfig, mutateConfig } from '../../config/config-loa
const router = Router();
const logger = createLogger('web-server:routes:cliproxy-server');
router.use((req: Request, res: Response, next) => {
if (
requireLocalAccessWhenAuthDisabled(
@@ -48,7 +51,12 @@ router.get('/', async (_req: Request, res: Response) => {
const config = await loadOrCreateUnifiedConfig();
res.json(config.cliproxy_server || DEFAULT_CLIPROXY_SERVER_CONFIG);
} 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' });
}
});
@@ -113,7 +121,14 @@ router.put('/', (req: Request, res: Response) => {
res.json(updated.cliproxy_server);
} 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' });
}
});
@@ -130,7 +145,12 @@ router.get('/backend', async (_req: Request, res: Response) => {
managementPanelRepository: getManagementPanelRepository(),
});
} 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' });
}
});
@@ -187,7 +207,14 @@ router.put('/backend', (req: Request, res: Response) => {
res.json({ backend, managementPanelRepository: getManagementPanelRepository() });
} 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' });
}
});
@@ -221,7 +248,18 @@ router.post('/test', async (req: Request, res: Response) => {
res.json(status);
} 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' });
}
});
+11 -5
View File
@@ -21,6 +21,9 @@ import type { TargetType } from '../../targets/target-adapter';
import { isPersistedTargetType } from '../../targets/target-metadata';
import { ValidationError } from '../../errors/error-types';
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 */
export interface ModelMapping {
@@ -498,11 +501,14 @@ export function createRouteErrorHelpers(prefix: string): {
) => void;
} {
function logRouteError(context: string, error: unknown): void {
if (error instanceof Error) {
console.error(`[${prefix}] ${context}: ${error.message}`);
return;
}
console.error(`[${prefix}] ${context}: unknown error`);
logger.error('route.error', `${prefix}: ${context}`, {
prefix,
context,
err:
error instanceof Error
? { name: error.name, message: error.message }
: { message: String(error) },
});
}
function respondInternalError(
+10 -8
View File
@@ -85,7 +85,7 @@ function getInstancePaths(): string[] {
return fs.existsSync(projectsPath);
});
} catch {
console.error(fail('Failed to read CCS instances directory'));
process.stderr.write(String(fail('Failed to read CCS instances directory')) + '\n');
return [];
}
}
@@ -436,7 +436,7 @@ async function refreshFromSource(): Promise<{
instanceDataResults.push(data);
} catch (err) {
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))`));
}
} 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 {
@@ -480,7 +480,7 @@ async function refreshFromSource(): Promise<{
console.log(info(`Included native Droid usage data (${droidEntries.length} event(s))`));
}
} 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)
@@ -493,7 +493,7 @@ async function refreshFromSource(): Promise<{
console.log(info('Included CLIProxy usage data'));
}
} 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
@@ -595,7 +595,7 @@ async function getCachedData<T>(key: string, ttl: number, loader: () => Promise<
persistCacheIfComplete();
})
.catch((err) => {
console.error(fail(`Background refresh failed for ${key}: ${err}`));
process.stderr.write(String(fail(`Background refresh failed for ${key}: ${err}`)) + '\n');
})
.finally(() => {
pendingRequests.delete(key);
@@ -731,7 +731,9 @@ export async function prewarmUsageCache(): Promise<{
isRefreshing = true;
refreshFromSourceCoalesced()
.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(() => {
isRefreshing = false;
});
@@ -748,7 +750,7 @@ export async function prewarmUsageCache(): Promise<{
console.log(ok(`Usage cache ready (${elapsed}ms)`));
return { timestamp: Date.now(), elapsed, source: 'fresh' };
} 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;
}
}
+3 -1
View File
@@ -121,7 +121,9 @@ export function filterByDateRange<
}
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 isValidationError =
errorMessage.includes('Invalid') ||
@@ -10,11 +10,12 @@ import { DelegationHandler } from '../../../src/delegation/delegation-handler';
describe('DelegationHandler', () => {
let handler: DelegationHandler;
// Validation warnings are written to stderr (process.stderr.write), not console.error.
let consoleErrorSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
handler = new DelegationHandler();
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {});
consoleErrorSpy = spyOn(process.stderr, 'write').mockImplementation(() => true);
});
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');
});
});