@cjhyy/code-shell-capability-coding 0.8.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 (134) hide show
  1. package/README.md +69 -0
  2. package/THIRD_PARTY_NOTICES.md +206 -0
  3. package/dist/bin/agent-server-stdio.d.ts +1 -0
  4. package/dist/bin/agent-server-stdio.js +4 -0
  5. package/dist/capability-runtime.d.ts +22 -0
  6. package/dist/capability-runtime.js +88 -0
  7. package/dist/cc-orchestrator/agent-adapter.d.ts +55 -0
  8. package/dist/cc-orchestrator/agent-adapter.js +143 -0
  9. package/dist/cc-orchestrator/cc-capability.d.ts +20 -0
  10. package/dist/cc-orchestrator/cc-capability.js +57 -0
  11. package/dist/cc-orchestrator/codex-session-discovery.d.ts +45 -0
  12. package/dist/cc-orchestrator/codex-session-discovery.js +251 -0
  13. package/dist/cc-orchestrator/codex-session-history.d.ts +39 -0
  14. package/dist/cc-orchestrator/codex-session-history.js +249 -0
  15. package/dist/cc-orchestrator/codex-user-text.d.ts +9 -0
  16. package/dist/cc-orchestrator/codex-user-text.js +25 -0
  17. package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
  18. package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
  19. package/dist/cc-orchestrator/external-agent-changes.d.ts +19 -0
  20. package/dist/cc-orchestrator/external-agent-changes.js +231 -0
  21. package/dist/cc-orchestrator/external-agent-driver.d.ts +19 -0
  22. package/dist/cc-orchestrator/external-agent-driver.js +284 -0
  23. package/dist/cc-orchestrator/external-agent-session-store.d.ts +36 -0
  24. package/dist/cc-orchestrator/external-agent-session-store.js +185 -0
  25. package/dist/cc-orchestrator/index.d.ts +10 -0
  26. package/dist/cc-orchestrator/index.js +10 -0
  27. package/dist/cc-orchestrator/related-session-discovery.d.ts +21 -0
  28. package/dist/cc-orchestrator/related-session-discovery.js +77 -0
  29. package/dist/cc-orchestrator/relevance-judge.d.ts +16 -0
  30. package/dist/cc-orchestrator/relevance-judge.js +38 -0
  31. package/dist/cc-orchestrator/session-discovery.d.ts +99 -0
  32. package/dist/cc-orchestrator/session-discovery.js +295 -0
  33. package/dist/cc-orchestrator/session-history.d.ts +55 -0
  34. package/dist/cc-orchestrator/session-history.js +150 -0
  35. package/dist/external-agents/config.d.ts +3 -0
  36. package/dist/external-agents/config.js +16 -0
  37. package/dist/external-agents/types.d.ts +32 -0
  38. package/dist/external-agents/types.js +1 -0
  39. package/dist/external-runtimes/claude-code/event-translator.d.ts +54 -0
  40. package/dist/external-runtimes/claude-code/event-translator.js +201 -0
  41. package/dist/external-runtimes/claude-code/mcp-config.d.ts +84 -0
  42. package/dist/external-runtimes/claude-code/mcp-config.js +175 -0
  43. package/dist/external-runtimes/claude-code/runtime.d.ts +56 -0
  44. package/dist/external-runtimes/claude-code/runtime.js +168 -0
  45. package/dist/external-runtimes/codex/app-server-client.d.ts +66 -0
  46. package/dist/external-runtimes/codex/app-server-client.js +244 -0
  47. package/dist/external-runtimes/codex/event-translator.d.ts +68 -0
  48. package/dist/external-runtimes/codex/event-translator.js +187 -0
  49. package/dist/external-runtimes/codex/index.d.ts +7 -0
  50. package/dist/external-runtimes/codex/index.js +6 -0
  51. package/dist/external-runtimes/codex/runtime.d.ts +128 -0
  52. package/dist/external-runtimes/codex/runtime.js +216 -0
  53. package/dist/external-runtimes/index.d.ts +37 -0
  54. package/dist/external-runtimes/index.js +27 -0
  55. package/dist/external-runtimes/session-factory.d.ts +97 -0
  56. package/dist/external-runtimes/session-factory.js +110 -0
  57. package/dist/external-runtimes/shared/mcp-bridge.d.ts +66 -0
  58. package/dist/external-runtimes/shared/mcp-bridge.js +365 -0
  59. package/dist/external-runtimes/shared/session-context-store.d.ts +83 -0
  60. package/dist/external-runtimes/shared/session-context-store.js +114 -0
  61. package/dist/external-runtimes/shared/spawn-env.d.ts +30 -0
  62. package/dist/external-runtimes/shared/spawn-env.js +41 -0
  63. package/dist/git/parse-log.d.ts +14 -0
  64. package/dist/git/parse-log.js +21 -0
  65. package/dist/git/utils.d.ts +49 -0
  66. package/dist/git/utils.js +161 -0
  67. package/dist/git/worktree/crud.d.ts +108 -0
  68. package/dist/git/worktree/crud.js +415 -0
  69. package/dist/git/worktree/diff.d.ts +14 -0
  70. package/dist/git/worktree/diff.js +83 -0
  71. package/dist/git/worktree/git-exec.d.ts +7 -0
  72. package/dist/git/worktree/git-exec.js +86 -0
  73. package/dist/git/worktree/index.d.ts +6 -0
  74. package/dist/git/worktree/index.js +6 -0
  75. package/dist/git/worktree/query.d.ts +43 -0
  76. package/dist/git/worktree/query.js +128 -0
  77. package/dist/git/worktree/slug.d.ts +12 -0
  78. package/dist/git/worktree/slug.js +59 -0
  79. package/dist/git/worktree.d.ts +1 -0
  80. package/dist/git/worktree.js +5 -0
  81. package/dist/index.capability.d.ts +7 -0
  82. package/dist/index.capability.js +190 -0
  83. package/dist/index.d.ts +18 -0
  84. package/dist/index.git.d.ts +4 -0
  85. package/dist/index.git.js +4 -0
  86. package/dist/index.js +18 -0
  87. package/dist/index.orchestration.d.ts +7 -0
  88. package/dist/index.orchestration.js +5 -0
  89. package/dist/lsp/client.d.ts +41 -0
  90. package/dist/lsp/client.js +192 -0
  91. package/dist/lsp/manager.d.ts +53 -0
  92. package/dist/lsp/manager.js +248 -0
  93. package/dist/lsp/root-path.d.ts +9 -0
  94. package/dist/lsp/root-path.js +12 -0
  95. package/dist/lsp/servers.d.ts +16 -0
  96. package/dist/lsp/servers.js +60 -0
  97. package/dist/prompt/coding.md +42 -0
  98. package/dist/quota/credentials.d.ts +3 -0
  99. package/dist/quota/credentials.js +80 -0
  100. package/dist/quota/index.d.ts +36 -0
  101. package/dist/quota/index.js +161 -0
  102. package/dist/quota/types.d.ts +48 -0
  103. package/dist/quota/types.js +13 -0
  104. package/dist/review/review-prompt.d.ts +28 -0
  105. package/dist/review/review-prompt.js +81 -0
  106. package/dist/tools/apply-patch/LICENSE-codex +201 -0
  107. package/dist/tools/apply-patch/NOTICE.md +29 -0
  108. package/dist/tools/apply-patch/applier.d.ts +26 -0
  109. package/dist/tools/apply-patch/applier.js +306 -0
  110. package/dist/tools/apply-patch/backup-targets.d.ts +10 -0
  111. package/dist/tools/apply-patch/backup-targets.js +30 -0
  112. package/dist/tools/apply-patch/eol.d.ts +4 -0
  113. package/dist/tools/apply-patch/eol.js +9 -0
  114. package/dist/tools/apply-patch/index.d.ts +20 -0
  115. package/dist/tools/apply-patch/index.js +106 -0
  116. package/dist/tools/apply-patch/parser.d.ts +17 -0
  117. package/dist/tools/apply-patch/parser.js +209 -0
  118. package/dist/tools/apply-patch/seek-sequence.d.ts +18 -0
  119. package/dist/tools/apply-patch/seek-sequence.js +123 -0
  120. package/dist/tools/apply-patch/types.d.ts +49 -0
  121. package/dist/tools/apply-patch/types.js +13 -0
  122. package/dist/tools/brief.d.ts +6 -0
  123. package/dist/tools/brief.js +41 -0
  124. package/dist/tools/check-quota.d.ts +14 -0
  125. package/dist/tools/check-quota.js +38 -0
  126. package/dist/tools/drive-agent.d.ts +52 -0
  127. package/dist/tools/drive-agent.js +1184 -0
  128. package/dist/tools/lsp.d.ts +6 -0
  129. package/dist/tools/lsp.js +220 -0
  130. package/dist/tools/notebook-edit.d.ts +6 -0
  131. package/dist/tools/notebook-edit.js +127 -0
  132. package/dist/tools/worktree.d.ts +10 -0
  133. package/dist/tools/worktree.js +363 -0
  134. package/package.json +60 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The composition root for an external-runtime session.
