llm-cli-gateway 2.7.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +28 -1
  3. package/dist/acp/client.d.ts +78 -0
  4. package/dist/acp/client.js +201 -0
  5. package/dist/acp/errors.d.ts +63 -0
  6. package/dist/acp/errors.js +139 -0
  7. package/dist/acp/json-rpc-stdio.d.ts +71 -0
  8. package/dist/acp/json-rpc-stdio.js +375 -0
  9. package/dist/acp/process-manager.d.ts +66 -0
  10. package/dist/acp/process-manager.js +364 -0
  11. package/dist/acp/provider-registry.d.ts +24 -0
  12. package/dist/acp/provider-registry.js +82 -0
  13. package/dist/acp/types.d.ts +557 -0
  14. package/dist/acp/types.js +335 -0
  15. package/dist/approval-manager.d.ts +1 -0
  16. package/dist/approval-manager.js +14 -1
  17. package/dist/async-job-manager.d.ts +3 -0
  18. package/dist/async-job-manager.js +56 -16
  19. package/dist/auth.d.ts +4 -0
  20. package/dist/auth.js +16 -0
  21. package/dist/cache-stats.d.ts +1 -0
  22. package/dist/cache-stats.js +19 -11
  23. package/dist/cli-updater.js +5 -2
  24. package/dist/codex-json-parser.d.ts +3 -0
  25. package/dist/codex-json-parser.js +17 -0
  26. package/dist/config.d.ts +30 -0
  27. package/dist/config.js +140 -0
  28. package/dist/flight-recorder.d.ts +7 -1
  29. package/dist/flight-recorder.js +33 -6
  30. package/dist/http-transport.js +21 -18
  31. package/dist/index.js +104 -34
  32. package/dist/job-store.d.ts +4 -0
  33. package/dist/job-store.js +16 -4
  34. package/dist/oauth.d.ts +2 -0
  35. package/dist/oauth.js +90 -8
  36. package/dist/pricing.d.ts +1 -1
  37. package/dist/pricing.js +67 -2
  38. package/dist/provider-tool-capabilities.d.ts +38 -0
  39. package/dist/provider-tool-capabilities.js +142 -0
  40. package/dist/request-context.d.ts +4 -0
  41. package/dist/request-context.js +16 -0
  42. package/dist/request-helpers.d.ts +4 -4
  43. package/dist/request-limits.d.ts +8 -0
  44. package/dist/request-limits.js +49 -0
  45. package/dist/secret-redaction.d.ts +3 -0
  46. package/dist/secret-redaction.js +53 -0
  47. package/dist/session-manager-pg.js +8 -5
  48. package/dist/session-manager.d.ts +1 -0
  49. package/dist/session-manager.js +2 -0
  50. package/dist/upstream-contracts.d.ts +27 -0
  51. package/dist/upstream-contracts.js +131 -0
  52. package/migrations/004_session_owner_principal.sql +10 -0
  53. package/npm-shrinkwrap.json +2 -2
  54. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,88 @@ All notable changes to the llm-cli-gateway project.
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [2.9.0] - 2026-06-14: MCP-surface red-team remediation
8
+
9
+ This release remediates all 17 findings of a multi-LLM red-team of the gateway's
10
+ external and internal MCP surface. Every change is material only in the opt-in
11
+ remote/OAuth/multi-tenant modes or under `approvalStrategy:"mcp_managed"`; the
12
+ default local-stdio path is unaffected. Provider CLI release targets are
13
+ unchanged from 2.8.0 (see `docs/upstream/release-targets.md`).
14
+
15
+ ### Security
16
+
17
+ - Per-principal isolation (F3): `session_*`, `llm_job_*`, and
18
+ `llm_request_result` now resolve a calling owner principal and enforce
19
+ own-or-not-found access, so one OAuth client can no longer read or mutate
20
+ another client's sessions, jobs, or persisted request results. Owner columns
21
+ are additive and nullable; legacy unowned rows remain accessible to local
22
+ callers. The shared static bearer is one principal by design.
23
+ - Built-in OAuth server hardening (F14): the authorization-code flow gains an
24
+ opt-in human-consent gate (dedicated consent password, default off) and a
25
+ trusted-principal-header seam so a proxy front door can attribute requests
26
+ without the gateway shipping any identity-provider configuration.
27
+ - Approval-gate hard boundary (F15): under `approvalStrategy:"mcp_managed"` a
28
+ full permission/sandbox bypass request is now denied by default regardless of
29
+ heuristic score, and the managed strategy no longer force-sets each provider's
30
+ most-permissive mode. See "Changed" for the per-provider defaults.
31
+ - Secret redaction (F4): prompts and responses are redacted before they are
32
+ written to the flight-recorder database.
33
+ - Remote-exposure fail-closed (F17): a remotely-exposed open OAuth configuration
34
+ refuses to start, and the `Host` header is no longer trusted for loopback
35
+ determination.
36
+ - MCP surface hardening (F1/F2/F10): request bodies are capped to prevent a
37
+ memory DoS, `cli_upgrade` targets are clamped to block arbitrary-package
38
+ installation, and a committed NUL byte in `config.ts` (which rendered the file
39
+ binary and invisible to gitleaks/SAST/grep) was removed.
40
+
41
+ ### Changed
42
+
43
+ - Under `approvalStrategy:"mcp_managed"`, every provider now defaults to an
44
+ accept-edits-level mode (auto-accept file edits, dangerous tools still gated)
45
+ instead of full auto-approve: Claude `--permission-mode acceptEdits`, Grok
46
+ `--permission-mode acceptEdits`, Mistral `--agent accept-edits`, and Gemini
47
+ prompted `default` (the `agy` CLI has no accept-edits rung, so without the
48
+ opt-in Gemini cannot auto-approve mutating tools under `mcp_managed`). Each
49
+ escalates to its full auto-approve mode only when the operator sets
50
+ `LLM_GATEWAY_APPROVAL_ALLOW_BYPASS`. The `legacy` strategy is unchanged.
51
+
52
+ ## [2.8.0] - 2026-06-14: HTTP workspace gating and ACP transport
53
+
54
+ ### Added
55
+
56
+ - Added ACP gateway extension foundations, including the contract, config and
57
+ capability surface, and transport core.
58
+ - Added provider CLI release-target evidence for release validation: exact
59
+ probed Claude Code, Codex, Antigravity (`agy`), Grok, and Mistral Vibe
60
+ versions plus artifact SHA-256 values.
61
+ - Added Gemini, Grok, and Mistral pricing families and Codex token/cache usage
62
+ telemetry extraction.
63
+
64
+ ### Changed
65
+
66
+ - HTTP and tunnel provider execution now uses explicit request transport
67
+ metadata and requires a registered workspace, default workspace, or session
68
+ workspace before spawning a provider across sync/async request tools and
69
+ `codex_fork_session`. Local stdio keeps the prior unrestricted
70
+ `workingDir`/`addDir` behavior.
71
+ - Replaced the OAuth-only remote workspace guard with transport-based HTTP
72
+ workspace gating, so bearer, OAuth, auth-disabled, and configured no-auth
73
+ connector HTTP paths all fail closed before provider spawn.
74
+ - Replaced CodeQL with the OSS SAST workflow using OpenGrep, gosec, and
75
+ govulncheck.
76
+
77
+ ### Fixed
78
+
79
+ - Corrected Claude default pricing/cache classification and Grok API pricing
80
+ bucket handling.
81
+ - Preserved captured async orphan readbacks as completed flight-recorder rows
82
+ when stdout was captured and no failure was recorded.
83
+
84
+ ### Documentation
85
+
86
+ - Documented Grok CLI prompt-surface telemetry limitations.
87
+ - Documented ACP gateway extension research, design, and smoke validation.
88
+
7
89
  ## [2.7.0] - 2026-06-12: Provider capability inventory
