gaslamp 1.0.0 → 1.1.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 +10 -0
- package/bin/gaslamp.mjs +2 -2
- package/package.json +2 -2
- package/src/server.mjs +45 -6
- package/src/setup.mjs +48 -1
package/README.md
CHANGED
|
@@ -63,11 +63,21 @@ A Codex→Claude consult with no `sandbox` arg runs with your own Claude config
|
|
|
63
63
|
would. Per-call overrides: `read-only` (advisory, no edits) or `danger-full-access`
|
|
64
64
|
(full read/write). Claude has no filesystem sandbox, so there's no `workspace-write`.
|
|
65
65
|
|
|
66
|
+
## Recursion
|
|
67
|
+
|
|
68
|
+
A consult is **one hop** by default: a Claude reached through gaslamp can't turn
|
|
69
|
+
around and consult Codex, and a Codex reached through gaslamp can't consult Claude
|
|
70
|
+
back. (The Claude→Codex registration tags the consulted Codex with
|
|
71
|
+
`GASLAMP_NESTED=1`, and the spawned Claude is denied the bridge with
|
|
72
|
+
`--disallowedTools mcp__gaslamp`.) Only the top-level agent you drive can open a
|
|
73
|
+
consult. Set `GASLAMP_ALLOW_RECURSION=1` to restore unbounded, symmetric handoffs.
|
|
74
|
+
|
|
66
75
|
## Env knobs
|
|
67
76
|
|
|
68
77
|
| var | default | meaning |
|
|
69
78
|
|-----|---------|---------|
|
|
70
79
|
| `GASLAMP_ALLOWED_TOOLS` | `Read Grep Glob WebFetch WebSearch` | tools the `read-only` override permits |
|
|
80
|
+
| `GASLAMP_ALLOW_RECURSION` | unset | allow a consulted agent to consult back (off = one hop) |
|
|
71
81
|
| `GASLAMP_LOGFILE` | `~/.codex/gaslamp.log` | transcript path; `off` to disable |
|
|
72
82
|
| `GASLAMP_DEBUG` | unset | verbose stderr (raw JSON-RPC) |
|
|
73
83
|
| `CLAUDE_BIN` | autodetected | path to the `claude` binary |
|
package/bin/gaslamp.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// gaslamp
|
|
2
|
+
// gaslamp: Minimal MCP server that lets Claude and Codex talk to each other.
|
|
3
3
|
//
|
|
4
4
|
// gaslamp run the MCP server on stdio (what Codex spawns)
|
|
5
5
|
// gaslamp serve same as above, explicit
|
|
@@ -18,7 +18,7 @@ import { fileURLToPath } from "node:url";
|
|
|
18
18
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
19
19
|
const pkg = JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8"));
|
|
20
20
|
|
|
21
|
-
const HELP = `gaslamp ${pkg.version}
|
|
21
|
+
const HELP = `gaslamp ${pkg.version}: Minimal MCP server that lets Claude and Codex talk to each other.
|
|
22
22
|
|
|
23
23
|
Usage:
|
|
24
24
|
gaslamp [serve] Run the MCP server on stdio (what Codex spawns).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gaslamp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Minimal MCP server that lets Claude and Codex talk to each other.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"gaslamp": "bin/gaslamp.mjs"
|
package/src/server.mjs
CHANGED
|
@@ -10,8 +10,14 @@
|
|
|
10
10
|
// - Per-call `sandbox` arg; OMITTING it defers to the user's own Claude config
|
|
11
11
|
// (~/.claude settings: permission defaultMode, allow/deny rules, model, MCP,
|
|
12
12
|
// CLAUDE.md), the mirror of Codex deferring to `sandbox_mode` in config.toml.
|
|
13
|
-
// - No bespoke timeout
|
|
14
|
-
//
|
|
13
|
+
// - No bespoke timeout — a consulted Claude loads the user's full config/MCP,
|
|
14
|
+
// exactly as a consulted Codex does.
|
|
15
|
+
// - Recursion guard (default on): a consult is one hop. The Claude we spawn is
|
|
16
|
+
// denied the Claude→Codex bridge (`--disallowedTools mcp__gaslamp`, a deny
|
|
17
|
+
// rule that holds even under --dangerously-skip-permissions), and if THIS
|
|
18
|
+
// server is itself running under a consulted Codex (GASLAMP_NESTED, set on
|
|
19
|
+
// the Claude→Codex registration by `gaslamp setup`) it refuses the call.
|
|
20
|
+
// GASLAMP_ALLOW_RECURSION=1 restores the old unbounded symmetric handoff.
|
|
15
21
|
// - The lone Claude-only step (no Codex analog, so not an asymmetry):
|
|
16
22
|
// ANTHROPIC_API_KEY is stripped from the child env so keychain OAuth is
|
|
17
23
|
// authoritative. A stale env key 401s every call otherwise.
|
|
@@ -57,6 +63,14 @@ const ALLOWED_TOOLS = (process.env.GASLAMP_ALLOWED_TOOLS || "Read Grep Glob WebF
|
|
|
57
63
|
.split(/[\s,]+/).filter(Boolean).join(",");
|
|
58
64
|
const DEBUG = !!process.env.GASLAMP_DEBUG;
|
|
59
65
|
|
|
66
|
+
// Recursion guard: a consult is one hop unless explicitly opted out of.
|
|
67
|
+
// ALLOW_RECURSION — restore the old symmetric, unbounded handoff.
|
|
68
|
+
// NESTED — this server is running under a Codex that is itself a
|
|
69
|
+
// gaslamp consult (tagged GASLAMP_NESTED=1 on the
|
|
70
|
+
// Claude→Codex registration); refuse to hand work back.
|
|
71
|
+
const ALLOW_RECURSION = !!process.env.GASLAMP_ALLOW_RECURSION;
|
|
72
|
+
const NESTED = !!process.env.GASLAMP_NESTED && !ALLOW_RECURSION;
|
|
73
|
+
|
|
60
74
|
// Persistent consultation transcript (the lightweight "watch them talk" log).
|
|
61
75
|
// Defaults to ~/.codex/gaslamp.log; set GASLAMP_LOGFILE=off to disable.
|
|
62
76
|
const LOGFILE = process.env.GASLAMP_LOGFILE === "off" ? null
|
|
@@ -78,6 +92,20 @@ function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n"); }
|
|
|
78
92
|
function reply(id, result) { send({ jsonrpc: "2.0", id, result }); }
|
|
79
93
|
function fail(id, code, message) { send({ jsonrpc: "2.0", id, error: { code, message } }); }
|
|
80
94
|
|
|
95
|
+
// Refuse a consult that would be a second hop: this server runs under a Codex
|
|
96
|
+
// that is itself a gaslamp consult. Returned as a readable tool result (not a
|
|
97
|
+
// protocol error) so the consulting Codex sees why, mirroring the claude-error
|
|
98
|
+
// shape in runClaude.
|
|
99
|
+
function refuseNested(id, name) {
|
|
100
|
+
const text =
|
|
101
|
+
"Recursive gaslamp consultation is disabled. This Codex is itself a gaslamp " +
|
|
102
|
+
"consult (GASLAMP_NESTED is set), so it can't hand work back to Claude — a " +
|
|
103
|
+
"consult is one hop. Set GASLAMP_ALLOW_RECURSION=1 to allow nested handoffs.";
|
|
104
|
+
log("refuse nested", name);
|
|
105
|
+
transcript(`← refused ${name} (nested; recursion disabled)`);
|
|
106
|
+
reply(id, { content: [{ type: "text", text }], structuredContent: { sessionId: null, content: text }, isError: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
81
109
|
// Mirrors codex's `{ threadId, content }` outputSchema. sessionId may be null on
|
|
82
110
|
// a hard failure, so only `content` is required.
|
|
83
111
|
const OUTPUT_SCHEMA = {
|
|
@@ -99,10 +127,12 @@ const TOOLS = [
|
|
|
99
127
|
{
|
|
100
128
|
name: "gaslamp",
|
|
101
129
|
title: "Claude",
|
|
130
|
+
// Mirrors codex mcp-server's `codex` description ("Run a Codex session.
|
|
131
|
+
// Accepts configuration parameters matching the Codex Config struct.") so the
|
|
132
|
+
// two directions read as twins. "config" (not "Config struct") because Claude
|
|
133
|
+
// Code has no such struct and gaslamp takes no `config` param — honest mirror.
|
|
102
134
|
description:
|
|
103
|
-
"
|
|
104
|
-
"`cwd` and returns a summary of what it found or changed. Returns a sessionId; pass it to " +
|
|
105
|
-
"gaslamp-reply to continue the same thread with full context.",
|
|
135
|
+
"Run a Claude session. Accepts configuration parameters matching the Claude Code config.",
|
|
106
136
|
inputSchema: {
|
|
107
137
|
type: "object",
|
|
108
138
|
additionalProperties: false,
|
|
@@ -119,7 +149,9 @@ const TOOLS = [
|
|
|
119
149
|
{
|
|
120
150
|
name: "gaslamp-reply",
|
|
121
151
|
title: "Claude Reply",
|
|
122
|
-
|
|
152
|
+
// Mirrors codex's `codex-reply`: "Continue a Codex conversation by providing
|
|
153
|
+
// the thread id and prompt." (thread id → session id).
|
|
154
|
+
description: "Continue a Claude conversation by providing the session id and prompt.",
|
|
123
155
|
inputSchema: {
|
|
124
156
|
type: "object",
|
|
125
157
|
additionalProperties: false,
|
|
@@ -150,6 +182,12 @@ function runClaude({ prompt, cwd, model, sandbox, resume }, id) {
|
|
|
150
182
|
}
|
|
151
183
|
// else (omitted): pass no permission flag — the consulted Claude uses the
|
|
152
184
|
// user's own ~/.claude config, mirroring Codex deferring to config.toml.
|
|
185
|
+
|
|
186
|
+
// Recursion guard: deny the consulted Claude the Claude→Codex bridge so it
|
|
187
|
+
// can't open a further sub-session. A bare server name removes all of
|
|
188
|
+
// gaslamp's tools from its context, and deny rules hold even under
|
|
189
|
+
// --dangerously-skip-permissions. GASLAMP_ALLOW_RECURSION=1 opts back in.
|
|
190
|
+
if (!ALLOW_RECURSION) args.push("--disallowedTools", "mcp__gaslamp");
|
|
153
191
|
if (resume) args.push("--resume", resume);
|
|
154
192
|
if (model) args.push("--model", model);
|
|
155
193
|
|
|
@@ -204,6 +242,7 @@ function handle(msg) {
|
|
|
204
242
|
} else if (method === "tools/call") {
|
|
205
243
|
const name = params?.name;
|
|
206
244
|
const a = params?.arguments || {};
|
|
245
|
+
if (NESTED && (name === "gaslamp" || name === "gaslamp-reply")) return refuseNested(id, name);
|
|
207
246
|
if (name === "gaslamp") {
|
|
208
247
|
if (!a.prompt) return fail(id, -32602, "missing required arg: prompt");
|
|
209
248
|
runClaude({ prompt: a.prompt, cwd: a.cwd, model: a.model, sandbox: a.sandbox }, id);
|
package/src/setup.mjs
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
// the package is published, or for from-source dev.
|
|
17
17
|
|
|
18
18
|
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
19
21
|
import { dirname, join, resolve } from "node:path";
|
|
20
22
|
import { fileURLToPath } from "node:url";
|
|
21
23
|
import { which } from "./which.mjs";
|
|
@@ -26,6 +28,45 @@ function run(label, cmd, args) {
|
|
|
26
28
|
return r.status === 0;
|
|
27
29
|
}
|
|
28
30
|
|
|
31
|
+
// `codex mcp add` cannot persist per-server timeouts (its `-c` flag is a runtime
|
|
32
|
+
// override that never lands in the block), so we patch ~/.codex/config.toml
|
|
33
|
+
// directly. Without this, Codex's MCP client kills any tools/call that runs past
|
|
34
|
+
// its default tool_timeout_sec — a substantial consult (30-45 min) dies with
|
|
35
|
+
// `timed out awaiting tools/call after 120s` while the gaslamp child keeps
|
|
36
|
+
// working, orphaned. The server itself has no timeout; this is the client side.
|
|
37
|
+
// Idempotent: sets the keys in-place if present, inserts them if not.
|
|
38
|
+
function patchCodexTimeouts() {
|
|
39
|
+
const tool = process.env.GASLAMP_TOOL_TIMEOUT_SEC || "100000"; // ~27.8h — matches Claude Code's own default MCP tool timeout (1e8 ms), so both directions are symmetric
|
|
40
|
+
const startup = process.env.GASLAMP_STARTUP_TIMEOUT_SEC || "30"; // headroom for npx cold-start
|
|
41
|
+
const cfgPath = join(process.env.CODEX_HOME || join(homedir(), ".codex"), "config.toml");
|
|
42
|
+
|
|
43
|
+
let text;
|
|
44
|
+
try { text = readFileSync(cfgPath, "utf8"); }
|
|
45
|
+
catch { console.warn(` (could not read ${cfgPath}; skipped timeout patch)`); return; }
|
|
46
|
+
|
|
47
|
+
const lines = text.split("\n");
|
|
48
|
+
const header = lines.findIndex((l) => l.trim() === "[mcp_servers.gaslamp]");
|
|
49
|
+
if (header < 0) { console.warn(" (no [mcp_servers.gaslamp] block found; skipped timeout patch)"); return; }
|
|
50
|
+
|
|
51
|
+
// Block runs from the header to the next table header (line starting with `[`).
|
|
52
|
+
let end = lines.length;
|
|
53
|
+
for (let i = header + 1; i < lines.length; i++) { if (/^\s*\[/.test(lines[i])) { end = i; break; } }
|
|
54
|
+
const block = lines.slice(header, end);
|
|
55
|
+
|
|
56
|
+
const setKey = (key, val) => {
|
|
57
|
+
const i = block.findIndex((l) => new RegExp(`^\\s*${key}\\s*=`).test(l));
|
|
58
|
+
if (i >= 0) { block[i] = `${key} = ${val}`; return; }
|
|
59
|
+
let at = block.length; // insert before trailing blank lines
|
|
60
|
+
while (at > 1 && block[at - 1].trim() === "") at--;
|
|
61
|
+
block.splice(at, 0, `${key} = ${val}`);
|
|
62
|
+
};
|
|
63
|
+
setKey("tool_timeout_sec", tool);
|
|
64
|
+
setKey("startup_timeout_sec", startup);
|
|
65
|
+
|
|
66
|
+
writeFileSync(cfgPath, [...lines.slice(0, header), ...block, ...lines.slice(end)].join("\n"));
|
|
67
|
+
process.stdout.write(` timeouts: tool_timeout_sec=${tool}s startup_timeout_sec=${startup}s in ${cfgPath}\n`);
|
|
68
|
+
}
|
|
69
|
+
|
|
29
70
|
// Quietly remove an existing registration (ignore "not found" noise).
|
|
30
71
|
function remove(cli, name, scopeArgs = []) {
|
|
31
72
|
spawnSync(cli, ["mcp", "remove", name, ...scopeArgs], { stdio: "ignore" });
|
|
@@ -62,12 +103,18 @@ export function runSetup(argv = []) {
|
|
|
62
103
|
serveCmd = ["mcp", "add", "gaslamp", "--", "npx", "-y", "gaslamp", "serve"];
|
|
63
104
|
}
|
|
64
105
|
const okCodex = run("codex -> claude", "codex", serveCmd);
|
|
106
|
+
if (okCodex) patchCodexTimeouts(); // codex mcp add can't persist these; do it ourselves
|
|
65
107
|
|
|
66
108
|
// --- Claude -> Codex: claude gets gaslamp's codex tools (user scope) --------
|
|
109
|
+
// `--env GASLAMP_NESTED=1` tags every Codex spawned as a Claude consult as
|
|
110
|
+
// nested, so gaslamp's server.mjs refuses if that consulted Codex tries to
|
|
111
|
+
// hand work back to Claude — a consult is one hop (GASLAMP_ALLOW_RECURSION=1
|
|
112
|
+
// opts back in). Placement is safe: the server name `gaslamp` precedes --env
|
|
113
|
+
// and `--` follows it, so the name is never misread as a KEY=VALUE pair.
|
|
67
114
|
remove("claude", "gaslamp", ["-s", "user"]);
|
|
68
115
|
remove("claude", "codex", ["-s", "user"]); // legacy name
|
|
69
116
|
const okClaude = run("claude -> codex", "claude",
|
|
70
|
-
["mcp", "add", "gaslamp", "-s", "user", "--", "codex", "mcp-server"]);
|
|
117
|
+
["mcp", "add", "gaslamp", "-s", "user", "--env", "GASLAMP_NESTED=1", "--", "codex", "mcp-server"]);
|
|
71
118
|
|
|
72
119
|
console.log();
|
|
73
120
|
if (okCodex && okClaude) {
|