3
+ *
4
+ * Until now every piece existed but nothing assembled them, so
5
+ * `createSessionToolHost` had no production caller. That absence is exactly how a
6
+ * real security defect slipped in earlier (an untrusted project could
7
+ * self-authorize, because no caller was passing `projectTrusted`) — an options
8
+ * object with no real caller is an options object nobody has had to get right.
9
+ * This module is that caller.
10
+ *
11
+ * It owns the assembly order, which is not arbitrary:
12
+ *
13
+ * 1. bridge first — it mints the token and port the runtime will be told about;
14
+ * 2. host second — it needs the exposure policy and the permission inputs;
15
+ * 3. register the session in the store BEFORE the runtime starts, so a tool call
16
+ * on the runtime's very first turn can already be routed (§13.1);
17
+ * 4. runtime last.
18
+ *
19
+ * Teardown reverses it, and unregisters before disposing the host so a late
20
+ * request finds nothing rather than a disposed host (§13.4).
21
+ */
22
+ import type { PermissionRule, ToolDefinition } from "@cjhyy/code-shell-core/extension";
23
+ import { createSessionToolHost, type ExternalToolExposurePolicy, type ToolVisibilityInputs } from "@cjhyy/code-shell-core/extension";
24
+ import { type CodexRuntimeHooks, type CodexRuntimeOptions } from "./codex/runtime.js";
25
+ import { type ClaudeRuntimeHooks } from "./claude-code/runtime.js";
26
+ export type ExternalRuntimeKind = "codex" | "claude-code";
27
+ /**
28
+ * Everything the host must decide. Deliberately no defaults for the
29
+ * security-relevant fields: see `CreateSessionToolHostOptions`, where the same
30
+ * rule is enforced and tested. A default here would reintroduce the fail-open
31
+ * shape one layer up.
32
+ */
33
+ export interface ExternalRuntimeSessionOptions {
34
+ kind: ExternalRuntimeKind;
35
+ cwd: string;
36
+ businessSessionId: string;
37
+ /** Registry holding the tools that may be exposed. */
38
+ registry: Parameters<typeof createSessionToolHost>[0]["registry"];
39
+ permissionMode: Parameters<typeof createSessionToolHost>[0]["permissionMode"];
40
+ presetRules: readonly PermissionRule[];
41
+ /** Whether the user has trusted the project at `cwd`. No default — §12.2. */
42
+ projectTrusted: boolean;
43
+ planMode: boolean;
44
+ visibility: ToolVisibilityInputs;
45
+ /**
46
+ * Tool exposure. Defaults to the reviewed first-phase allowlist rather than to
47
+ * "everything", so forgetting it cannot silently widen the surface.
48
+ */
49
+ exposure?: ExternalToolExposurePolicy;
50
+ approvalBackend?: Parameters<typeof createSessionToolHost>[0]["approvalBackend"];
51
+ /** Host seams the tools need (panels, browser, askUser, …). */
52
+ contextOverrides?: Parameters<typeof createSessionToolHost>[0]["contextOverrides"];
53
+ settingsScope?: Parameters<typeof createSessionToolHost>[0]["settingsScope"];
54
+ model?: string;
55
+ /** Codex only. Kebab-case per protocol. */
56
+ sandbox?: string;
57
+ /** Codex only. Kebab-case per protocol. */
58
+ approvalPolicy?: string;
59
+ /** Claude Code only: extra CLI args. */
60
+ claudeExtraArgs?: readonly string[];
61
+ /**
62
+ * Override how the runtime process is launched (Codex: app-server client
63
+ * options; Claude: the `claude` binary). Exists so the assembly can be tested
64
+ * against a fake runtime — a composition root that can only be exercised with a
65
+ * logged-in binary is a composition root nobody tests.
66
+ */
67
+ codexClient?: CodexRuntimeOptions["client"];
68
+ claudeCommand?: string;
69
+ hooks?: CodexRuntimeHooks & ClaudeRuntimeHooks;
70
+ log?: (event: string, data: Record<string, unknown>) => void;
71
+ }
72
+ export interface ExternalRuntimeSession {
73
+ readonly kind: ExternalRuntimeKind;
74
+ readonly businessSessionId: string;
75
+ /** Runtime-side id (Codex thread / Claude session). Resume + routing only. */
76
+ readonly runtimeSessionId: string | undefined;
77
+ /** Tools actually exposed, after the allowlist and visibility guards. */
78
+ listTools(): readonly ToolDefinition[];
79
+ send(text: string): Promise<{
80
+ done: Promise<void>;
81
+ }>;
82
+ interrupt(): Promise<void>;
83
+ /** Reverses the assembly order; safe to call more than once. */
84
+ close(): Promise<void>;
85
+ }
86
+ /**
87
+ * Assemble and start an external-runtime session.
88
+ *
89
+ * The bridge is pinned to this one session for BOTH runtimes. Codex could share
90
+ * one bridge across threads (it sends `_meta.threadId`), but its first
91
+ * `tools/list` arrives before the thread exists and therefore cannot be routed —
92
+ * measured, and when Codex does not re-list, the tool stays invisible for the
93
+ * whole session. Claude Code sends no thread identity at all. One bridge per
94
+ * session makes the port the attribution and removes both problems; §22.7's cost
95
+ * (an extra port per session) is the price.
96
+ */
97
+ export declare function startExternalRuntimeSession(options: ExternalRuntimeSessionOptions): Promise<ExternalRuntimeSession>;
@@ -0,0 +1,110 @@
1
+ import { createSessionToolHost, FIRST_PHASE_EXPOSURE, } from "@cjhyy/code-shell-core/extension";
2
+ import { startLoopbackMcpBridge, } from "./shared/mcp-bridge.js";
3
+ import { SessionContextStore } from "./shared/session-context-store.js";
4
+ import { CodexRuntime } from "./codex/runtime.js";
5
+ import { ClaudeCodeRuntime } from "./claude-code/runtime.js";
6
+ /**
7
+ * Assemble and start an external-runtime session.
8
+ *
9
+ * The bridge is pinned to this one session for BOTH runtimes. Codex could share
10
+ * one bridge across threads (it sends `_meta.threadId`), but its first
11
+ * `tools/list` arrives before the thread exists and therefore cannot be routed —
12
+ * measured, and when Codex does not re-list, the tool stays invisible for the
13
+ * whole session. Claude Code sends no thread identity at all. One bridge per
14
+ * session makes the port the attribution and removes both problems; §22.7's cost
15
+ * (an extra port per session) is the price.
16
+ */
17
+ export async function startExternalRuntimeSession(options) {
18
+ const log = options.log ?? (() => { });
19
+ const exposure = options.exposure ?? FIRST_PHASE_EXPOSURE;
20
+ const pinnedThreadId = `codeshell-${options.businessSessionId}`;
21
+ // 1. Bridge — mints the port and token the runtime is configured with.
22
+ const store = new SessionContextStore();
23
+ const bridge = await startLoopbackMcpBridge({
24
+ store,
25
+ singleSessionThreadId: pinnedThreadId,
26
+ log,
27
+ });
28
+ let host;
29
+ let runtime;
30
+ let closed = false;
31
+ const close = async () => {
32
+ if (closed)
33
+ return;
34
+ closed = true;
35
+ // Unregister BEFORE disposing: a late request must find nothing rather than a
36
+ // disposed host (§13.4).
37
+ store.unregister(pinnedThreadId);
38
+ await runtime?.close();
39
+ await host?.dispose();
40
+ await bridge.close();
41
+ };
42
+ try {
43
+ // 2. Host — the authorization boundary for every tool call the runtime makes.
44
+ host = createSessionToolHost({
45
+ businessSessionId: options.businessSessionId,
46
+ cwd: options.cwd,
47
+ registry: options.registry,
48
+ permissionMode: options.permissionMode,
49
+ presetRules: options.presetRules,
50
+ projectTrusted: options.projectTrusted,
51
+ planMode: options.planMode,
52
+ exposure,
53
+ visibility: options.visibility,
54
+ ...(options.approvalBackend ? { approvalBackend: options.approvalBackend } : {}),
55
+ ...(options.contextOverrides ? { contextOverrides: options.contextOverrides } : {}),
56
+ ...(options.settingsScope ? { settingsScope: options.settingsScope } : {}),
57
+ });
58
+ // 3. Register before the runtime starts, so a tool call on the very first
59
+ // turn is already routable (§13.1).
60
+ store.register(pinnedThreadId, host);
61
+ // 4. Runtime.
62
+ const exposedToolNames = host.listTools().map((definition) => definition.name);
63
+ if (options.kind === "codex") {
64
+ const codex = new CodexRuntime({
65
+ cwd: options.cwd,
66
+ businessSessionId: options.businessSessionId,
67
+ bridge,
68
+ ...(options.model ? { model: options.model } : {}),
69
+ ...(options.sandbox ? { sandbox: options.sandbox } : {}),
70
+ ...(options.approvalPolicy ? { approvalPolicy: options.approvalPolicy } : {}),
71
+ ...(options.codexClient ? { client: options.codexClient } : {}),
72
+ log,
73
+ }, options.hooks ?? {});
74
+ await codex.start();
75
+ runtime = codex;
76
+ }
77
+ else {
78
+ runtime = new ClaudeCodeRuntime({
79
+ cwd: options.cwd,
80
+ businessSessionId: options.businessSessionId,
81
+ bridge,
82
+ exposedToolNames,
83
+ ...(options.model ? { model: options.model } : {}),
84
+ ...(options.claudeExtraArgs ? { extraArgs: options.claudeExtraArgs } : {}),
85
+ ...(options.claudeCommand ? { command: options.claudeCommand } : {}),
86
+ log,
87
+ }, options.hooks ?? {});
88
+ // Claude Code is one process per turn; the first send() is the start.
89
+ }
90
+ }
91
+ catch (error) {
92
+ // A runtime that failed to start must not leave an orphaned bridge holding a
93
+ // port and a live token (§13.1).
94
+ await close();
95
+ throw error;
96
+ }
97
+ const activeHost = host;
98
+ const activeRuntime = runtime;
99
+ return {
100
+ kind: options.kind,
101
+ businessSessionId: options.businessSessionId,
102
+ get runtimeSessionId() {
103
+ return activeRuntime.runtimeSessionId;
104
+ },
105
+ listTools: () => activeHost.listTools(),
106
+ send: (text) => activeRuntime.send(text),
107
+ interrupt: () => activeRuntime.interrupt(),
108
+ close,
109
+ };
110
+ }
@@ -0,0 +1,66 @@
1
+ import type { SessionContextStore } from "./session-context-store.js";
2
+ /** What the bridge needs of a session tool host. Structural, so this module
3
+ * does not depend on core's concrete implementation. */
4
+ export interface BridgeToolHost {
5
+ readonly businessSessionId: string;
6
+ listTools(): readonly {
7
+ name: string;
8
+ description: string;
9
+ inputSchema: unknown;
10
+ }[];
11
+ execute(call: {
12
+ id: string;
13
+ name: string;
14
+ input: unknown;
15
+ }): Promise<{
16
+ result?: string;
17
+ error?: string;
18
+ isError?: boolean;
19
+ }>;
20
+ }
21
+ export interface McpBridgeOptions {
22
+ store: SessionContextStore<BridgeToolHost>;
23
+ /** Logical MCP server name Codex is configured with. */
24
+ serverName?: string;
25
+ /**
26
+ * Bind this bridge to exactly ONE session, for runtimes that do not send a
27
+ * per-call thread identity.
28
+ *
29
+ * Codex injects `_meta.threadId`, which is what lets one shared bridge serve
30
+ * many threads safely. Claude Code sends no equivalent — so for it the binding
31
+ * has to be the PORT: one bridge, one session, and whatever arrives on this
32
+ * socket belongs to that session by construction.
33
+ *
34
+ * This is not the "guess the foreground session" fallback that §11.3 and §22.5
35
+ * forbid. The distinction is that nothing is being inferred: a request cannot
36
+ * be misattributed, because there is only one possible attribution and it was
37
+ * fixed before the port was opened. The cost is that a shared bridge is no
38
+ * longer possible for such a runtime — which is exactly the §22.7 trade-off,
39
+ * accepted here per-runtime rather than globally.
40
+ */
41
+ singleSessionThreadId?: string;
42
+ /** Cap on a single request body. Prevents an oversized init/call. */
43
+ maxBodyBytes?: number;
44
+ /**
45
+ * Structured log sink. Deliberately narrow: §12.4 forbids logging the bearer
46
+ * token, full tool arguments, or full results, so only classifications and
47
+ * identifiers get through here.
48
+ */
49
+ log?: (event: string, data: Record<string, unknown>) => void;
50
+ }
51
+ export interface McpBridgeHandle {
52
+ /** `http://127.0.0.1:<port>/mcp` — pass to Codex as `mcp_servers.<n>.url`. */
53
+ readonly url: string;
54
+ /** Value for the env var named by `mcp_servers.<n>.bearer_token_env_var`. */
55
+ readonly token: string;
56
+ readonly tokenEnvVar: string;
57
+ close(): Promise<void>;
58
+ }
59
+ export declare const CODEX_MCP_TOKEN_ENV_VAR = "CODESHELL_CODEX_MCP_TOKEN";
60
+ export declare function threadIdFromMeta(params: unknown): string | undefined;
61
+ export declare function startLoopbackMcpBridge(options: McpBridgeOptions): Promise<McpBridgeHandle>;
62
+ /**
63
+ * Codex CLI config flags that point a thread at this bridge. The token travels
64
+ * by environment variable, never in argv (§12.2).
65
+ */
66
+ export declare function codexBridgeConfigArgs(handle: McpBridgeHandle, serverName?: string): string[];
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Loopback HTTP MCP bridge: the reverse channel a Codex thread uses to call
3
+ * CodeShell tools (§11.2).
4
+ *
5
+ * Shape and constraints are not guesswork — they were established against a real
6
+ * `codex-cli 0.145.0` (probes in `docs/todo/evidence/`):
7
+ *
8
+ * - Codex sends `accept: text/event-stream, application/json`. Answering with a
9
+ * plain JSON body makes it report `user cancelled MCP tool call` — a
10
+ * thoroughly misleading error that reads like a user refusal but is a
11
+ * transport mismatch. So: reply as SSE whenever the client will take it.
12
+ * - Thread identity arrives in the JSON-RPC body as `_meta.threadId` (and
13
+ * `_meta["x-codex-turn-metadata"].thread_id`), NOT in any HTTP header. The
14
+ * only headers present are `mcp-protocol-version`, `accept`, `authorization`.
15
+ * - `bearer_token_env_var` is a real, shipping Codex config surface, so the
16
+ * token goes through the environment and never onto a command line.
17
+ *
18
+ * Everything the model controls — tool name, arguments, call ordering — is
19
+ * untrusted. Session identity is bound out of band via the thread context store.
20
+ */
21
+ import { createServer } from "node:http";
22
+ import { randomBytes } from "node:crypto";
23
+ const MCP_PATH = "/mcp";
24
+ const DEFAULT_MAX_BODY = 1024 * 1024;
25
+ export const CODEX_MCP_TOKEN_ENV_VAR = "CODESHELL_CODEX_MCP_TOKEN";
26
+ /** Loopback only. A bridge that answers off-host would expose CodeShell tools
27
+ * to the network; §11.2 requires 127.0.0.1 / ::1 and nothing else. */
28
+ function isLoopback(req) {
29
+ const address = req.socket.remoteAddress ?? "";
30
+ return (address === "127.0.0.1" ||
31
+ address === "::1" ||
32
+ address === "::ffff:127.0.0.1" ||
33
+ address.startsWith("127."));
34
+ }
35
+ export function threadIdFromMeta(params) {
36
+ if (!params || typeof params !== "object")
37
+ return undefined;
38
+ const meta = params._meta;
39
+ if (!meta || typeof meta !== "object")
40
+ return undefined;
41
+ const direct = meta.threadId;
42
+ if (typeof direct === "string" && direct)
43
+ return direct;
44
+ const turn = meta["x-codex-turn-metadata"];
45
+ if (turn && typeof turn === "object") {
46
+ const nested = turn.thread_id;
47
+ if (typeof nested === "string" && nested)
48
+ return nested;
49
+ }
50
+ return undefined;
51
+ }
52
+ function turnIdFromMeta(params) {
53
+ if (!params || typeof params !== "object")
54
+ return undefined;
55
+ const meta = params._meta;
56
+ if (!meta || typeof meta !== "object")
57
+ return undefined;
58
+ const turn = meta["x-codex-turn-metadata"];
59
+ if (turn && typeof turn === "object") {
60
+ const id = turn.turn_id;
61
+ if (typeof id === "string" && id)
62
+ return id;
63
+ }
64
+ return undefined;
65
+ }
66
+ /** Model-facing text for a routing refusal. Says what happened without leaking
67
+ * which other sessions exist. */
68
+ function missMessage(reason) {
69
+ switch (reason) {
70
+ case "missing_thread_id":
71
+ return "Refused: this tool call carried no thread identity, so it cannot be attributed to a session.";
72
+ case "unknown_thread":
73
+ return "Refused: this thread is not registered with the CodeShell host.";
74
+ case "stale_generation":
75
+ return "Refused: this thread belongs to a previous host generation and is no longer valid.";
76
+ case "ambiguous_thread":
77
+ return "Refused: a single batch may not span multiple threads.";
78
+ }
79
+ }
80
+ export async function startLoopbackMcpBridge(options) {
81
+ const token = randomBytes(32).toString("hex");
82
+ const maxBody = options.maxBodyBytes ?? DEFAULT_MAX_BODY;
83
+ const serverName = options.serverName ?? "codeshell_tools";
84
+ const log = options.log ?? (() => { });
85
+ const store = options.store;
86
+ // Treat "" as unset explicitly. `??` only guards null/undefined, so an empty
87
+ // string would silently fall back to caller-supplied `_meta` — restoring exactly
88
+ // the caller-steered routing the pin exists to remove.
89
+ const pinnedThreadId = options.singleSessionThreadId || undefined;
90
+ if (options.singleSessionThreadId !== undefined && !pinnedThreadId) {
91
+ throw new Error("singleSessionThreadId must be a non-empty string when provided.");
92
+ }
93
+ /**
94
+ * Thread identity for a request. When the bridge is pinned to one session the
95
+ * pin wins outright — a runtime that sends no `_meta.threadId` must not be able
96
+ * to steer routing by supplying one either.
97
+ */
98
+ const threadIdFor = (params) => pinnedThreadId ?? threadIdFromMeta(params);
99
+ const server = createServer((req, res) => {
100
+ void handle(req, res).catch((error) => {
101
+ log("bridge.handler_failed", { error: error instanceof Error ? error.name : "unknown" });
102
+ if (!res.headersSent)
103
+ res.writeHead(500);
104
+ res.end();
105
+ });
106
+ });
107
+ async function handle(req, res) {
108
+ if (!isLoopback(req)) {
109
+ log("bridge.rejected", { reason: "non_loopback" });
110
+ res.writeHead(403).end();
111
+ return;
112
+ }
113
+ if (req.url !== MCP_PATH) {
114
+ res.writeHead(404).end();
115
+ return;
116
+ }
117
+ // POST only. A GET with a valid token used to return 202, which is both
118
+ // misleading (MCP Streamable HTTP reserves GET for opening an SSE stream,
119
+ // which this bridge does not offer) and the natural shape for a
120
+ // browser-originated probe.
121
+ if (req.method !== "POST") {
122
+ log("bridge.rejected", { reason: "method_not_allowed", method: req.method });
123
+ res.writeHead(405, { allow: "POST" }).end();
124
+ return;
125
+ }
126
+ // Constant-shape check; the token never reaches the log.
127
+ if (req.headers.authorization !== `Bearer ${token}`) {
128
+ log("bridge.rejected", { reason: "unauthorized" });
129
+ res.writeHead(401).end();
130
+ return;
131
+ }
132
+ const body = await readBody(req, maxBody);
133
+ if (body === null) {
134
+ log("bridge.rejected", { reason: "body_too_large" });
135
+ res.writeHead(413).end();
136
+ return;
137
+ }
138
+ if (body === "") {
139
+ res.writeHead(202).end();
140
+ return;
141
+ }
142
+ let parsed;
143
+ try {
144
+ parsed = JSON.parse(body);
145
+ }
146
+ catch {
147
+ log("bridge.rejected", { reason: "malformed_json" });
148
+ res.writeHead(400).end();
149
+ return;
150
+ }
151
+ // JSON-RPC allows an array (batch). Left unhandled, a batch fell through
152
+ // every branch below and got `{result:{}}` with HTTP 200 — no host reached,
153
+ // so it failed closed, but it failed SILENTLY and told the client it had
154
+ // succeeded. Refuse it explicitly and say why.
155
+ //
156
+ // Batches are not supported at all rather than resolved per item: a mixed
157
+ // batch would touch two sessions on the strength of one authorization
158
+ // (§11.3), and `store.resolveBatch()` exists for the day batching is added.
159
+ if (Array.isArray(parsed)) {
160
+ const threadIds = parsed.map((entry) => threadIdFor(entry?.params));
161
+ const batch = store.resolveBatch(threadIds, store.generation);
162
+ const reason = batch.ok ? "batch_unsupported" : batch.reason;
163
+ log("bridge.rejected", { reason, itemCount: parsed.length });
164
+ res.writeHead(400, { "content-type": "application/json" });
165
+ res.end(JSON.stringify({
166
+ jsonrpc: "2.0",
167
+ id: null,
168
+ error: {
169
+ code: -32600,
170
+ message: batch.ok || batch.reason === "ambiguous_thread"
171
+ ? "Batched requests are not supported by this bridge."
172
+ : missMessage(batch.reason),
173
+ },
174
+ }));
175
+ return;
176
+ }
177
+ if (!parsed || typeof parsed !== "object") {
178
+ log("bridge.rejected", { reason: "not_an_object" });
179
+ res.writeHead(400).end();
180
+ return;
181
+ }
182
+ const message = parsed;
183
+ const reply = (result) => {
184
+ // `id` must always be present: JSON.stringify drops an undefined value,
185
+ // and a reply with no id is spec-invalid and uncorrelatable.
186
+ const payload = JSON.stringify({
187
+ jsonrpc: "2.0",
188
+ id: message.id ?? null,
189
+ result,
190
+ });
191
+ // SSE when the client accepts it — see the module header; a plain JSON body
192
+ // makes Codex report the call as user-cancelled.
193
+ if (String(req.headers.accept ?? "").includes("text/event-stream")) {
194
+ res.writeHead(200, {
195
+ "content-type": "text/event-stream",
196
+ "cache-control": "no-cache",
197
+ connection: "keep-alive",
198
+ });
199
+ res.write(`event: message\ndata: ${payload}\n\n`);
200
+ res.end();
201
+ return;
202
+ }
203
+ res.writeHead(200, { "content-type": "application/json" });
204
+ res.end(payload);
205
+ };
206
+ if (message.method === "initialize") {
207
+ reply({
208
+ protocolVersion: message.params?.protocolVersion ??
209
+ "2025-06-18",
210
+ capabilities: { tools: {} },
211
+ serverInfo: { name: serverName, version: "1" },
212
+ });
213
+ return;
214
+ }
215
+ if (typeof message.method === "string" && message.method.startsWith("notifications/")) {
216
+ res.writeHead(202).end();
217
+ return;
218
+ }
219
+ if (message.method === "tools/list") {
220
+ const resolved = store.resolve({
221
+ threadId: threadIdFor(message.params),
222
+ generation: store.generation,
223
+ });
224
+ if (!resolved.ok) {
225
+ // Advertise nothing rather than guessing whose tools to show. A wrong
226
+ // guess here would leak one session's surface into another's prompt.
227
+ //
228
+ // Observed against real codex-cli 0.145.0: the FIRST `tools/list` of a
229
+ // run carries no `_meta.threadId` at all — the thread does not exist yet —
230
+ // so on a SHARED bridge it necessarily lands here.
231
+ //
232
+ // An earlier note here claimed Codex reliably re-issues `tools/list` once
233
+ // the thread exists. That was wrong: it does so only sometimes, and when
234
+ // it does not, the tool is invisible for the entire session (measured
235
+ // both ways on the same binary). Do not depend on the retry.
236
+ //
237
+ // The supported fix is `singleSessionThreadId`: one bridge per session,
238
+ // where the port is the attribution, so the pre-thread `tools/list`
239
+ // resolves. §22.7's trade-off, taken per-runtime. A shared bridge remains
240
+ // correct for a runtime that identifies every call — it just cannot
241
+ // advertise tools before the first thread exists.
242
+ log("bridge.tools_list_refused", { reason: resolved.reason });
243
+ reply({ tools: [] });
244
+ return;
245
+ }
246
+ const tools = resolved.host.listTools().map((definition) => ({
247
+ name: definition.name,
248
+ description: definition.description,
249
+ inputSchema: definition.inputSchema,
250
+ }));
251
+ log("bridge.tools_list", {
252
+ businessSessionId: resolved.host.businessSessionId,
253
+ count: tools.length,
254
+ });
255
+ reply({ tools });
256
+ return;
257
+ }
258
+ if (message.method === "tools/call") {
259
+ const params = message.params;
260
+ const threadId = threadIdFor(message.params);
261
+ const resolved = store.resolve({ threadId, generation: store.generation });
262
+ if (!resolved.ok) {
263
+ log("bridge.call_refused", {
264
+ reason: resolved.reason,
265
+ threadIdPrefix: threadId?.slice(0, 8),
266
+ });
267
+ reply({ content: [{ type: "text", text: missMessage(resolved.reason) }], isError: true });
268
+ return;
269
+ }
270
+ const name = typeof params?.name === "string" ? params.name : "";
271
+ const started = Date.now();
272
+ // The host is the authorization boundary; the bridge only routes.
273
+ const outcome = await resolved.host.execute({
274
+ id: turnIdFromMeta(message.params) ?? `mcp-${randomBytes(6).toString("hex")}`,
275
+ name,
276
+ input: params?.arguments ?? {},
277
+ });
278
+ log("bridge.call", {
279
+ businessSessionId: resolved.host.businessSessionId,
280
+ threadIdPrefix: threadId?.slice(0, 8),
281
+ toolName: name,
282
+ resultStatus: outcome.isError ? "error" : "ok",
283
+ durationMs: Date.now() - started,
284
+ });
285
+ reply({
286
+ content: [{ type: "text", text: String(outcome.result ?? outcome.error ?? "") }],
287
+ isError: Boolean(outcome.isError),
288
+ });
289
+ return;
290
+ }
291
+ reply({});
292
+ }
293
+ // Streamable HTTP is a long-lived SSE stream. Node's default 5s keep-alive
294
+ // severs the Codex connection mid-session; `headersTimeout` must stay >=
295
+ // keepAliveTimeout, and 0 means unlimited for both. A Codex MCP request can
296
+ // also be genuinely large (base64 images), so the per-request clock is off too
297
+ // — body size is bounded explicitly in readBody() instead.
298
+ //
299
+ // Credit where due: this is one of two latent bugs found by reading the
300
+ // `makecindy/cindy` reference implementation (§16). Without it the bridge
301
+ // works in a fast test and drops out after five seconds in real use.
302
+ server.keepAliveTimeout = 0;
303
+ server.headersTimeout = 0;
304
+ server.requestTimeout = 0;
305
+ await new Promise((resolve, reject) => {
306
+ server.once("error", reject);
307
+ // Resolve only once actually listening: a caller that hands the url to a
308
+ // spawned Codex before the socket is up gets a connection refused.
309
+ server.listen(0, "127.0.0.1", () => {
310
+ server.removeListener("error", reject);
311
+ resolve();
312
+ });
313
+ });
314
+ const { port } = server.address();
315
+ return {
316
+ url: `http://127.0.0.1:${port}${MCP_PATH}`,
317
+ token,
318
+ tokenEnvVar: CODEX_MCP_TOKEN_ENV_VAR,
319
+ async close() {
320
+ await new Promise((resolve) => server.close(() => resolve()));
321
+ },
322
+ };
323
+ }
324
+ async function readBody(req, limit) {
325
+ return await new Promise((resolve) => {
326
+ let size = 0;
327
+ const chunks = [];
328
+ let aborted = false;
329
+ req.on("data", (chunk) => {
330
+ if (aborted)
331
+ return;
332
+ size += chunk.length;
333
+ if (size > limit) {
334
+ // Stop accumulating, but do NOT destroy the socket: the caller still has
335
+ // to send 413. Tearing the connection down here surfaces as ECONNRESET,
336
+ // which tells the client nothing about what went wrong.
337
+ aborted = true;
338
+ req.pause();
339
+ resolve(null);
340
+ return;
341
+ }
342
+ chunks.push(chunk);
343
+ });
344
+ req.on("end", () => {
345
+ if (!aborted)
346
+ resolve(Buffer.concat(chunks).toString("utf8"));
347
+ });
348
+ req.on("error", () => {
349
+ if (!aborted)
350
+ resolve("");
351
+ });
352
+ });
353
+ }
354
+ /**
355
+ * Codex CLI config flags that point a thread at this bridge. The token travels
356
+ * by environment variable, never in argv (§12.2).
357
+ */
358
+ export function codexBridgeConfigArgs(handle, serverName = "codeshell_tools") {
359
+ return [
360
+ "-c",
361
+ `mcp_servers.${serverName}.url="${handle.url}"`,
362
+ "-c",
363
+ `mcp_servers.${serverName}.bearer_token_env_var="${handle.tokenEnvVar}"`,
364
+ ];
365
+ }