8
90
 
9
91
  ### Added
package/README.md CHANGED
@@ -45,7 +45,7 @@ Or use directly with `npx` from an MCP client:
45
45
  - Can run requests inside gateway-managed git worktrees for isolated multi-agent review and implementation loops.
46
46
  - Ships personal-appliance setup surfaces: HTTP transport with bearer-token auth, `doctor --json`, setup UI artifacts, provider setup snippets, Docker fallback, and checked release bundles.
47
47
  - Remote web connectors use MCP OAuth discovery and authorization-code setup with static client or shared-secret gates. Client secrets are generated locally, stored only as hashes, and printed only by explicit copy-once commands.
48
- - Provider CLI requests can select registered workspaces by alias via `workspace`; remote requests should use aliases, not arbitrary filesystem paths. New local folder/Git workspaces can be created only under configured allowed roots.
48
+ - Provider CLI requests can select registered workspaces by alias via `workspace`; every HTTP/tunnel request must use a registered alias, session workspace, or `[workspaces].default` before provider execution. Local unrestricted filesystem access is the stdio transport.
49
49
 
50
50
  ## Workflow Assets
51
51
 
@@ -337,6 +337,8 @@ For clients that already support local stdio MCP servers, add a configuration li
337
337
  }
338
338
  ```
339
339
 
340
+ Stdio is the recommended path for unrestricted machine-local development access. HTTP MCP, including localhost HTTP and tunneled HTTPS, is treated as remote-capable for provider execution: provider tools must resolve a registered workspace alias, a session workspace, or `[workspaces].default` before spawning a CLI. Remote clients should pass relative `workingDir`, `addDir`, and include-directory values inside the selected workspace; disabling auth or using a no-auth connector path is not a filesystem bypass.
341
+
340
342
  This generic stdio example is not provider-support verification for the Personal MCP Appliance. Client-specific setup guides for ChatGPT, Claude web, Claude Desktop, Codex, Gemini CLI, Gemini web, and Grok remain gated by the provider-support matrix in [docs/personal-mcp/PRODUCT_CONTRACT.md](docs/personal-mcp/PRODUCT_CONTRACT.md).
341
343
 
342
344
  ### Available Tools
@@ -399,6 +401,8 @@ Execute a Claude Code request with optional session management.
399
401
  - `promptParts` (object, optional): Cache-aware structured prompt `{ system?, tools?, context?, task }`; mutually exclusive with `prompt`
400
402
  - `forceRefresh` (boolean, optional): Bypass dedup and force a fresh CLI run, default: false
401
403
 
404
+ Workspace boundary: stdio callers may use machine-local paths directly. HTTP/tunnel callers must pass `workspace` or rely on a configured default/session workspace; path fields are then validated relative to that workspace. `[workspaces].allow_unregistered_working_dir` is a stdio/local legacy setting and does not allow arbitrary HTTP working directories or additional directories.
405
+
402
406
  **Response extras:**
403
407
 
404
408
  - `approval`: Approval decision record when `approvalStrategy="mcp_managed"`
@@ -1156,6 +1160,24 @@ await callTool("session_delete", {
1156
1160
  ```bash
