gaslamp 1.0.1 → 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 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gaslamp",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Minimal MCP server that lets Claude and Codex talk to each other.",
5
5
  "type": "module",
6
6
  "bin": {
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 and no recursion isolation — a consulted Claude loads
14
- // the user's full config/MCP, exactly as a consulted Codex does.
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
- "Start a Claude Code session for a code review, second opinion, or fix. Claude works in " +
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
- description: "Continue a Claude consultation by providing the sessionId and prompt, preserving its context.",
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
@@ -106,10 +106,15 @@ export function runSetup(argv = []) {
106
106
  if (okCodex) patchCodexTimeouts(); // codex mcp add can't persist these; do it ourselves
107
107
 
108
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.
109
114
  remove("claude", "gaslamp", ["-s", "user"]);
110
115
  remove("claude", "codex", ["-s", "user"]); // legacy name
111
116
  const okClaude = run("claude -> codex", "claude",
112
- ["mcp", "add", "gaslamp", "-s", "user", "--", "codex", "mcp-server"]);
117
+ ["mcp", "add", "gaslamp", "-s", "user", "--env", "GASLAMP_NESTED=1", "--", "codex", "mcp-server"]);
113
118
 
114
119
  console.log();
115
120
  if (okCodex && okClaude) {