@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.
- package/README.md +69 -0
- package/THIRD_PARTY_NOTICES.md +206 -0
- package/dist/bin/agent-server-stdio.d.ts +1 -0
- package/dist/bin/agent-server-stdio.js +4 -0
- package/dist/capability-runtime.d.ts +22 -0
- package/dist/capability-runtime.js +88 -0
- package/dist/cc-orchestrator/agent-adapter.d.ts +55 -0
- package/dist/cc-orchestrator/agent-adapter.js +143 -0
- package/dist/cc-orchestrator/cc-capability.d.ts +20 -0
- package/dist/cc-orchestrator/cc-capability.js +57 -0
- package/dist/cc-orchestrator/codex-session-discovery.d.ts +45 -0
- package/dist/cc-orchestrator/codex-session-discovery.js +251 -0
- package/dist/cc-orchestrator/codex-session-history.d.ts +39 -0
- package/dist/cc-orchestrator/codex-session-history.js +249 -0
- package/dist/cc-orchestrator/codex-user-text.d.ts +9 -0
- package/dist/cc-orchestrator/codex-user-text.js +25 -0
- package/dist/cc-orchestrator/external-agent-bindings.d.ts +27 -0
- package/dist/cc-orchestrator/external-agent-bindings.js +150 -0
- package/dist/cc-orchestrator/external-agent-changes.d.ts +19 -0
- package/dist/cc-orchestrator/external-agent-changes.js +231 -0
- package/dist/cc-orchestrator/external-agent-driver.d.ts +19 -0
- package/dist/cc-orchestrator/external-agent-driver.js +284 -0
- package/dist/cc-orchestrator/external-agent-session-store.d.ts +36 -0
- package/dist/cc-orchestrator/external-agent-session-store.js +185 -0
- package/dist/cc-orchestrator/index.d.ts +10 -0
- package/dist/cc-orchestrator/index.js +10 -0
- package/dist/cc-orchestrator/related-session-discovery.d.ts +21 -0
- package/dist/cc-orchestrator/related-session-discovery.js +77 -0
- package/dist/cc-orchestrator/relevance-judge.d.ts +16 -0
- package/dist/cc-orchestrator/relevance-judge.js +38 -0
- package/dist/cc-orchestrator/session-discovery.d.ts +99 -0
- package/dist/cc-orchestrator/session-discovery.js +295 -0
- package/dist/cc-orchestrator/session-history.d.ts +55 -0
- package/dist/cc-orchestrator/session-history.js +150 -0
- package/dist/external-agents/config.d.ts +3 -0
- package/dist/external-agents/config.js +16 -0
- package/dist/external-agents/types.d.ts +32 -0
- package/dist/external-agents/types.js +1 -0
- package/dist/external-runtimes/claude-code/event-translator.d.ts +54 -0
- package/dist/external-runtimes/claude-code/event-translator.js +201 -0
- package/dist/external-runtimes/claude-code/mcp-config.d.ts +84 -0
- package/dist/external-runtimes/claude-code/mcp-config.js +175 -0
- package/dist/external-runtimes/claude-code/runtime.d.ts +56 -0
- package/dist/external-runtimes/claude-code/runtime.js +168 -0
- package/dist/external-runtimes/codex/app-server-client.d.ts +66 -0
- package/dist/external-runtimes/codex/app-server-client.js +244 -0
- package/dist/external-runtimes/codex/event-translator.d.ts +68 -0
- package/dist/external-runtimes/codex/event-translator.js +187 -0
- package/dist/external-runtimes/codex/index.d.ts +7 -0
- package/dist/external-runtimes/codex/index.js +6 -0
- package/dist/external-runtimes/codex/runtime.d.ts +128 -0
- package/dist/external-runtimes/codex/runtime.js +216 -0
- package/dist/external-runtimes/index.d.ts +37 -0
- package/dist/external-runtimes/index.js +27 -0
- package/dist/external-runtimes/session-factory.d.ts +97 -0
- package/dist/external-runtimes/session-factory.js +110 -0
- package/dist/external-runtimes/shared/mcp-bridge.d.ts +66 -0
- package/dist/external-runtimes/shared/mcp-bridge.js +365 -0
- package/dist/external-runtimes/shared/session-context-store.d.ts +83 -0
- package/dist/external-runtimes/shared/session-context-store.js +114 -0
- package/dist/external-runtimes/shared/spawn-env.d.ts +30 -0
- package/dist/external-runtimes/shared/spawn-env.js +41 -0
- package/dist/git/parse-log.d.ts +14 -0
- package/dist/git/parse-log.js +21 -0
- package/dist/git/utils.d.ts +49 -0
- package/dist/git/utils.js +161 -0
- package/dist/git/worktree/crud.d.ts +108 -0
- package/dist/git/worktree/crud.js +415 -0
- package/dist/git/worktree/diff.d.ts +14 -0
- package/dist/git/worktree/diff.js +83 -0
- package/dist/git/worktree/git-exec.d.ts +7 -0
- package/dist/git/worktree/git-exec.js +86 -0
- package/dist/git/worktree/index.d.ts +6 -0
- package/dist/git/worktree/index.js +6 -0
- package/dist/git/worktree/query.d.ts +43 -0
- package/dist/git/worktree/query.js +128 -0
- package/dist/git/worktree/slug.d.ts +12 -0
- package/dist/git/worktree/slug.js +59 -0
- package/dist/git/worktree.d.ts +1 -0
- package/dist/git/worktree.js +5 -0
- package/dist/index.capability.d.ts +7 -0
- package/dist/index.capability.js +190 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.git.d.ts +4 -0
- package/dist/index.git.js +4 -0
- package/dist/index.js +18 -0
- package/dist/index.orchestration.d.ts +7 -0
- package/dist/index.orchestration.js +5 -0
- package/dist/lsp/client.d.ts +41 -0
- package/dist/lsp/client.js +192 -0
- package/dist/lsp/manager.d.ts +53 -0
- package/dist/lsp/manager.js +248 -0
- package/dist/lsp/root-path.d.ts +9 -0
- package/dist/lsp/root-path.js +12 -0
- package/dist/lsp/servers.d.ts +16 -0
- package/dist/lsp/servers.js +60 -0
- package/dist/prompt/coding.md +42 -0
- package/dist/quota/credentials.d.ts +3 -0
- package/dist/quota/credentials.js +80 -0
- package/dist/quota/index.d.ts +36 -0
- package/dist/quota/index.js +161 -0
- package/dist/quota/types.d.ts +48 -0
- package/dist/quota/types.js +13 -0
- package/dist/review/review-prompt.d.ts +28 -0
- package/dist/review/review-prompt.js +81 -0
- package/dist/tools/apply-patch/LICENSE-codex +201 -0
- package/dist/tools/apply-patch/NOTICE.md +29 -0
- package/dist/tools/apply-patch/applier.d.ts +26 -0
- package/dist/tools/apply-patch/applier.js +306 -0
- package/dist/tools/apply-patch/backup-targets.d.ts +10 -0
- package/dist/tools/apply-patch/backup-targets.js +30 -0
- package/dist/tools/apply-patch/eol.d.ts +4 -0
- package/dist/tools/apply-patch/eol.js +9 -0
- package/dist/tools/apply-patch/index.d.ts +20 -0
- package/dist/tools/apply-patch/index.js +106 -0
- package/dist/tools/apply-patch/parser.d.ts +17 -0
- package/dist/tools/apply-patch/parser.js +209 -0
- package/dist/tools/apply-patch/seek-sequence.d.ts +18 -0
- package/dist/tools/apply-patch/seek-sequence.js +123 -0
- package/dist/tools/apply-patch/types.d.ts +49 -0
- package/dist/tools/apply-patch/types.js +13 -0
- package/dist/tools/brief.d.ts +6 -0
- package/dist/tools/brief.js +41 -0
- package/dist/tools/check-quota.d.ts +14 -0
- package/dist/tools/check-quota.js +38 -0
- package/dist/tools/drive-agent.d.ts +52 -0
- package/dist/tools/drive-agent.js +1184 -0
- package/dist/tools/lsp.d.ts +6 -0
- package/dist/tools/lsp.js +220 -0
- package/dist/tools/notebook-edit.d.ts +6 -0
- package/dist/tools/notebook-edit.js +127 -0
- package/dist/tools/worktree.d.ts +10 -0
- package/dist/tools/worktree.js +363 -0
- 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
|
+
}
|