1157
1161
  LLM_GATEWAY_APPROVAL_POLICY=strict node dist/index.js
1158
1162
  ```
1163
+ - `LLM_GATEWAY_APPROVAL_ALLOW_BYPASS`: Under `approvalStrategy:"mcp_managed"`, a full permission / sandbox bypass request (e.g. `dangerouslyBypassApprovalsAndSandbox`, `dangerouslySkipPermissions`) is **denied by default** regardless of approval score, **and** `mcp_managed` no longer force-bypasses any provider — each defaults to an auto-accept-edits-level mode (auto-accept file edits, still gate Bash and other dangerous tools) instead of full auto-approve:
1164
+ - **Claude** → `--permission-mode acceptEdits` (was `bypassPermissions`)
1165
+ - **Grok** → `--permission-mode acceptEdits` (was `--always-approve`)
1166
+ - **Mistral (Vibe)** → `--agent accept-edits` (was `--agent auto-approve`)
1167
+ - **Gemini (Antigravity)** → `default` / prompted, i.e. **no** `--dangerously-skip-permissions` (the `agy` CLI has no accept-edits middle rung, so the safe default is prompted execution; without the opt-in, Gemini cannot auto-approve mutating tools under `mcp_managed`)
1168
+
1169
+ Set to `1`/`true` to let the operator opt back in: this permits bypass requests through the approval gate **and** restores each provider's full auto-approve mode under `mcp_managed` (Claude `bypassPermissions`, Grok `--always-approve`, Mistral `--agent auto-approve`, Gemini `--dangerously-skip-permissions`). Sandboxed auto modes (e.g. codex `--sandbox workspace-write`) are unaffected.
1170
+ ```bash
1171
+ LLM_GATEWAY_APPROVAL_ALLOW_BYPASS=1 node dist/index.js
1172
+ ```
1173
+ - `LLM_GATEWAY_TRUSTED_PRINCIPAL_HEADER`: Name of an HTTP header carrying the authenticated user identity asserted by a **trusted front door** (any identity-aware reverse proxy / IdP). When set, the gateway adopts that header value as the request's ownership principal — but **only** for requests authenticated with the gateway's own static bearer token (i.e. the trusted upstream proxy), never from an arbitrary remote client. Off by default; IdP-agnostic. Lets a proxy-fronted multi-user deployment carry per-user identity into the gateway.
1174
+ ```bash
1175
+ LLM_GATEWAY_TRUSTED_PRINCIPAL_HEADER=x-gateway-principal node dist/index.js
1176
+ ```
1177
+ - `LLM_GATEWAY_OAUTH_REQUIRE_CONSENT` / `LLM_GATEWAY_OAUTH_CONSENT_SECRET`: Opt-in human-consent gate for the built-in OAuth server. When enabled (`REQUIRE_CONSENT=1`, or implied by setting `CONSENT_SECRET`), `/oauth/authorize` renders an operator approval page (CSRF-protected) and issues an authorization code **only** after the dedicated consent password is entered — instead of auto-issuing. `CONSENT_SECRET` is the plaintext password (hashed in memory; or persist a `consent_secret_hash` in `[http.oauth]`). Off by default; remote OAuth refuses to enable consent without a secret to verify.
1178
+ ```bash
1179
+ LLM_GATEWAY_OAUTH_REQUIRE_CONSENT=1 LLM_GATEWAY_OAUTH_CONSENT_SECRET='choose-a-strong-code' node dist/index.js
1180
+ ```
1159
1181
  - `LLM_GATEWAY_CONFIG`: Path to the gateway TOML config (default: `~/.llm-cli-gateway/config.toml`). See **Persistence configuration** above for the `[persistence]` schema.
1160
1182
  - `LLM_GATEWAY_LOGS_DB`: **Deprecated** — overrides `[persistence].path` and selects `backend = "sqlite"` (or `backend = "none"` when set to `none`). Emits a deprecation warning at startup; migrate to `config.toml`.
1161
1183
  ```bash
