Setting the file. One moment.
Run CLI Builder · N8n:testmax N8n AI · n8n-io/n8n · Skills Docs
ContentsBack to the top of the page def write_claude_interactive_config
— line 181
This file
Number 40.16
Position 16 of 24
Type Python
Size 31 KB
Lines 714 scripts/ run_cli_builder.py
Python · 714 lines · 31 KB
screen.cast records the exact screen, and a terminal window can watch it live.
16 The trace comes from the session file of the CLI.
17 headless `claude -p` or `codex exec` with a JSON event stream. Clean isolation uses this mode.
18
19 Secrets: the model key and the MCP token go through environment variables only.
20 They are never written to a file or put on a command line.
21
22 Codex sends its model requests through a local recorder (request_recorder.py), which writes the
23 request bodies, but not the headers, to requests.jsonl. Set COMPARE_RECORD_REQUESTS=0 to turn it off.
24 """
25
26 import argparse
27 import hashlib
28 import json
29 import os
30 import re
31 import shutil
32 import subprocess
33 import sys
34 import threading
35 import time
36 from datetime import datetime, timezone
37 from pathlib import Path
38 from urllib.parse import urlparse
39
40 from live import enabled as live_enabled
41 from live import open_log_window, open_terminal
42 from n8n_api import DEFAULT_BASE_URL , load_token, setting
43 from request_recorder import RequestRecorder
44
45 SKILL_DIR = Path( __file__ ).resolve().parents[ 1 ]
46 IMAGE = 'n8n-builder-sandbox'
47 # The builder-specific name comes first, so the CLI can use a different key than
48 # the ANTHROPIC_API_KEY or OPENAI_API_KEY that the instance reads from .env.local.
49 KEY_SETTINGS = { 'claude' : [ 'CLAUDE_API_KEY' , 'ANTHROPIC_API_KEY' ], 'codex' : [ 'CODEX_API_KEY' , 'OPENAI_API_KEY' ]}
50 # Codex features that add tools or downloads beyond the n8n MCP server. shell_snapshot is off
51 # because it writes the environment, with the API key, to a file.
52 CODEX_FEATURES_OFF = [ 'apps' , 'browser_use' , 'browser_use_external' , 'computer_use' , 'image_generation' ,
53 'in_app_browser' , 'plugins' , 'remote_plugin' , 'shell_snapshot' ]
54 SCREEN = { 'cols' : 120 , 'rows' : 36 }
55 OPENAI_BASE_URL = 'https://api.openai.com/v1'
56 # Interactive mode: the variable that the CLI reads the model key from.
57 SANDBOX_KEY = { 'claude' : 'CLAUDE_SANDBOX_KEY' , 'codex' : 'CODEX_SANDBOX_KEY' }
58 IDLE_LIMIT_S = 600
59
60
61 def now_iso ():
62 return datetime.now(timezone.utc).isoformat()
63
64
65 def ensure_image (rebuild):
66 # The label holds a hash of the Dockerfile, so an edit to the Dockerfile rebuilds the image.
67 digest = hashlib.sha256(( SKILL_DIR / 'sandbox' / 'Dockerfile' ).read_bytes()).hexdigest()[: 16 ]
68 found = subprocess.run([ 'docker' , 'image' , 'inspect' , '--format' ,
69 ' {{ index .Config.Labels "dockerfile-sha" }} ' , IMAGE ], capture_output = True , text = True )
70 if found.returncode == 0 and found.stdout.strip() == digest and not rebuild:
71 return
72 print ( f 'building docker image { IMAGE } ' , flush = True )
73 subprocess.run([ 'docker' , 'build' , '--label' , f 'dockerfile-sha= { digest } ' , '-t' , IMAGE ,
74 str ( SKILL_DIR / 'sandbox' )], check = True )
75
76
77 def mcp_url (base_url, isolation):
78 if isolation != 'container' :
79 return f ' { base_url } /mcp-server/http'
80 parsed = urlparse(base_url)
81 port = parsed.port or ( 443 if parsed.scheme == 'https' else 80 )
82 host = 'host.docker.internal' if parsed.hostname in ( 'localhost' , '127.0.0.1' ) else parsed.hostname
83 return f ' { parsed.scheme } :// { host } : { port } /mcp-server/http'
84
85
86 def claude_tool_args (tools):
87 # 'default' gives the normal Claude Code tools, all allowed without a question, like Codex in the
88 # container. Only a container may use it, because Bash and Write can change the files they see.
89 if tools == 'default' :
90 return [ '--permission-mode' , 'bypassPermissions' ]
91 # dontAsk allows the pre-approved n8n tools and denies the rest without a prompt.
92 return [ '--tools' , '' , '--allowedTools' , 'mcp__n8n' , '--permission-mode' , 'dontAsk' ]
93
94
95 def claude_args (prompt, model, config_path, bare, tools = 'none' ):
96 # --bare also replaces the system prompt with a minimal one. A container already isolates the CLI,
97 # so only the clean mode, which runs on the host, uses it.
98 args = [
99 'claude' , '-p' , prompt, * ([ '--bare' ] if bare else []),
100 '--strict-mcp-config' , '--mcp-config' , config_path, * claude_tool_args(tools),
101 '--disable-slash-commands' , '--output-format' , 'stream-json' , '--verbose' ,
102 ]
103 if model:
104 args += [ '--model' , model]
105 return args
106
107
108 def claude_interactive_args (model, tools = 'none' ):
109 args = [
110 'claude' , '--strict-mcp-config' , '--mcp-config' , '/out/mcp.json' , * claude_tool_args(tools),
111 '--disable-slash-commands' , '--settings' , '/out/.screen/claude-settings.json' ,
112 ]
113 if model:
114 args += [ '--model' , model]
115 return args
116
117
118 def codex_args (prompt, model):
119 args = [ 'codex' , 'exec' , '--json' , '--skip-git-repo-check' , '--sandbox' , 'read-only' ]
120 if model:
121 args += [ '--model' , model]
122 return args + [prompt]
123
124
125 def codex_interactive_args (model):
126 # The background app server needs `ps`, which the slim image does not have.
127 args = [ 'codex' , '--no-daemon' ]
128 if model:
129 args += [ '--model' , model]
130 return args
131
132
133 def codex_effort_setting ():
134 return setting( 'CODEX_REASONING_EFFORT' , default = 'medium' )
135
136
137 def run_effort (builder, session_file, out):
138 """The reasoning effort that the model got: from the session file for Claude Code, and from the
139 recorded request for Codex. Without a recorded request, the Codex effort is the configured one."""
140 if builder == 'claude' :
141 if session_file and session_file.exists():
142 for line in reversed (session_file.read_text().splitlines()):
143 try :
144 row = json.loads(line)
145 except json.JSONDecodeError:
146 continue
147 if row.get( 'type' ) == 'assistant' and row.get( 'effort' ):
148 return row[ 'effort' ], 'session file'
149 return None , None
150 requests = out / 'requests.jsonl'
151 if requests.exists():
152 for line in requests.read_text().splitlines():
153 try :
154 body = json.loads(line)
155 except json.JSONDecodeError:
156 continue
157 effort = ((body.get( 'body' , body)).get( 'reasoning' ) or {}).get( 'effort' )
158 if effort:
159 return effort, 'model request'
160 return codex_effort_setting(), 'CODEX_REASONING_EFFORT'
161
162
163 def write_codex_config (home, url, key_env = None , api_base = OPENAI_BASE_URL ):
164 """Write config.toml. With key_env, a provider reads the key from that variable, so the
165 interactive screen does not ask for a sign-in and no key is stored in auth.json."""
166 codex_home = home / '.codex'
167 codex_home.mkdir( parents = True , exist_ok = True )
168 lines = [ 'approval_policy = "never"' , 'sandbox_mode = "read-only"' ,
169 f 'model_reasoning_effort = " { codex_effort_setting() } "' ]
170 if key_env:
171 lines.append( 'model_provider = "sandbox-openai"' )
172 lines += [ '' , '[features]' ] + [ f ' { name } = false' for name in CODEX_FEATURES_OFF ]
173 if key_env:
174 lines += [ '' , '[model_providers.sandbox-openai]' , 'name = "OpenAI"' ,
175 f 'base_url = " { api_base } "' , f 'env_key = " { key_env } "' , 'wire_api = "responses"' ]
176 lines += [ '' , '[projects."/out/work"]' , 'trust_level = "trusted"' ,
177 '' , '[mcp_servers.n8n]' , f 'url = " { url } "' , 'bearer_token_env_var = "N8N_MCP_TOKEN"' , '' ]
178 (codex_home / 'config.toml' ).write_text( ' \n ' .join(lines))
179
180
181 def write_claude_interactive_config (out, home, tools):
182 # Skip the first-run screens: theme, onboarding and the folder trust question.
183 (home / '.claude' / '.claude.json' ).write_text(json.dumps({
184 'hasCompletedOnboarding' : True , 'theme' : 'dark' ,
185 'projects' : { '/out/work' : { 'hasTrustDialogAccepted' : True , 'hasCompletedProjectOnboarding' : True }},
186 }))
187 # With ANTHROPIC_API_KEY the screen asks to confirm the key and shows part of it.
188 # apiKeyHelper reads the key from the environment without that question.
189 settings = { 'apiKeyHelper' : f 'printenv { SANDBOX_KEY [ "claude" ] } ' }
190 if tools == 'default' :
191 # The bypass-mode warning screen also has a ❯ marker, so the prompt would answer "No, exit".
192 settings[ 'skipDangerousModePermissionPrompt' ] = True
193 (out / '.screen' / 'claude-settings.json' ).write_text(json.dumps(settings))
194
195
196 def live_line (builder, event):
197 """One short terminal line for an interesting stream event, or None."""
198 if builder == 'claude' :
199 if event.get( 'subtype' ) == 'permission_denied' :
200 return f 'denied { event.get( "tool_name" ) } '
201 if event.get( 'type' ) == 'assistant' :
202 for block in (event.get( 'message' ) or {}).get( 'content' ) or []:
203 if block.get( 'type' ) == 'tool_use' :
204 return f 'tool { block.get( "name" ) } '
205 if block.get( 'type' ) == 'text' and block.get( 'text' , '' ).strip():
206 return 'text ' + block[ 'text' ].strip().splitlines()[ 0 ][: 120 ]
207 if event.get( 'type' ) == 'result' :
208 return f 'result { event.get( "subtype" ) } turns= { event.get( "num_turns" ) } '
209 else :
210 item = event.get( 'item' ) or {}
211 if event.get( 'type' ) == 'item.started' and item.get( 'type' ) == 'mcp_tool_call' :
212 return f 'tool { item.get( "server" ) } . { item.get( "tool" ) } '
213 if event.get( 'type' ) == 'item.completed' and item.get( 'type' ) == 'agent_message' :
214 return 'text ' + (item.get( 'text' ) or '' ).strip()[: 120 ]
215 if event.get( 'type' ) in ( 'turn.completed' , 'turn.failed' , 'error' ):
216 return event[ 'type' ]
217 return None
218
219
220 def session_lines (builder, row):
221 """Short terminal lines for one session-file row, and the end state: None, 'done' or 'error'."""
222 lines = []
223 if builder == 'claude' :
224 if row.get( 'type' ) != 'assistant' :
225 return lines, None
226 msg = row.get( 'message' ) or {}
227 for block in msg.get( 'content' ) or []:
228 if block.get( 'type' ) == 'tool_use' :
229 lines.append( f 'tool { block.get( "name" ) } ' )
230 elif block.get( 'type' ) == 'text' and block.get( 'text' , '' ).strip():
231 lines.append( 'text ' + block[ 'text' ].strip().splitlines()[ 0 ][: 120 ])
232 if row.get( 'isApiErrorMessage' ):
233 return lines, 'error'
234 return lines, 'done' if msg.get( 'stop_reason' ) == 'end_turn' else None
235 payload = row.get( 'payload' ) or {}
236 if row.get( 'type' ) != 'event_msg' :
237 return lines, None
238 kind, item = payload.get( 'type' ), payload.get( 'item' ) or {}
239 if kind == 'item_completed' and item.get( 'type' ) == 'McpToolCall' :
240 lines.append( f 'tool { item.get( "server" ) } . { item.get( "tool" ) } ' )
241 elif kind == 'item_completed' and item.get( 'type' ) == 'AgentMessage' :
242 text = ' ' .join(c.get( 'text' , '' ) for c in item.get( 'content' ) or [] if isinstance (c, dict ))
243 lines.append( 'text ' + text.strip()[: 120 ])
244 elif kind == 'error' :
245 lines.append( f 'error { str (payload.get( "message" ))[: 200 ] } ' )
246 return lines, { 'task_complete' : 'done' , 'turn_aborted' : 'error' }.get(kind)
247
248
249 BOLD , DIM , CYAN , GREEN , RED , RESET = ' \033 [1m' , ' \033 [2m' , ' \033 [36m' , ' \033 [32m' , ' \033 [31m' , ' \033 [0m'
250 LABELS = { 'claude' : 'Claude Code + n8n MCP' , 'codex' : 'Codex + n8n MCP' }
251
252
253 def short (value, limit = 400 ):
254 """Readable one-line text for a tool input or result."""
255 if isinstance (value, dict ) and isinstance (value.get( 'content' ), list ):
256 value = value[ 'content' ]
257 if isinstance (value, list ) and all ( isinstance (v, dict ) and 'text' in v for v in value):
258 value = ' ' .join(v[ 'text' ] for v in value)
259 text = value if isinstance (value, str ) else json.dumps(value, ensure_ascii = False )
260 text = ' ' .join(text.split())
261 return text if len (text) <= limit else text[:limit] + ' …'
262
263
264 def log_entries (builder, event):
265 """Detailed, colored lines for live.log, the file that the headless live window follows."""
266 lines = []
267 if builder == 'claude' :
268 # A permission_denied event has a string message, not an API message.
269 if event.get( 'subtype' ) == 'permission_denied' :
270 lines.append( f ' { RED } denied { event.get( "tool_name" ) }{ RESET } ' )
271 message = event.get( 'message' )
272 content = message.get( 'content' ) if isinstance (message, dict ) else None
273 for block in content if isinstance (content, list ) else []:
274 kind = block.get( 'type' )
275 if kind == 'thinking' and block.get( 'thinking' , '' ).strip():
276 lines.append( f ' { DIM } thinking: { short(block[ "thinking" ], 600 ) }{ RESET } ' )
277 elif kind == 'text' and block.get( 'text' , '' ).strip():
278 lines.append(block[ 'text' ].strip())
279 elif kind == 'tool_use' :
280 name = block.get( 'name' , '' ).removeprefix( 'mcp__n8n__' )
281 lines.append( f ' { BOLD }{ CYAN } → { name }{ RESET } { short(block.get( "input" )) } ' )
282 elif kind == 'tool_result' :
283 color = RED if block.get( 'is_error' ) else DIM
284 lines.append( f ' { color } ← { short(block.get( "content" )) }{ RESET } ' )
285 if event.get( 'type' ) == 'result' :
286 cost = event.get( 'total_cost_usd' )
287 lines.append( f ' { GREEN } done: { event.get( "subtype" ) } , { event.get( "num_turns" ) } turns'
288 + ( f ', $ { cost :.2f} (Claude Code estimate)' if cost is not None else '' ) + RESET )
289 else :
290 item, etype = event.get( 'item' ) or {}, event.get( 'type' )
291 kind = item.get( 'type' )
292 if etype == 'item.started' and kind == 'mcp_tool_call' :
293 lines.append( f ' { BOLD }{ CYAN } → { item.get( "tool" ) }{ RESET } { short(item.get( "arguments" )) } ' )
294 elif etype == 'item.completed' and kind == 'mcp_tool_call' :
295 if item.get( 'error' ):
296 lines.append( f ' { RED } ← { short(item[ "error" ]) }{ RESET } ' )
297 else :
298 lines.append( f ' { DIM } ← { short(item.get( "result" )) }{ RESET } ' )
299 elif etype == 'item.completed' and kind == 'reasoning' and item.get( 'text' ):
300 lines.append( f ' { DIM } thinking: { short(item[ "text" ], 600 ) }{ RESET } ' )
301 elif etype == 'item.completed' and kind == 'agent_message' and item.get( 'text' ):
302 lines.append(item[ 'text' ].strip())
303 elif etype == 'turn.completed' :
304 lines.append( f ' { GREEN } done: turn completed { RESET } ' )
305 elif etype in ( 'turn.failed' , 'error' ):
306 lines.append( f ' { RED }{ etype } : { short(event.get( "error" ) or event.get( "message" )) }{ RESET } ' )
307 return lines
308
309
310 WORKFLOW_ID = re.compile( r ' (?: workflowId | WORKFLOW_ID ) \\ * " ? \s * [ := ]\s * \\ * " ? ([ A-Za-z0-9 ] {10,} ) ' )
311
312
313 def session_candidates (builder, home):
314 if builder == 'claude' :
315 # Subagent transcripts are in deeper folders. The main session is at projects/<cwd>/<id>.jsonl.
316 root = home / '.claude' / 'projects'
317 return list (root.glob( '*/*.jsonl' )) if root.exists() else []
318 root = home / '.codex' / 'sessions'
319 return list (root.rglob( 'rollout-*.jsonl' )) if root.exists() else []
320
321
322 def find_session_file (builder, home, session_id = None ):
323 matches = session_candidates(builder, home)
324 if session_id:
325 matches = [p for p in matches if session_id in p.name] or matches
326 matches.sort( key =lambda p: p.stat().st_mtime)
327 return matches[ - 1 ] if matches else None
328
329
330 class SessionReader :
331 """Reads the rows that the CLI appends to its session file."""
332
333 def __init__ (self, builder, home):
334 self .builder, self .home, self .path, self .offset = builder, home, None , 0
335
336 def rows (self):
337 self .path = self .path or find_session_file( self .builder, self .home)
338 if not self .path:
339 return []
340 with self .path.open( 'rb' ) as f:
341 f.seek( self .offset)
342 data = f.read()
343 end = data.rfind( b ' \n ' ) + 1
344 self .offset += end
345 rows = []
346 for line in data[:end].splitlines():
347 try :
348 rows.append(json.loads(line))
349 except json.JSONDecodeError:
350 continue
351 return rows
352
353
354 def turn_span (builder, session_file):
355 """Start and end of the model turn in the session file, without container start-up, the prompt paste
356 wait and shutdown. (None, None) when the file has no turn."""
357 if not session_file or not session_file.exists():
358 return None , None
359 rows = []
360 for line in session_file.read_text().splitlines():
361 try :
362 rows.append(json.loads(line))
363 except json.JSONDecodeError:
364 continue
365 if builder == 'claude' :
366 users = [r[ 'timestamp' ] for r in rows if r.get( 'type' ) == 'user' and r.get( 'timestamp' )]
367 replies = [r[ 'timestamp' ] for r in rows if r.get( 'type' ) == 'assistant' and r.get( 'timestamp' )]
368 return (users[ 0 ], replies[ - 1 ]) if users and replies else ( None , None )
369 starts = [r[ 'timestamp' ] for r in rows
370 if r.get( 'type' ) == 'event_msg' and (r.get( 'payload' ) or {}).get( 'type' ) == 'task_started' ]
371 ends = [r[ 'timestamp' ] for r in rows
372 if r.get( 'type' ) == 'event_msg' and (r.get( 'payload' ) or {}).get( 'type' ) == 'task_complete' ]
373 return (starts[ 0 ], ends[ - 1 ]) if starts and ends else ( None , None )
374
375
376 def docker_command (container, out, env_names):
377 cmd = [
378 'docker' , 'run' , '--rm' , '-i' , '--name' , container,
379 '--user' , f ' { os.getuid() } : { os.getgid() } ' ,
380 '--cap-drop' , 'ALL' , '--security-opt' , 'no-new-privileges' ,
381 '--read-only' , '--tmpfs' , '/tmp' ,
382 '--add-host' , 'host.docker.internal:host-gateway' ,
383 '-v' , f ' { out } :/out' , '-w' , '/out/work' ,
384 '-e' , 'HOME=/out/home' ,
385 '-e' , 'CLAUDE_CONFIG_DIR=/out/home/.claude' ,
386 '-e' , 'CODEX_HOME=/out/home/.codex' ,
387 '-e' , 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1' ,
388 '-e' , 'COLORTERM=truecolor' ,
389 ]
390 # `-e NAME` without a value copies the value from this process, so secrets stay off argv.
391 for name in env_names:
392 cmd += [ '-e' , name]
393 return cmd + [ IMAGE ]
394
395
396 def check_no_secrets (out, secrets):
397 """Warn if a secret value ended up in a file of the run directory. Never prints the value."""
398 needles = [s.encode() for s in secrets if s and len (s) >= 12 ]
399 for path in out.rglob( '*' ):
400 if not path.is_file() or path.stat().st_size > 50_000_000 :
401 continue
402 data = path.read_bytes()
403 if any (n in data for n in needles):
404 print ( f 'WARNING: a secret value is in { path } . Delete the file before you share the run.' , flush = True )
405
406
407 def run_headless (args, out, prompt, cmd, run_env, cwd, container):
408 stream_path = out / 'stream.jsonl'
409 session_id, workflow_ids, result, errors_in_row = None , [], None , 0
410 live_log = (out / 'live.log' ).open( 'w' , buffering = 1 )
411 live_log.write( f ' { BOLD }{ LABELS [args.builder] }{ RESET } model: { args.model or "CLI default" } , '
412 f 'isolation: { args.isolation }\n{ DIM } prompt: { short(prompt, 600 ) }{ RESET }\n\n ' )
413 open_log_window( LABELS [args.builder], out / 'live.log' )
414 # The Codex stream has no timestamps, so stream.times keeps the arrival time of each line.
415 with stream_path.open( 'w' ) as stream, (out / 'stream.times' ).open( 'w' ) as times, \
416 (out / 'stderr.log' ).open( 'w' ) as err, live_log:
417 proc = subprocess.Popen(cmd, env = run_env, cwd = cwd, stdin = subprocess. DEVNULL ,
418 stdout = subprocess. PIPE , stderr = err, text = True )
419 watchdog = threading.Timer(args.timeout_min * 60 , proc.kill)
420 watchdog.start()
421 for line in proc.stdout:
422 try :
423 event = json.loads(line)
424 except json.JSONDecodeError:
425 continue
426 stream.write(line)
427 stream.flush()
428 times.write( f ' { time.time() :.3f}\n ' )
429 if event.get( 'type' ) == 'system' and event.get( 'subtype' ) == 'init' :
430 session_id = event.get( 'session_id' )
431 if event.get( 'type' ) == 'thread.started' :
432 session_id = event.get( 'thread_id' )
433 if event.get( 'type' ) == 'result' :
434 result = event
435 for match in WORKFLOW_ID .findall(line):
436 if match not in workflow_ids:
437 workflow_ids.append(match)
438 text = live_line(args.builder, event)
439 if text:
440 print ( f '[ { args.builder } ] { text } ' , flush = True )
441 for entry in log_entries(args.builder, event):
442 live_log.write(entry + ' \n\n ' )
443 errors_in_row = errors_in_row + 1 if event.get( 'type' ) == 'error' else 0
444 if errors_in_row >= 10 :
445 print ( f '[ { args.builder } ] stopped after 10 errors in a row: { event.get( "message" , "" )[: 200 ] } ' , flush = True )
446 proc.kill()
447 break
448 proc.wait()
449 if not watchdog.is_alive():
450 print ( 'timeout: killed the builder' , flush = True )
451 watchdog.cancel()
452 if args.isolation == 'container' :
453 subprocess.run([ 'docker' , 'kill' , container], capture_output = True )
454 live_log.write( f ' { BOLD } === finished, exit code { proc.returncode } . You can close this window. === { RESET }\n ' )
455 return { 'exitCode' : proc.returncode, 'sessionId' : session_id, 'workflowIds' : workflow_ids,
456 'reportedCostUsd' : (result or {}).get( 'total_cost_usd' )}
457
458
459 # The review screen of a picker with several tabs has no footer, only the submit question.
460 QUESTION_MARKERS = ( 'Enter to select' , 'Ready to submit your answers?' )
461 ANSWER_SETTLE_S = 2
462
463
464 class QuestionAnswerer :
465 """Answers a question picker on the CLI screen with its first option, like the Instance AI runner.
466
467 The first option is the recommended one. A picker with several tabs gets one Enter per tab, then Submit.
468 """
469
470 def __init__ (self, container, log_path):
471 self .container, self .log_path, self .answers, self .seen_at, self .last_screen = container, log_path, [], None , None
472
473 def poll (self):
474 screen = subprocess.run([ 'docker' , 'exec' , self .container, 'tmux' , 'capture-pane' , '-p' , '-t' , 'run' ],
475 capture_output = True , text = True ).stdout
476 if not any (marker in screen for marker in QUESTION_MARKERS ):
477 self .last_screen, self .seen_at = None , None
478 return None
479 # Wait until the picker stops changing, so a key press does not land while it draws.
480 if screen != self .last_screen:
481 self .last_screen, self .seen_at = screen, time.time()
482 return None
483 if time.time() - self .seen_at < ANSWER_SETTLE_S :
484 return None
485 subprocess.run([ 'docker' , 'exec' , self .container, 'tmux' , 'send-keys' , '-t' , 'run' , 'Enter' ],
486 capture_output = True )
487 question = QUESTION_MARKERS [ 1 ] if QUESTION_MARKERS [ 1 ] in screen else next (
488 (l.strip() for l in screen.splitlines() if l.strip().endswith( '?' )), '' )
489 option = next ((l.strip().lstrip( '❯ ' ).strip() for l in screen.splitlines() if l.strip().startswith( '❯ 1.' )), '' )
490 self .answers.append({ 'at' : time.time(), 'question' : question, 'answer' : option or 'first option' })
491 self .log_path.write_text(json.dumps( self .answers, indent = 2 , ensure_ascii = False ))
492 self .last_screen, self .seen_at = None , None
493 return f 'answered " { question[: 80 ] } " with " { option[: 80 ] or "first option" } "'
494
495
496 def run_interactive (args, out, home, prompt, run_env, container):
497 screen_dir = out / '.screen'
498 argv = (claude_interactive_args(args.model, args.claude_tools) if args.builder == 'claude'
499 else codex_interactive_args(args.model))
500 # Claude Code shows its input box before the MCP servers connect, so it waits a few seconds more.
501 ready = { 'claude' : ( '❯' , 5000 ), 'codex' : ( '›' , 2000 )}[args.builder]
502 (screen_dir / 'command.json' ).write_text(json.dumps(
503 { 'argv' : argv, ** SCREEN , 'input' : prompt, 'readyText' : ready[ 0 ], 'settleMs' : ready[ 1 ]}))
504 cmd = docker_command(container, out, [n for n in run_env if n in ( SANDBOX_KEY [args.builder], 'N8N_MCP_TOKEN' )])
505 cmd += [ 'node' , '/out/.screen/screen.mjs' , 'run' , '/out/.screen' ]
506
507 reader, workflow_ids, state, timed_out = SessionReader(args.builder, home), [], None , False
508 with (out / 'stderr.log' ).open( 'w' ) as err:
509 proc = subprocess.Popen(cmd, env = run_env, stdin = subprocess. DEVNULL , stdout = err, stderr = err)
510 for _ in range ( 120 ):
511 if (screen_dir / 'screen.cast' ).exists() or proc.poll() is not None :
512 break
513 time.sleep( 0.25 )
514 watch = f 'docker exec -it { container } tmux attach -r -t run'
515 if live_enabled() and not open_terminal( LABELS [args.builder], watch, SCREEN [ 'cols' ], SCREEN [ 'rows' ]):
516 print ( f 'watch { LABELS [args.builder] } : { watch } ' , flush = True )
517 deadline, last_growth = time.time() + args.timeout_min * 60 , time.time()
518 answerer = QuestionAnswerer(container, out / 'answers.json' ) if args.isolation == 'container' else None
519 while proc.poll() is None and not state:
520 rows = reader.rows()
521 if rows:
522 last_growth = time.time()
523 answered = answerer.poll() if answerer else None
524 if answered:
525 print ( f '[ { args.builder } ] { answered } ' , flush = True )
526 last_growth = time.time()
527 for row in rows:
528 lines, row_state = session_lines(args.builder, row)
529 for line in lines:
530 print ( f '[ { args.builder } ] { line } ' , flush = True )
531 for match in WORKFLOW_ID .findall(json.dumps(row)):
532 if match not in workflow_ids:
533 workflow_ids.append(match)
534 state = state or row_state
535 if time.time() > deadline:
536 print ( 'timeout: stopped the builder' , flush = True )
537 timed_out = True
538 break
539 if reader.path and time.time() - last_growth > IDLE_LIMIT_S :
540 print ( f 'no progress for { IDLE_LIMIT_S // 60 } minutes: stopped the builder' , flush = True )
541 timed_out = True
542 break
543 time.sleep( 1 )
544 (screen_dir / 'stop' ).write_text( '' )
545 try :
546 proc.wait( timeout = 30 )
547 except subprocess.TimeoutExpired:
548 subprocess.run([ 'docker' , 'kill' , container], capture_output = True )
549 proc.wait()
550 for row in reader.rows():
551 for match in WORKFLOW_ID .findall(json.dumps(row)):
552 if match not in workflow_ids:
553 workflow_ids.append(match)
554 if (screen_dir / 'screen.cast' ).exists():
555 shutil.move(screen_dir / 'screen.cast' , out / 'screen.cast' )
556 # After a finished turn the driver stops tmux, so the exit code of the CLI means nothing.
557 exited = screen_dir / 'exited'
558 if state == 'done' and not timed_out:
559 code = 0
560 else :
561 code = int (exited.read_text() or 1 ) if exited.exists() else 1
562 code = code or 1
563 session = reader.path
564 return { 'exitCode' : code, 'sessionId' : session.stem.split( 'rollout-' )[ - 1 ] if session else None ,
565 'workflowIds' : workflow_ids, 'reportedCostUsd' : None }
566
567
568 def main ():
569 ap = argparse.ArgumentParser()
570 ap.add_argument( '--builder' , choices = [ 'claude' , 'codex' ], required = True )
571 ap.add_argument( '--out' , required = True )
572 ap.add_argument( '--prompt-file' , required = True )
573 ap.add_argument( '--isolation' , choices = [ 'container' , 'clean' ],
574 default = setting( 'COMPARE_ISOLATION' , default = 'container' ))
575 ap.add_argument( '--mode' , choices = [ 'interactive' , 'headless' ], default = setting( 'COMPARE_CLI_MODE' ),
576 help = 'Default: interactive in a container, headless in clean isolation.' )
577 ap.add_argument( '--model' , help = 'Model id. Default: CLAUDE_MODEL or CODEX_MODEL, else the CLI default.' )
578 ap.add_argument( '--key-env' , help = 'Name of the setting with the model API key.' )
579 ap.add_argument( '--claude-tools' , choices = [ 'none' , 'default' ],
580 default = setting( 'COMPARE_CLAUDE_TOOLS' , default = 'none' ),
581 help = 'none: only the n8n tools. default: also the built-in Claude Code tools, all allowed.' )
582 ap.add_argument( '--rebuild-image' , action = 'store_true' )
583 ap.add_argument( '--timeout-min' , type = float , default = 30 )
584 args = ap.parse_args()
585
586 if args.claude_tools not in ( 'none' , 'default' ):
587 sys.exit( f 'COMPARE_CLAUDE_TOOLS must be none or default, not { args.claude_tools !r} .' )
588 if args.builder == 'codex' and args.isolation != 'container' :
589 sys.exit( 'Codex runs only in a container. Its shell tool can read the host disk.' )
590 if args.isolation != 'container' :
591 args.mode = 'headless'
592 if args.builder == 'claude' and args.claude_tools != 'none' :
593 print ( '[claude] clean isolation runs on the host, so COMPARE_CLAUDE_TOOLS=default is ignored.' , flush = True )
594 args.claude_tools = 'none'
595 args.mode = args.mode or 'interactive'
596
597 prefix = args.builder.upper()
598 args.model = args.model or setting( f ' { prefix } _MODEL' )
599 # Gateway-style ids such as anthropic/claude-opus-5-5 are not valid for the CLIs.
600 provider = { 'claude' : 'anthropic/' , 'codex' : 'openai/' }[args.builder]
601 if args.model and args.model.startswith(provider):
602 args.model = args.model.removeprefix(provider)
603 key_names = [args.key_env] if args.key_env else KEY_SETTINGS [args.builder]
604 api_key = setting( * key_names)
605 if not api_key:
606 sys.exit( f 'Set { " or " .join(key_names) } in .env.ai-surfaces, .env.local or the environment.' )
607 token = load_token()
608 if not token:
609 sys.exit( 'No MCP token. Run prepare.py first.' )
610
611 out = Path(args.out).resolve()
612 if out.exists():
613 shutil.rmtree(out)
614 work, home = out / 'work' , out / 'home'
615 work.mkdir( parents = True )
616 (home / '.claude' ).mkdir( parents = True )
617 prompt = Path(args.prompt_file).read_text().strip()
618 base_url = setting( 'N8N_BASE_URL' , default = DEFAULT_BASE_URL ).rstrip( '/' )
619 url = mcp_url(base_url, args.isolation)
620 container = f 'cwb- { args.builder } - { int (time.time()) } '
621 interactive = args.mode == 'interactive'
622
623 # Codex does not save the tools it sends to the model, so a local proxy records the request bodies.
624 recorder = None
625 if args.builder == 'codex' and setting( 'COMPARE_RECORD_REQUESTS' , default = '1' ) != '0' :
626 recorder = RequestRecorder( OPENAI_BASE_URL , out / 'requests.jsonl' ,
627 setting( 'COMPARE_RECORDER_BIND' , default = '127.0.0.1' )).start()
628 api_base = recorder.base_url( 'host.docker.internal' ) if recorder else OPENAI_BASE_URL
629
630 child_env = { 'N8N_MCP_TOKEN' : token}
631 if args.builder == 'claude' :
632 (out / 'mcp.json' ).write_text(json.dumps({ 'mcpServers' : { 'n8n' : {
633 'type' : 'http' , 'url' : url, 'headers' : { 'Authorization' : 'Bearer $ {N8N_MCP_TOKEN} ' },
634 }}}))
635 if interactive:
636 (out / '.screen' ).mkdir()
637 shutil.copy( SKILL_DIR / 'sandbox' / 'screen.mjs' , out / '.screen' / 'screen.mjs' )
638 child_env[ SANDBOX_KEY [args.builder]] = api_key
639 if args.builder == 'claude' :
640 write_claude_interactive_config(out, home, args.claude_tools)
641 else :
642 write_codex_config(home, url, SANDBOX_KEY [ 'codex' ], api_base)
643 elif args.builder == 'claude' :
644 child_env[ 'ANTHROPIC_API_KEY' ] = api_key
645 else :
646 child_env[ 'OPENAI_API_KEY' ] = api_key
647 child_env[ 'CODEX_API_KEY' ] = api_key
648 write_codex_config(home, url, 'CODEX_API_KEY' if recorder else None , api_base)
649
650 started_at = now_iso()
651 if args.isolation == 'container' :
652 ensure_image(args.rebuild_image)
653 run_env = { ** os.environ, ** child_env}
654 try :
655 if interactive:
656 result = run_interactive(args, out, home, prompt, run_env, container)
657 else :
658 cmd = docker_command(container, out, child_env)
659 cmd += (claude_args(prompt, args.model, '/out/mcp.json' , False , args.claude_tools)
660 if args.builder == 'claude' else codex_args(prompt, args.model))
661 result = run_headless(args, out, prompt, cmd, run_env, None , container)
662 finally :
663 if recorder:
664 recorder.stop()
665 if recorder and not recorder.count:
666 print ( f '[ { args.builder } ] the request recorder saw no requests, so the report has no Codex tools.' , flush = True )
667 else :
668 if not shutil.which( 'claude' ):
669 sys.exit( 'claude is not on PATH.' )
670 run_env = {
671 'PATH' : os.environ.get( 'PATH' , '/usr/bin:/bin' ),
672 'HOME' : str (home),
673 'CLAUDE_CONFIG_DIR' : str (home / '.claude' ),
674 'DISABLE_AUTOUPDATER' : '1' ,
675 'DISABLE_TELEMETRY' : '1' ,
676 ** child_env,
677 }
678 cmd = claude_args(prompt, args.model, str (out / 'mcp.json' ), True )
679 result = run_headless(args, out, prompt, cmd, run_env, str (work), container)
680
681 session_file = find_session_file(args.builder, home, result[ 'sessionId' ])
682 turn_started_at, turn_finished_at = turn_span(args.builder, session_file)
683 effort, effort_source = run_effort(args.builder, session_file, out)
684 meta = {
685 'kind' : args.builder,
686 'isolation' : args.isolation,
687 'mode' : args.mode,
688 'model' : args.model,
689 ** ({ 'claudeTools' : args.claude_tools} if args.builder == 'claude' else {}),
690 'startedAt' : started_at,
691 'finishedAt' : now_iso(),
692 'turnStartedAt' : turn_started_at,
693 'turnFinishedAt' : turn_finished_at,
694 'effort' : effort,
695 'effortSource' : effort_source,
696 'exitCode' : result[ 'exitCode' ],
697 'sessionId' : result[ 'sessionId' ],
698 'sessionFile' : str (session_file.relative_to(out)) if session_file else None ,
699 'workflowIds' : result[ 'workflowIds' ],
700 'workflowId' : result[ 'workflowIds' ][ - 1 ] if result[ 'workflowIds' ] else None ,
701 'reportedCostUsd' : result[ 'reportedCostUsd' ],
702 'screen' : 'screen.cast' if (out / 'screen.cast' ).exists() else None ,
703 'requests' : 'requests.jsonl' if recorder and recorder.count else None ,
704 }
705 (out / 'run.json' ).write_text(json.dumps(meta, indent = 2 ))
706 shutil.rmtree(home / '.codex' / 'shell_snapshots' , ignore_errors = True )
707 check_no_secrets(out, [api_key, token])
708 print (json.dumps({k: meta[k] for k in ( 'exitCode' , 'sessionId' , 'workflowId' , 'sessionFile' , 'screen' )}, indent = 2 ))
709 if meta[ 'exitCode' ]:
710 print ( f 'see { out / "stderr.log" } ' , flush = True )
711
712
713 if __name__ == '__main__' :
714 main()