mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
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:
1 parent
5e1b8640ab
commit
87aeb8f193
60 files changed
+1710
-896
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user