@@ -1164,6 +1186,11 @@ await callTool("session_delete", {
1164
1186
  # Disable durable persistence (also disables *_request_async tools)
1165
1187
  LLM_GATEWAY_LOGS_DB=none node dist/index.js
1166
1188
  ```
1189
+ - `LLM_GATEWAY_REDACT_LOGGED_SECRETS`: Redact recognisable secrets (provider/cloud/VCS keys, bearer tokens, JWTs, PEM private keys, `key=value` secret assignments) from the prompt/system/response copies written to the flight-recorder log. **Enabled by default**; set to `0`/`false`/`off`/`no` to store content verbatim. Only the audit log is affected — live sync responses and async `llm_job_result` output are never altered.
1190
+ ```bash
1191
+ # Opt out of flight-recorder secret redaction
1192
+ LLM_GATEWAY_REDACT_LOGGED_SECRETS=0 node dist/index.js
1193
+ ```
1167
1194
 
1168
1195
  ### CLI-Specific Settings
1169
1196
 
@@ -0,0 +1,78 @@
1
+ import { AcpError } from "./errors.js";
2
+ import type { JsonRpcStdioTransport, JsonRpcId } from "./json-rpc-stdio.js";
3
+ import { type ContentBlock, type InitializeResponse, type ReadTextFileRequest, type ReadTextFileResponse, type RequestPermissionRequest, type RequestPermissionResponse, type SessionLoadResponse, type SessionNewResponse, type SessionPromptResponse, type SessionUpdateNotification, type WriteTextFileRequest, type WriteTextFileResponse } from "./types.js";
4
+ import type { Logger } from "../logger.js";
5
+ import type { CliType } from "../session-manager.js";
6
+ export declare const DEFAULT_ACP_PROTOCOL_VERSION = 1;
7
+ export interface HostServices {
8
+ readTextFile?(request: ReadTextFileRequest, context: HostCallbackContext): Promise<ReadTextFileResponse>;
9
+ writeTextFile?(request: WriteTextFileRequest, context: HostCallbackContext): Promise<WriteTextFileResponse>;
10
+ requestPermission?(request: RequestPermissionRequest, context: HostCallbackContext): Promise<RequestPermissionResponse>;
11
+ }
12
+ export interface HostCallbackContext {
13
+ readonly provider: CliType;
14
+ readonly method: string;
15
+ }
16
+ export interface AcpClientCallbacks {
17
+ readonly onSessionUpdate?: (update: SessionUpdateNotification) => void;
18
+ readonly onProcessExit?: (error: AcpError) => void;
19
+ }
20
+ export interface AcpClientOptions {
21
+ readonly transport: JsonRpcStdioTransport;
22
+ readonly provider: CliType;
23
+ readonly hostServices: HostServices;
24
+ readonly callbacks?: AcpClientCallbacks;
25
+ readonly logger?: Logger;
26
+ readonly protocolVersion?: number;
27
+ readonly timeouts?: AcpClientTimeouts;
28
+ }
29
+ export interface AcpClientTimeouts {
30
+ readonly initializeMs?: number;
31
+ readonly sessionNewMs?: number;
32
+ readonly sessionLoadMs?: number;
33
+ readonly promptMs?: number;
34
+ }
35
+ export interface InitializeOptions {
36
+ readonly readTextFile?: boolean;
37
+ readonly writeTextFile?: boolean;
38
+ readonly terminal?: boolean;
39
+ }
40
+ export interface NewSessionParams {
41
+ readonly cwd: string;
42
+ readonly mcpServers?: ReadonlyArray<Record<string, unknown>>;
43
+ }
44
+ export interface LoadSessionParams extends NewSessionParams {
45
+ readonly sessionId: string;
46
+ }
47
+ export interface PromptParams {
48
+ readonly sessionId: string;
49
+ readonly prompt: ReadonlyArray<ContentBlock>;
50
+ }
51
+ export declare class AcpClient {
52
+ private readonly transport;
53
+ private readonly provider;
54
+ private readonly hostServices;
55
+ private readonly callbacks?;
56
+ private readonly logger;
57
+ private readonly protocolVersion;
58
+ private readonly timeouts;
59
+ private initialized;
60
+ private initializeResult;
61
+ constructor(options: AcpClientOptions);
62
+ get isInitialized(): boolean;
63
+ get agentInfo(): InitializeResponse | null;
64
+ initialize(options?: InitializeOptions): Promise<InitializeResponse>;
65
+ newSession(params: NewSessionParams): Promise<SessionNewResponse>;
66
+ loadSession(params: LoadSessionParams): Promise<SessionLoadResponse>;
67
+ prompt(params: PromptParams): Promise<SessionPromptResponse>;
68
+ cancel(sessionId: string): void;
69
+ private send;
70
+ private normalizeError;
71
+ private assertInitialized;
72
+ handleNotification(method: string, params: unknown): void;
73
+ private handleSessionUpdate;
74
+ handleRequest(id: JsonRpcId, method: string, params: unknown): void;
75
+ private dispatchRequest;
76
+ private requireHandler;
77
+ notifyProcessExit(error: AcpError): void;
78
+ }
@@ -0,0 +1,201 @@
1
+ import { AcpProtocolError, isAcpError } from "./errors.js";
2
+ import { parseInitializeResponse, parseReadTextFileRequest, parseRequestPermissionRequest, parseSessionLoadResponse, parseSessionNewResponse, parseSessionPromptResponse, parseSessionUpdateNotification, parseWriteTextFileRequest, } from "./types.js";
3
+ import { noopLogger } from "../logger.js";
4
+ export const DEFAULT_ACP_PROTOCOL_VERSION = 1;
5
+ export class AcpClient {
6
+ transport;
7
+ provider;
8
+ hostServices;
9
+ callbacks;
10
+ logger;
11
+ protocolVersion;
12
+ timeouts;
13
+ initialized = false;
14
+ initializeResult = null;
15
+ constructor(options) {
16
+ this.transport = options.transport;
17
+ this.provider = options.provider;
18
+ this.hostServices = options.hostServices;
19
+ this.callbacks = options.callbacks;
20
+ this.logger = options.logger ?? noopLogger;
21
+ this.protocolVersion = options.protocolVersion ?? DEFAULT_ACP_PROTOCOL_VERSION;
22
+ this.timeouts = options.timeouts ?? {};
23
+ }
24
+ get isInitialized() {
25
+ return this.initialized;
26
+ }
27
+ get agentInfo() {
28
+ return this.initializeResult;
29
+ }
30
+ async initialize(options = {}) {
31
+ if (this.initialized && this.initializeResult) {
32
+ return this.initializeResult;
33
+ }
34
+ const params = {
35
+ protocolVersion: this.protocolVersion,
36
+ clientCapabilities: {
37
+ fs: {
38
+ readTextFile: options.readTextFile ?? false,
39
+ writeTextFile: options.writeTextFile ?? false,
40
+ },
41
+ terminal: options.terminal ?? false,
42
+ },
43
+ };
44
+ const raw = await this.send("initialize", params, this.timeouts.initializeMs);
45
+ const result = parseInitializeResponse(raw, this.provider);
46
+ this.initialized = true;
47
+ this.initializeResult = result;
48
+ return result;
49
+ }
50
+ async newSession(params) {
51
+ this.assertInitialized("session/new");
52
+ const raw = await this.send("session/new", { cwd: params.cwd, mcpServers: params.mcpServers ?? [] }, this.timeouts.sessionNewMs);
53
+ return parseSessionNewResponse(raw, this.provider);
54
+ }
55
+ async loadSession(params) {
56
+ this.assertInitialized("session/load");
57
+ const raw = await this.send("session/load", {
58
+ sessionId: params.sessionId,
59
+ cwd: params.cwd,
60
+ mcpServers: params.mcpServers ?? [],
61
+ }, this.timeouts.sessionLoadMs);
62
+ return parseSessionLoadResponse(raw, this.provider);
63
+ }
64
+ async prompt(params) {
65
+ this.assertInitialized("session/prompt");
66
+ const raw = await this.send("session/prompt", { sessionId: params.sessionId, prompt: params.prompt }, this.timeouts.promptMs);
67
+ return parseSessionPromptResponse(raw, this.provider);
68
+ }
69
+ cancel(sessionId) {
70
+ this.transport.notify("session/cancel", { sessionId });
71
+ }
72
+ async send(method, params, timeoutMs) {
73
+ try {
74
+ return await this.transport.request(method, params, timeoutMs);
75
+ }
76
+ catch (err) {
77
+ throw this.normalizeError(method, err);
78
+ }
79
+ }
80
+ normalizeError(method, err) {
81
+ if (isAcpError(err)) {
82
+ return err;
83
+ }
84
+ return new AcpProtocolError(`ACP request ${method} failed unexpectedly.`, {
85
+ provider: this.provider,
86
+ debug: { method, errorClass: err instanceof Error ? err.name : "unknown" },
87
+ });
88
+ }
89
+ assertInitialized(method) {
90
+ if (!this.initialized) {
91
+ throw new AcpProtocolError(`ACP ${method} requires initialize to complete first.`, {
92
+ provider: this.provider,
93
+ debug: { method, reason: "not_initialized" },
94
+ });
95
+ }
96
+ }
97
+ handleNotification(method, params) {
98
+ if (method === "session/update") {
99
+ this.handleSessionUpdate(params);
100
+ return;
101
+ }
102
+ this.logger.debug("acp.client.unknown_notification", {
103
+ provider: this.provider,
104
+ method,
105
+ });
106
+ }
107
+ handleSessionUpdate(params) {
108
+ let parsed;
109
+ try {
110
+ parsed = parseSessionUpdateNotification(params, this.provider);
111
+ }
112
+ catch (err) {
113
+ this.logger.error("acp.client.session_update.parse_error", {
114
+ provider: this.provider,
115
+ errorClass: err instanceof Error ? err.name : "unknown",
116
+ });
117
+ return;
118
+ }
119
+ try {
120
+ this.callbacks?.onSessionUpdate?.(parsed);
121
+ }
122
+ catch (err) {
123
+ this.logger.error("acp.client.session_update.handler_error", {
124
+ provider: this.provider,
125
+ errorClass: err instanceof Error ? err.name : "unknown",
126
+ });
127
+ }
128
+ }
129
+ handleRequest(id, method, params) {
130
+ void this.dispatchRequest(id, method, params);
131
+ }
132
+ async dispatchRequest(id, method, params) {
133
+ const context = { provider: this.provider, method };
134
+ try {
135
+ switch (method) {
136
+ case "fs/read_text_file": {
137
+ const request = parseReadTextFileRequest(params, this.provider);
138
+ const result = await this.requireHandler(this.hostServices.readTextFile, method)(request, context);
139
+ this.transport.respond(id, result);
140
+ return;
141
+ }
142
+ case "fs/write_text_file": {
143
+ const request = parseWriteTextFileRequest(params, this.provider);
144
+ const result = await this.requireHandler(this.hostServices.writeTextFile, method)(request, context);
145
+ this.transport.respond(id, result);
146
+ return;
147
+ }
148
+ case "session/request_permission": {
149
+ const request = parseRequestPermissionRequest(params, this.provider);
150
+ const result = await this.requireHandler(this.hostServices.requestPermission, method)(request, context);
151
+ this.transport.respond(id, result);
152
+ return;
153
+ }
154
+ default: {
155
+ this.logger.debug("acp.client.unknown_host_request", {
156
+ provider: this.provider,
157
+ method,
158
+ });
159
+ this.transport.respondError(id, {
160
+ code: -32601,
161
+ message: "Method not found",
162
+ });
163
+ return;
164
+ }
165
+ }
166
+ }
167
+ catch (err) {
168
+ const acpError = isAcpError(err) ? err : this.normalizeError(method, err);
169
+ this.logger.error("acp.client.host_request.failed", {
170
+ provider: this.provider,
171
+ method,
172
+ errorClass: acpError.name,
173
+ kind: acpError.kind,
174
+ });
175
+ this.transport.respondError(id, {
176
+ code: -32000,
177
+ message: acpError.userMessage,
178
+ });
179
+ }
180
+ }
181
+ requireHandler(handler, method) {
182
+ if (!handler) {
183
+ throw new AcpProtocolError(`Host does not support ACP ${method}.`, {
184
+ provider: this.provider,
185
+ debug: { method, reason: "unsupported_host_method" },
186
+ });
187
+ }
188
+ return handler.bind(this.hostServices);
189
+ }
190
+ notifyProcessExit(error) {
191
+ try {
192
+ this.callbacks?.onProcessExit?.(error);
193
+ }
194
+ catch (err) {
195
+ this.logger.error("acp.client.process_exit.handler_error", {
196
+ provider: this.provider,
197
+ errorClass: err instanceof Error ? err.name : "unknown",
198
+ });
199
+ }
200
+ }
201
+ }
@@ -0,0 +1,63 @@
1
+ import type { CliType } from "../session-manager.js";
2
+ export type AcpErrorKind = "acp_disabled" | "provider_acp_disabled" | "provider_acp_unsupported" | "provider_runtime_disabled" | "provider_unavailable" | "protocol" | "timeout" | "permission_denied" | "process_exit";
3
+ export declare function redactAcpMessage(input: string): string;
4
+ export declare function redactAcpDebug(value: unknown): unknown;
5
+ export declare function redactAcpCause(cause: unknown): unknown;
6
+ export interface AcpErrorDebug {
7
+ readonly [key: string]: unknown;
8
+ }
9
+ export declare class AcpError extends Error {
10
+ readonly kind: AcpErrorKind;
11
+ readonly provider?: CliType;
12
+ readonly debug: AcpErrorDebug;
13
+ constructor(kind: AcpErrorKind, userMessage: string, options?: {
14
+ provider?: CliType;
15
+ debug?: AcpErrorDebug;
16
+ cause?: unknown;
17
+ });
18
+ get userMessage(): string;
19
+ }
20
+ export declare class AcpDisabledError extends AcpError {
21
+ constructor(debug?: AcpErrorDebug);
22
+ }
23
+ export declare class ProviderAcpDisabledError extends AcpError {
24
+ constructor(provider: CliType, debug?: AcpErrorDebug);
25
+ }
26
+ export declare class ProviderAcpUnsupportedError extends AcpError {
27
+ constructor(provider: CliType, debug?: AcpErrorDebug);
28
+ }
29
+ export declare class ProviderRuntimeDisabledError extends AcpError {
30
+ constructor(provider: CliType, debug?: AcpErrorDebug);
31
+ }
32
+ export declare class ProviderUnavailableError extends AcpError {
33
+ constructor(provider: CliType, reason: string, debug?: AcpErrorDebug);
34
+ }
35
+ export declare class AcpProtocolError extends AcpError {
36
+ readonly code?: number;
37
+ constructor(userMessage: string, options?: {
38
+ provider?: CliType;
39
+ code?: number;
40
+ debug?: AcpErrorDebug;
41
+ });
42
+ }
43
+ export declare class AcpTimeoutError extends AcpError {
44
+ readonly method: string;
45
+ readonly timeoutMs: number;
46
+ constructor(method: string, timeoutMs: number, options?: {
47
+ provider?: CliType;
48
+ debug?: AcpErrorDebug;
49
+ });
50
+ }
51
+ export declare class AcpPermissionDeniedError extends AcpError {
52
+ constructor(provider: CliType, reason: string, debug?: AcpErrorDebug);
53
+ }
54
+ export declare class AcpProcessExitError extends AcpError {
55
+ readonly exitCode: number | null;
56
+ readonly signal: string | null;
57
+ constructor(provider: CliType, options?: {
58
+ exitCode?: number | null;
59
+ signal?: string | null;
60
+ debug?: AcpErrorDebug;
61
+ });
62
+ }
63
+ export declare function isAcpError(value: unknown): value is AcpError;
@@ -0,0 +1,139 @@
1
+ export function redactAcpMessage(input) {
2
+ let out = input;
3
+ out = out.replace(/\{[\s\S]*?\}/g, "<redacted-json>");
4
+ out = out.replace(/\[[\s\S]*?\]/g, "<redacted-json>");
5
+ out = out.replace(/\b(bearer|token|api[_-]?key|secret)\b\s*[:=]?\s*\S+/gi, "$1 <redacted>");
6
+ out = out.replace(/\b(sk|xai|gsk|key)-[A-Za-z0-9_-]{8,}\b/gi, "<redacted-token>");
7
+ out = out.replace(/(^|[^A-Za-z0-9._~\\/-])[A-Za-z]:\\[^\s"')\]}>]*/g, "$1<redacted-path>");
8
+ out = out.replace(/(^|[^A-Za-z0-9._~\\/-])\\\\[^\s"')\]}>]+/g, "$1<redacted-path>");
9
+ out = out.replace(/(^|[^A-Za-z0-9._~/-])~\/[^\s"')\]}>]+/g, "$1<redacted-path>");
10
+ out = out.replace(/(^|[^A-Za-z0-9._~/-])\/[A-Za-z0-9._/-]{2,}/g, "$1<redacted-path>");
11
+ out = out.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "<redacted-email>");
12
+ return out;
13
+ }
14
+ export function redactAcpDebug(value) {
15
+ const sensitiveKey = /(payload|body|prompt|content|token|secret|api[_-]?key|credential|auth|cwd|path)/i;
16
+ if (typeof value === "string") {
17
+ return redactAcpMessage(value);
18
+ }
19
+ if (Array.isArray(value)) {
20
+ return value.map(item => redactAcpDebug(item));
21
+ }
22
+ if (value !== null && typeof value === "object") {
23
+ const out = {};
24
+ for (const [key, inner] of Object.entries(value)) {
25
+ if (sensitiveKey.test(key)) {
26
+ out[key] = "<redacted>";
27
+ continue;
28
+ }
29
+ out[key] = redactAcpDebug(inner);
30
+ }
31
+ return out;
32
+ }
33
+ return value;
34
+ }
35
+ export function redactAcpCause(cause) {
36
+ if (cause instanceof Error) {
37
+ const redacted = new Error(redactAcpMessage(cause.message));
38
+ redacted.name = redactAcpMessage(cause.name);
39
+ if (typeof cause.stack === "string") {
40
+ redacted.stack = redactAcpMessage(cause.stack);
41
+ }
42
+ return redacted;
43
+ }
44
+ return redactAcpDebug(cause);
45
+ }
46
+ export class AcpError extends Error {
47
+ kind;
48
+ provider;
49
+ debug;
50
+ constructor(kind, userMessage, options) {
51
+ super(redactAcpMessage(userMessage));
52
+ this.name = "AcpError";
53
+ this.kind = kind;
54
+ this.provider = options?.provider;
55
+ this.debug = redactAcpDebug(options?.debug ?? {}) ?? {};
56
+ if (options?.cause !== undefined) {
57
+ this.cause = redactAcpCause(options.cause);
58
+ }
59
+ }
60
+ get userMessage() {
61
+ return this.message;
62
+ }
63
+ }
64
+ export class AcpDisabledError extends AcpError {
65
+ constructor(debug) {
66
+ super("acp_disabled", "ACP transport is disabled. Enable [acp] in the gateway config to use transport=acp, or omit transport to use the default CLI path.", { debug });
67
+ this.name = "AcpDisabledError";
68
+ }
69
+ }
70
+ export class ProviderAcpDisabledError extends AcpError {
71
+ constructor(provider, debug) {
72
+ super("provider_acp_disabled", `ACP is disabled for provider ${provider}. Enable it under [acp.providers.${provider}] or omit transport to use the default CLI path.`, { provider, debug });
73
+ this.name = "ProviderAcpDisabledError";
74
+ }
75
+ }
76
+ export class ProviderAcpUnsupportedError extends AcpError {
77
+ constructor(provider, debug) {
78
+ super("provider_acp_unsupported", `Provider ${provider} has no native ACP support at its target version. Use the default CLI transport for this provider.`, { provider, debug });
79
+ this.name = "ProviderAcpUnsupportedError";
80
+ }
81
+ }
82
+ export class ProviderRuntimeDisabledError extends AcpError {
83
+ constructor(provider, debug) {
84
+ super("provider_runtime_disabled", `ACP runtime routing is not enabled for provider ${provider}. Set runtime_enabled=true under [acp.providers.${provider}] to allow prompt routing.`, { provider, debug });
85
+ this.name = "ProviderRuntimeDisabledError";
86
+ }
87
+ }
88
+ export class ProviderUnavailableError extends AcpError {
89
+ constructor(provider, reason, debug) {
90
+ super("provider_unavailable", `Provider ${provider} ACP entrypoint is unavailable: ${reason}`, {
91
+ provider,
92
+ debug,
93
+ });
94
+ this.name = "ProviderUnavailableError";
95
+ }
96
+ }
97
+ export class AcpProtocolError extends AcpError {
98
+ code;
99
+ constructor(userMessage, options) {
100
+ super("protocol", userMessage, { provider: options?.provider, debug: options?.debug });
101
+ this.name = "AcpProtocolError";
102
+ this.code = options?.code;
103
+ }
104
+ }
105
+ export class AcpTimeoutError extends AcpError {
106
+ method;
107
+ timeoutMs;
108
+ constructor(method, timeoutMs, options) {
109
+ super("timeout", `ACP request ${method} timed out after ${timeoutMs}ms.`, {
110
+ provider: options?.provider,
111
+ debug: options?.debug,
112
+ });
113
+ this.name = "AcpTimeoutError";
114
+ this.method = method;
115
+ this.timeoutMs = timeoutMs;
116
+ }
117
+ }
118
+ export class AcpPermissionDeniedError extends AcpError {
119
+ constructor(provider, reason, debug) {
120
+ super("permission_denied", `ACP permission request was denied: ${reason}`, { provider, debug });
121
+ this.name = "AcpPermissionDeniedError";
122
+ }
123
+ }
124
+ export class AcpProcessExitError extends AcpError {
125
+ exitCode;
126
+ signal;
127
+ constructor(provider, options) {
128
+ super("process_exit", `Provider ${provider} ACP process exited before the request completed.`, {
129
+ provider,
130
+ debug: options?.debug,
131
+ });
132
+ this.name = "AcpProcessExitError";
133
+ this.exitCode = options?.exitCode ?? null;
134
+ this.signal = options?.signal ?? null;
135
+ }
136
+ }
137
+ export function isAcpError(value) {
138
+ return value instanceof AcpError;
139
+ }