feat(cli): --repo flag clones a template repo and applies spawn.md - #3360

Merged
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag
Apr 25, 2026
Merged

feat(cli): --repo flag clones a template repo and applies spawn.md#3360
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag

Conversation

@AhmedTMM

Copy link
Copy Markdown
Collaborator

Summary

Adds `--repo user/template` to the spawn CLI. Clones the named GitHub repo to `~/project` on the VM, parses `spawn.md` (YAML frontmatter) at the repo root, and applies it as a setup contract before handing off to the agent.

```
spawn --repo user/my-template
```

What spawn.md can declare

  • `setup` — custom auth steps:
    • `oauth` — open a URL locally, wait for Enter
    • `cli_auth` — run an auth command on the VM (e.g. `vercel login`)
    • `api_key` — no-echo prompt → base64 → `/etc/spawn/secrets` (sourced from `~/.bashrc`)
    • `command` — run anything on the VM
  • `mcp_servers` — MCP entries with `${NAME}` placeholders for env values (so the template repo never holds secrets). Routes through the existing `skills.ts` install helpers — Claude → `/.claude/settings.json`, Cursor → `/.cursor/mcp.json`, Codex → `/.codex/config.toml` (new TOML installer), generic → `/./mcp.json`.
  • `setup_commands` — shell commands run inside `~/project`.

Built-in steps (github auth, auto-update, security-scan, etc.) stay on the CLI `--steps` flag — `spawn.md` only handles the custom-setup parts Spawn doesn't know about natively.

Launch behavior

If the clone succeeds, the agent launches with `cd ~/project && <agent.launchCmd>` so the user lands in their template's working directory. `saveLaunchCmd` persists this, so `spawn last` reconnects into the same dir. Invalid slugs / clone failures fall back to the standard launch — no broken-cd footgun.

Test plan

  • `bunx @biomejs/biome check src/` — 0 errors
  • `bunx tsc --noEmit -p .` — 0 production errors
  • `bun test` — 2169 pass, same 4 pre-existing failures as upstream/main (network/DNS, update-check)
  • New unit tests: `spawn-md.test.ts` (parser correctness, schema validation, malformed input)
  • `unknown-flags.test.ts` updated to include `--repo`
  • End-to-end: clone a template repo containing `spawn.md` + verify MCP servers + setup steps land
  • Verify reconnect (`spawn last`) still cd's into `~/project`

🤖 Generated with Claude Code

AhmedTMMand others added 2 commits April 24, 2026 23:28
 spawn <agent> <cloud> --repo user/template
Clones https://github.com/user/template.git to ~/project on the VM,
parses spawn.md (YAML frontmatter), and applies its custom-setup
contract:
- `setup`: oauth (open URL + wait for Enter), cli_auth (run on VM),
api_key (no-echo prompt → /etc/spawn/secrets, sourced from .bashrc),
command (run on VM)
- `mcp_servers`: env values stay as ${NAME} placeholders so secrets
never end up in the template repo. Replay routes through the
existing skills.ts helpers (Claude settings.json, Cursor mcp.json,
Codex config.toml) — no `node -e` injection.
- `setup_commands`: run inside ~/project
When the clone succeeds, the agent launches with `cd ~/project && ...`
so the user lands in their template's working directory. Reconnect via
`spawn last` replays the same launchCmd.
Built-in steps (github auth, auto-update, etc.) stay in the CLI
--steps flag — spawn.md only handles custom setup that Spawn doesn't
know about natively.
Bumps CLI to 1.0.22.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@la14-1la14-1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review

Verdict: APPROVE
Commit: 24cbdad

Threat model

The --repo user/template flag explicitly opts the user in to running arbitrary shell from a third-party GitHub repo (setup_commands, cli_auth.command, command.command are all unsandboxed). This is the feature, not a vulnerability — a user invoking spawn claude hetzner --repo X/Y is consenting to let X/Y's spawn.md drive setup. Reviewed under that threat model.

What I verified

  • Slug regex^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$ is tight — no shell metacharacters, no @ for host override, no ?/& for query injection. Clone URL always https://github.com/<slug>.git. ✅
  • openBrowser uses argv arrays, not shell — oauth.url and api_key.guide_url cannot trigger RCE via URL-shaped payloads. ✅
  • TOML writer (tomlString) escapes \ and " correctly for TOML basic strings. ✅
  • SpawnMdSchema via valibot rejects unknown type values and missing required fields — malformed spawn.md returns null ("ignored with warning"), not thrown. ✅
  • Parse-loose / validate-strict pattern for the hand-rolled YAML parser: parseYamlFrontmatter never throws; valibot is the gate. Reasonable given the frontmatter subset used. ✅

Findings

SevFileIssue
MEDIUMspawn-md.ts:394Deferred shell injection via /etc/spawn/secrets when an api_key value contains " or newline — the file is later sourced from ~/.bashrc, so corruption becomes code execution on next login. Self-inflicted, but the escaping is wrong.
LOWskills.ts:322tomlString handles \ and " but not raw newlines — multi-line MCP env values produce invalid TOML for Codex. Functional corruption, not RCE.
LOWspawn-md.ts:270/tmp/spawn-capture-${Date.now()} — predictable path, shared-tmp race risk. Inert on single-user VMs.
NOTEspawn.md env: { VAR: "${NAME}" }Placeholders written literally into agent MCP configs. Claude/Codex MCP clients don't expand env vars in mcp_servers.*.env — users will see literal ${NAME} strings passed to the MCP server. Functional, not security.

Recommended follow-up (MEDIUM)

Do not write user-supplied values into a shell-sourceable file. Either (a) keep values base64-encoded at rest and decode at source-time via a wrapper, or (b) use a dotenv-style loader with a parser that does not re-interpret quotes. Keeping the value encoded eliminates quote-escape concerns entirely.

Tests

  • bunx @biomejs/biome check src/ — 195 files, no issues
  • bun test — 2112 pass, 0 fail (+4 new spawn-md tests)
  • bunx tsc --noEmit -p . — 5 pre-existing errors in update-check.test.ts and daytona.ts, all present on main, none introduced by this PR

Per security protocol: MEDIUM + LOW → APPROVE. Filing the escape-injection finding as a follow-up.


-- security/pr-reviewer

Comment threadpackages/cli/src/shared/spawn-md.ts
Comment threadpackages/cli/src/shared/skills.ts
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AhmedTMM@la14-1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(cli): --repo flag clones a template repo and applies spawn.md - #3360

Merged
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag
Apr 25, 2026
Merged

feat(cli): --repo flag clones a template repo and applies spawn.md#3360
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag

Conversation

@AhmedTMM

Copy link
Copy Markdown
Collaborator

Summary

Adds `--repo user/template` to the spawn CLI. Clones the named GitHub repo to `~/project` on the VM, parses `spawn.md` (YAML frontmatter) at the repo root, and applies it as a setup contract before handing off to the agent.

```
spawn --repo user/my-template
```

What spawn.md can declare

  • `setup` — custom auth steps:
    • `oauth` — open a URL locally, wait for Enter
    • `cli_auth` — run an auth command on the VM (e.g. `vercel login`)
    • `api_key` — no-echo prompt → base64 → `/etc/spawn/secrets` (sourced from `~/.bashrc`)
    • `command` — run anything on the VM
  • `mcp_servers` — MCP entries with `${NAME}` placeholders for env values (so the template repo never holds secrets). Routes through the existing `skills.ts` install helpers — Claude → `/.claude/settings.json`, Cursor → `/.cursor/mcp.json`, Codex → `/.codex/config.toml` (new TOML installer), generic → `/./mcp.json`.
  • `setup_commands` — shell commands run inside `~/project`.

Built-in steps (github auth, auto-update, security-scan, etc.) stay on the CLI `--steps` flag — `spawn.md` only handles the custom-setup parts Spawn doesn't know about natively.

Launch behavior

If the clone succeeds, the agent launches with `cd ~/project && <agent.launchCmd>` so the user lands in their template's working directory. `saveLaunchCmd` persists this, so `spawn last` reconnects into the same dir. Invalid slugs / clone failures fall back to the standard launch — no broken-cd footgun.

Test plan

  • `bunx @biomejs/biome check src/` — 0 errors
  • `bunx tsc --noEmit -p .` — 0 production errors
  • `bun test` — 2169 pass, same 4 pre-existing failures as upstream/main (network/DNS, update-check)
  • New unit tests: `spawn-md.test.ts` (parser correctness, schema validation, malformed input)
  • `unknown-flags.test.ts` updated to include `--repo`
  • End-to-end: clone a template repo containing `spawn.md` + verify MCP servers + setup steps land
  • Verify reconnect (`spawn last`) still cd's into `~/project`

🤖 Generated with Claude Code

AhmedTMMand others added 2 commits April 24, 2026 23:28
 spawn <agent> <cloud> --repo user/template
Clones https://github.com/user/template.git to ~/project on the VM,
parses spawn.md (YAML frontmatter), and applies its custom-setup
contract:
- `setup`: oauth (open URL + wait for Enter), cli_auth (run on VM),
api_key (no-echo prompt → /etc/spawn/secrets, sourced from .bashrc),
command (run on VM)
- `mcp_servers`: env values stay as ${NAME} placeholders so secrets
never end up in the template repo. Replay routes through the
existing skills.ts helpers (Claude settings.json, Cursor mcp.json,
Codex config.toml) — no `node -e` injection.
- `setup_commands`: run inside ~/project
When the clone succeeds, the agent launches with `cd ~/project && ...`
so the user lands in their template's working directory. Reconnect via
`spawn last` replays the same launchCmd.
Built-in steps (github auth, auto-update, etc.) stay in the CLI
--steps flag — spawn.md only handles custom setup that Spawn doesn't
know about natively.
Bumps CLI to 1.0.22.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@la14-1la14-1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review

Verdict: APPROVE
Commit: 24cbdad

Threat model

The --repo user/template flag explicitly opts the user in to running arbitrary shell from a third-party GitHub repo (setup_commands, cli_auth.command, command.command are all unsandboxed). This is the feature, not a vulnerability — a user invoking spawn claude hetzner --repo X/Y is consenting to let X/Y's spawn.md drive setup. Reviewed under that threat model.

What I verified

  • Slug regex^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$ is tight — no shell metacharacters, no @ for host override, no ?/& for query injection. Clone URL always https://github.com/<slug>.git. ✅
  • openBrowser uses argv arrays, not shell — oauth.url and api_key.guide_url cannot trigger RCE via URL-shaped payloads. ✅
  • TOML writer (tomlString) escapes \ and " correctly for TOML basic strings. ✅
  • SpawnMdSchema via valibot rejects unknown type values and missing required fields — malformed spawn.md returns null ("ignored with warning"), not thrown. ✅
  • Parse-loose / validate-strict pattern for the hand-rolled YAML parser: parseYamlFrontmatter never throws; valibot is the gate. Reasonable given the frontmatter subset used. ✅

Findings

SevFileIssue
MEDIUMspawn-md.ts:394Deferred shell injection via /etc/spawn/secrets when an api_key value contains " or newline — the file is later sourced from ~/.bashrc, so corruption becomes code execution on next login. Self-inflicted, but the escaping is wrong.
LOWskills.ts:322tomlString handles \ and " but not raw newlines — multi-line MCP env values produce invalid TOML for Codex. Functional corruption, not RCE.
LOWspawn-md.ts:270/tmp/spawn-capture-${Date.now()} — predictable path, shared-tmp race risk. Inert on single-user VMs.
NOTEspawn.md env: { VAR: "${NAME}" }Placeholders written literally into agent MCP configs. Claude/Codex MCP clients don't expand env vars in mcp_servers.*.env — users will see literal ${NAME} strings passed to the MCP server. Functional, not security.

Recommended follow-up (MEDIUM)

Do not write user-supplied values into a shell-sourceable file. Either (a) keep values base64-encoded at rest and decode at source-time via a wrapper, or (b) use a dotenv-style loader with a parser that does not re-interpret quotes. Keeping the value encoded eliminates quote-escape concerns entirely.

Tests

  • bunx @biomejs/biome check src/ — 195 files, no issues
  • bun test — 2112 pass, 0 fail (+4 new spawn-md tests)
  • bunx tsc --noEmit -p . — 5 pre-existing errors in update-check.test.ts and daytona.ts, all present on main, none introduced by this PR

Per security protocol: MEDIUM + LOW → APPROVE. Filing the escape-injection finding as a follow-up.


-- security/pr-reviewer

Comment threadpackages/cli/src/shared/spawn-md.ts
Comment threadpackages/cli/src/shared/skills.ts
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AhmedTMM@la14-1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(cli): --repo flag clones a template repo and applies spawn.md - #3360

Merged
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag
Apr 25, 2026
Merged

feat(cli): --repo flag clones a template repo and applies spawn.md#3360
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag

Conversation

@AhmedTMM

Copy link
Copy Markdown
Collaborator

Summary

Adds `--repo user/template` to the spawn CLI. Clones the named GitHub repo to `~/project` on the VM, parses `spawn.md` (YAML frontmatter) at the repo root, and applies it as a setup contract before handing off to the agent.

```
spawn --repo user/my-template
```

What spawn.md can declare

  • `setup` — custom auth steps:
    • `oauth` — open a URL locally, wait for Enter
    • `cli_auth` — run an auth command on the VM (e.g. `vercel login`)
    • `api_key` — no-echo prompt → base64 → `/etc/spawn/secrets` (sourced from `~/.bashrc`)
    • `command` — run anything on the VM
  • `mcp_servers` — MCP entries with `${NAME}` placeholders for env values (so the template repo never holds secrets). Routes through the existing `skills.ts` install helpers — Claude → `/.claude/settings.json`, Cursor → `/.cursor/mcp.json`, Codex → `/.codex/config.toml` (new TOML installer), generic → `/./mcp.json`.
  • `setup_commands` — shell commands run inside `~/project`.

Built-in steps (github auth, auto-update, security-scan, etc.) stay on the CLI `--steps` flag — `spawn.md` only handles the custom-setup parts Spawn doesn't know about natively.

Launch behavior

If the clone succeeds, the agent launches with `cd ~/project && <agent.launchCmd>` so the user lands in their template's working directory. `saveLaunchCmd` persists this, so `spawn last` reconnects into the same dir. Invalid slugs / clone failures fall back to the standard launch — no broken-cd footgun.

Test plan

  • `bunx @biomejs/biome check src/` — 0 errors
  • `bunx tsc --noEmit -p .` — 0 production errors
  • `bun test` — 2169 pass, same 4 pre-existing failures as upstream/main (network/DNS, update-check)
  • New unit tests: `spawn-md.test.ts` (parser correctness, schema validation, malformed input)
  • `unknown-flags.test.ts` updated to include `--repo`
  • End-to-end: clone a template repo containing `spawn.md` + verify MCP servers + setup steps land
  • Verify reconnect (`spawn last`) still cd's into `~/project`

🤖 Generated with Claude Code

AhmedTMMand others added 2 commits April 24, 2026 23:28
 spawn <agent> <cloud> --repo user/template
Clones https://github.com/user/template.git to ~/project on the VM,
parses spawn.md (YAML frontmatter), and applies its custom-setup
contract:
- `setup`: oauth (open URL + wait for Enter), cli_auth (run on VM),
api_key (no-echo prompt → /etc/spawn/secrets, sourced from .bashrc),
command (run on VM)
- `mcp_servers`: env values stay as ${NAME} placeholders so secrets
never end up in the template repo. Replay routes through the
existing skills.ts helpers (Claude settings.json, Cursor mcp.json,
Codex config.toml) — no `node -e` injection.
- `setup_commands`: run inside ~/project
When the clone succeeds, the agent launches with `cd ~/project && ...`
so the user lands in their template's working directory. Reconnect via
`spawn last` replays the same launchCmd.
Built-in steps (github auth, auto-update, etc.) stay in the CLI
--steps flag — spawn.md only handles custom setup that Spawn doesn't
know about natively.
Bumps CLI to 1.0.22.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@la14-1la14-1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review

Verdict: APPROVE
Commit: 24cbdad

Threat model

The --repo user/template flag explicitly opts the user in to running arbitrary shell from a third-party GitHub repo (setup_commands, cli_auth.command, command.command are all unsandboxed). This is the feature, not a vulnerability — a user invoking spawn claude hetzner --repo X/Y is consenting to let X/Y's spawn.md drive setup. Reviewed under that threat model.

What I verified

  • Slug regex^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$ is tight — no shell metacharacters, no @ for host override, no ?/& for query injection. Clone URL always https://github.com/<slug>.git. ✅
  • openBrowser uses argv arrays, not shell — oauth.url and api_key.guide_url cannot trigger RCE via URL-shaped payloads. ✅
  • TOML writer (tomlString) escapes \ and " correctly for TOML basic strings. ✅
  • SpawnMdSchema via valibot rejects unknown type values and missing required fields — malformed spawn.md returns null ("ignored with warning"), not thrown. ✅
  • Parse-loose / validate-strict pattern for the hand-rolled YAML parser: parseYamlFrontmatter never throws; valibot is the gate. Reasonable given the frontmatter subset used. ✅

Findings

SevFileIssue
MEDIUMspawn-md.ts:394Deferred shell injection via /etc/spawn/secrets when an api_key value contains " or newline — the file is later sourced from ~/.bashrc, so corruption becomes code execution on next login. Self-inflicted, but the escaping is wrong.
LOWskills.ts:322tomlString handles \ and " but not raw newlines — multi-line MCP env values produce invalid TOML for Codex. Functional corruption, not RCE.
LOWspawn-md.ts:270/tmp/spawn-capture-${Date.now()} — predictable path, shared-tmp race risk. Inert on single-user VMs.
NOTEspawn.md env: { VAR: "${NAME}" }Placeholders written literally into agent MCP configs. Claude/Codex MCP clients don't expand env vars in mcp_servers.*.env — users will see literal ${NAME} strings passed to the MCP server. Functional, not security.

Recommended follow-up (MEDIUM)

Do not write user-supplied values into a shell-sourceable file. Either (a) keep values base64-encoded at rest and decode at source-time via a wrapper, or (b) use a dotenv-style loader with a parser that does not re-interpret quotes. Keeping the value encoded eliminates quote-escape concerns entirely.

Tests

  • bunx @biomejs/biome check src/ — 195 files, no issues
  • bun test — 2112 pass, 0 fail (+4 new spawn-md tests)
  • bunx tsc --noEmit -p . — 5 pre-existing errors in update-check.test.ts and daytona.ts, all present on main, none introduced by this PR

Per security protocol: MEDIUM + LOW → APPROVE. Filing the escape-injection finding as a follow-up.


-- security/pr-reviewer

Comment threadpackages/cli/src/shared/spawn-md.ts
Comment threadpackages/cli/src/shared/skills.ts
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AhmedTMM@la14-1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(cli): --repo flag clones a template repo and applies spawn.md - #3360

Merged
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag
Apr 25, 2026
Merged

feat(cli): --repo flag clones a template repo and applies spawn.md#3360
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag

Conversation

@AhmedTMM

Copy link
Copy Markdown
Collaborator

Summary

Adds `--repo user/template` to the spawn CLI. Clones the named GitHub repo to `~/project` on the VM, parses `spawn.md` (YAML frontmatter) at the repo root, and applies it as a setup contract before handing off to the agent.

```
spawn --repo user/my-template
```

What spawn.md can declare

  • `setup` — custom auth steps:
    • `oauth` — open a URL locally, wait for Enter
    • `cli_auth` — run an auth command on the VM (e.g. `vercel login`)
    • `api_key` — no-echo prompt → base64 → `/etc/spawn/secrets` (sourced from `~/.bashrc`)
    • `command` — run anything on the VM
  • `mcp_servers` — MCP entries with `${NAME}` placeholders for env values (so the template repo never holds secrets). Routes through the existing `skills.ts` install helpers — Claude → `/.claude/settings.json`, Cursor → `/.cursor/mcp.json`, Codex → `/.codex/config.toml` (new TOML installer), generic → `/./mcp.json`.
  • `setup_commands` — shell commands run inside `~/project`.

Built-in steps (github auth, auto-update, security-scan, etc.) stay on the CLI `--steps` flag — `spawn.md` only handles the custom-setup parts Spawn doesn't know about natively.

Launch behavior

If the clone succeeds, the agent launches with `cd ~/project && <agent.launchCmd>` so the user lands in their template's working directory. `saveLaunchCmd` persists this, so `spawn last` reconnects into the same dir. Invalid slugs / clone failures fall back to the standard launch — no broken-cd footgun.

Test plan

  • `bunx @biomejs/biome check src/` — 0 errors
  • `bunx tsc --noEmit -p .` — 0 production errors
  • `bun test` — 2169 pass, same 4 pre-existing failures as upstream/main (network/DNS, update-check)
  • New unit tests: `spawn-md.test.ts` (parser correctness, schema validation, malformed input)
  • `unknown-flags.test.ts` updated to include `--repo`
  • End-to-end: clone a template repo containing `spawn.md` + verify MCP servers + setup steps land
  • Verify reconnect (`spawn last`) still cd's into `~/project`

🤖 Generated with Claude Code

AhmedTMMand others added 2 commits April 24, 2026 23:28
 spawn <agent> <cloud> --repo user/template
Clones https://github.com/user/template.git to ~/project on the VM,
parses spawn.md (YAML frontmatter), and applies its custom-setup
contract:
- `setup`: oauth (open URL + wait for Enter), cli_auth (run on VM),
api_key (no-echo prompt → /etc/spawn/secrets, sourced from .bashrc),
command (run on VM)
- `mcp_servers`: env values stay as ${NAME} placeholders so secrets
never end up in the template repo. Replay routes through the
existing skills.ts helpers (Claude settings.json, Cursor mcp.json,
Codex config.toml) — no `node -e` injection.
- `setup_commands`: run inside ~/project
When the clone succeeds, the agent launches with `cd ~/project && ...`
so the user lands in their template's working directory. Reconnect via
`spawn last` replays the same launchCmd.
Built-in steps (github auth, auto-update, etc.) stay in the CLI
--steps flag — spawn.md only handles custom setup that Spawn doesn't
know about natively.
Bumps CLI to 1.0.22.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@la14-1la14-1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review

Verdict: APPROVE
Commit: 24cbdad

Threat model

The --repo user/template flag explicitly opts the user in to running arbitrary shell from a third-party GitHub repo (setup_commands, cli_auth.command, command.command are all unsandboxed). This is the feature, not a vulnerability — a user invoking spawn claude hetzner --repo X/Y is consenting to let X/Y's spawn.md drive setup. Reviewed under that threat model.

What I verified

  • Slug regex^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$ is tight — no shell metacharacters, no @ for host override, no ?/& for query injection. Clone URL always https://github.com/<slug>.git. ✅
  • openBrowser uses argv arrays, not shell — oauth.url and api_key.guide_url cannot trigger RCE via URL-shaped payloads. ✅
  • TOML writer (tomlString) escapes \ and " correctly for TOML basic strings. ✅
  • SpawnMdSchema via valibot rejects unknown type values and missing required fields — malformed spawn.md returns null ("ignored with warning"), not thrown. ✅
  • Parse-loose / validate-strict pattern for the hand-rolled YAML parser: parseYamlFrontmatter never throws; valibot is the gate. Reasonable given the frontmatter subset used. ✅

Findings

SevFileIssue
MEDIUMspawn-md.ts:394Deferred shell injection via /etc/spawn/secrets when an api_key value contains " or newline — the file is later sourced from ~/.bashrc, so corruption becomes code execution on next login. Self-inflicted, but the escaping is wrong.
LOWskills.ts:322tomlString handles \ and " but not raw newlines — multi-line MCP env values produce invalid TOML for Codex. Functional corruption, not RCE.
LOWspawn-md.ts:270/tmp/spawn-capture-${Date.now()} — predictable path, shared-tmp race risk. Inert on single-user VMs.
NOTEspawn.md env: { VAR: "${NAME}" }Placeholders written literally into agent MCP configs. Claude/Codex MCP clients don't expand env vars in mcp_servers.*.env — users will see literal ${NAME} strings passed to the MCP server. Functional, not security.

Recommended follow-up (MEDIUM)

Do not write user-supplied values into a shell-sourceable file. Either (a) keep values base64-encoded at rest and decode at source-time via a wrapper, or (b) use a dotenv-style loader with a parser that does not re-interpret quotes. Keeping the value encoded eliminates quote-escape concerns entirely.

Tests

  • bunx @biomejs/biome check src/ — 195 files, no issues
  • bun test — 2112 pass, 0 fail (+4 new spawn-md tests)
  • bunx tsc --noEmit -p . — 5 pre-existing errors in update-check.test.ts and daytona.ts, all present on main, none introduced by this PR

Per security protocol: MEDIUM + LOW → APPROVE. Filing the escape-injection finding as a follow-up.


-- security/pr-reviewer

Comment threadpackages/cli/src/shared/spawn-md.ts
Comment threadpackages/cli/src/shared/skills.ts
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AhmedTMM@la14-1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(cli): --repo flag clones a template repo and applies spawn.md - #3360

Merged
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag
Apr 25, 2026
Merged

feat(cli): --repo flag clones a template repo and applies spawn.md#3360
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag

Conversation

@AhmedTMM

Copy link
Copy Markdown
Collaborator

Summary

Adds `--repo user/template` to the spawn CLI. Clones the named GitHub repo to `~/project` on the VM, parses `spawn.md` (YAML frontmatter) at the repo root, and applies it as a setup contract before handing off to the agent.

```
spawn --repo user/my-template
```

What spawn.md can declare

  • `setup` — custom auth steps:
    • `oauth` — open a URL locally, wait for Enter
    • `cli_auth` — run an auth command on the VM (e.g. `vercel login`)
    • `api_key` — no-echo prompt → base64 → `/etc/spawn/secrets` (sourced from `~/.bashrc`)
    • `command` — run anything on the VM
  • `mcp_servers` — MCP entries with `${NAME}` placeholders for env values (so the template repo never holds secrets). Routes through the existing `skills.ts` install helpers — Claude → `/.claude/settings.json`, Cursor → `/.cursor/mcp.json`, Codex → `/.codex/config.toml` (new TOML installer), generic → `/./mcp.json`.
  • `setup_commands` — shell commands run inside `~/project`.

Built-in steps (github auth, auto-update, security-scan, etc.) stay on the CLI `--steps` flag — `spawn.md` only handles the custom-setup parts Spawn doesn't know about natively.

Launch behavior

If the clone succeeds, the agent launches with `cd ~/project && <agent.launchCmd>` so the user lands in their template's working directory. `saveLaunchCmd` persists this, so `spawn last` reconnects into the same dir. Invalid slugs / clone failures fall back to the standard launch — no broken-cd footgun.

Test plan

  • `bunx @biomejs/biome check src/` — 0 errors
  • `bunx tsc --noEmit -p .` — 0 production errors
  • `bun test` — 2169 pass, same 4 pre-existing failures as upstream/main (network/DNS, update-check)
  • New unit tests: `spawn-md.test.ts` (parser correctness, schema validation, malformed input)
  • `unknown-flags.test.ts` updated to include `--repo`
  • End-to-end: clone a template repo containing `spawn.md` + verify MCP servers + setup steps land
  • Verify reconnect (`spawn last`) still cd's into `~/project`

🤖 Generated with Claude Code

AhmedTMMand others added 2 commits April 24, 2026 23:28
 spawn <agent> <cloud> --repo user/template
Clones https://github.com/user/template.git to ~/project on the VM,
parses spawn.md (YAML frontmatter), and applies its custom-setup
contract:
- `setup`: oauth (open URL + wait for Enter), cli_auth (run on VM),
api_key (no-echo prompt → /etc/spawn/secrets, sourced from .bashrc),
command (run on VM)
- `mcp_servers`: env values stay as ${NAME} placeholders so secrets
never end up in the template repo. Replay routes through the
existing skills.ts helpers (Claude settings.json, Cursor mcp.json,
Codex config.toml) — no `node -e` injection.
- `setup_commands`: run inside ~/project
When the clone succeeds, the agent launches with `cd ~/project && ...`
so the user lands in their template's working directory. Reconnect via
`spawn last` replays the same launchCmd.
Built-in steps (github auth, auto-update, etc.) stay in the CLI
--steps flag — spawn.md only handles custom setup that Spawn doesn't
know about natively.
Bumps CLI to 1.0.22.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@la14-1la14-1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review

Verdict: APPROVE
Commit: 24cbdad

Threat model

The --repo user/template flag explicitly opts the user in to running arbitrary shell from a third-party GitHub repo (setup_commands, cli_auth.command, command.command are all unsandboxed). This is the feature, not a vulnerability — a user invoking spawn claude hetzner --repo X/Y is consenting to let X/Y's spawn.md drive setup. Reviewed under that threat model.

What I verified

  • Slug regex^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$ is tight — no shell metacharacters, no @ for host override, no ?/& for query injection. Clone URL always https://github.com/<slug>.git. ✅
  • openBrowser uses argv arrays, not shell — oauth.url and api_key.guide_url cannot trigger RCE via URL-shaped payloads. ✅
  • TOML writer (tomlString) escapes \ and " correctly for TOML basic strings. ✅
  • SpawnMdSchema via valibot rejects unknown type values and missing required fields — malformed spawn.md returns null ("ignored with warning"), not thrown. ✅
  • Parse-loose / validate-strict pattern for the hand-rolled YAML parser: parseYamlFrontmatter never throws; valibot is the gate. Reasonable given the frontmatter subset used. ✅

Findings

SevFileIssue
MEDIUMspawn-md.ts:394Deferred shell injection via /etc/spawn/secrets when an api_key value contains " or newline — the file is later sourced from ~/.bashrc, so corruption becomes code execution on next login. Self-inflicted, but the escaping is wrong.
LOWskills.ts:322tomlString handles \ and " but not raw newlines — multi-line MCP env values produce invalid TOML for Codex. Functional corruption, not RCE.
LOWspawn-md.ts:270/tmp/spawn-capture-${Date.now()} — predictable path, shared-tmp race risk. Inert on single-user VMs.
NOTEspawn.md env: { VAR: "${NAME}" }Placeholders written literally into agent MCP configs. Claude/Codex MCP clients don't expand env vars in mcp_servers.*.env — users will see literal ${NAME} strings passed to the MCP server. Functional, not security.

Recommended follow-up (MEDIUM)

Do not write user-supplied values into a shell-sourceable file. Either (a) keep values base64-encoded at rest and decode at source-time via a wrapper, or (b) use a dotenv-style loader with a parser that does not re-interpret quotes. Keeping the value encoded eliminates quote-escape concerns entirely.

Tests

  • bunx @biomejs/biome check src/ — 195 files, no issues
  • bun test — 2112 pass, 0 fail (+4 new spawn-md tests)
  • bunx tsc --noEmit -p . — 5 pre-existing errors in update-check.test.ts and daytona.ts, all present on main, none introduced by this PR

Per security protocol: MEDIUM + LOW → APPROVE. Filing the escape-injection finding as a follow-up.


-- security/pr-reviewer

Comment threadpackages/cli/src/shared/spawn-md.ts
Comment threadpackages/cli/src/shared/skills.ts
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AhmedTMM@la14-1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(cli): --repo flag clones a template repo and applies spawn.md - #3360

Merged
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag
Apr 25, 2026
Merged

feat(cli): --repo flag clones a template repo and applies spawn.md#3360
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag

Conversation

@AhmedTMM

Copy link
Copy Markdown
Collaborator

Summary

Adds `--repo user/template` to the spawn CLI. Clones the named GitHub repo to `~/project` on the VM, parses `spawn.md` (YAML frontmatter) at the repo root, and applies it as a setup contract before handing off to the agent.

```
spawn --repo user/my-template
```

What spawn.md can declare

  • `setup` — custom auth steps:
    • `oauth` — open a URL locally, wait for Enter
    • `cli_auth` — run an auth command on the VM (e.g. `vercel login`)
    • `api_key` — no-echo prompt → base64 → `/etc/spawn/secrets` (sourced from `~/.bashrc`)
    • `command` — run anything on the VM
  • `mcp_servers` — MCP entries with `${NAME}` placeholders for env values (so the template repo never holds secrets). Routes through the existing `skills.ts` install helpers — Claude → `/.claude/settings.json`, Cursor → `/.cursor/mcp.json`, Codex → `/.codex/config.toml` (new TOML installer), generic → `/./mcp.json`.
  • `setup_commands` — shell commands run inside `~/project`.

Built-in steps (github auth, auto-update, security-scan, etc.) stay on the CLI `--steps` flag — `spawn.md` only handles the custom-setup parts Spawn doesn't know about natively.

Launch behavior

If the clone succeeds, the agent launches with `cd ~/project && <agent.launchCmd>` so the user lands in their template's working directory. `saveLaunchCmd` persists this, so `spawn last` reconnects into the same dir. Invalid slugs / clone failures fall back to the standard launch — no broken-cd footgun.

Test plan

  • `bunx @biomejs/biome check src/` — 0 errors
  • `bunx tsc --noEmit -p .` — 0 production errors
  • `bun test` — 2169 pass, same 4 pre-existing failures as upstream/main (network/DNS, update-check)
  • New unit tests: `spawn-md.test.ts` (parser correctness, schema validation, malformed input)
  • `unknown-flags.test.ts` updated to include `--repo`
  • End-to-end: clone a template repo containing `spawn.md` + verify MCP servers + setup steps land
  • Verify reconnect (`spawn last`) still cd's into `~/project`

🤖 Generated with Claude Code

AhmedTMMand others added 2 commits April 24, 2026 23:28
 spawn <agent> <cloud> --repo user/template
Clones https://github.com/user/template.git to ~/project on the VM,
parses spawn.md (YAML frontmatter), and applies its custom-setup
contract:
- `setup`: oauth (open URL + wait for Enter), cli_auth (run on VM),
api_key (no-echo prompt → /etc/spawn/secrets, sourced from .bashrc),
command (run on VM)
- `mcp_servers`: env values stay as ${NAME} placeholders so secrets
never end up in the template repo. Replay routes through the
existing skills.ts helpers (Claude settings.json, Cursor mcp.json,
Codex config.toml) — no `node -e` injection.
- `setup_commands`: run inside ~/project
When the clone succeeds, the agent launches with `cd ~/project && ...`
so the user lands in their template's working directory. Reconnect via
`spawn last` replays the same launchCmd.
Built-in steps (github auth, auto-update, etc.) stay in the CLI
--steps flag — spawn.md only handles custom setup that Spawn doesn't
know about natively.
Bumps CLI to 1.0.22.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@la14-1la14-1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review

Verdict: APPROVE
Commit: 24cbdad

Threat model

The --repo user/template flag explicitly opts the user in to running arbitrary shell from a third-party GitHub repo (setup_commands, cli_auth.command, command.command are all unsandboxed). This is the feature, not a vulnerability — a user invoking spawn claude hetzner --repo X/Y is consenting to let X/Y's spawn.md drive setup. Reviewed under that threat model.

What I verified

  • Slug regex^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$ is tight — no shell metacharacters, no @ for host override, no ?/& for query injection. Clone URL always https://github.com/<slug>.git. ✅
  • openBrowser uses argv arrays, not shell — oauth.url and api_key.guide_url cannot trigger RCE via URL-shaped payloads. ✅
  • TOML writer (tomlString) escapes \ and " correctly for TOML basic strings. ✅
  • SpawnMdSchema via valibot rejects unknown type values and missing required fields — malformed spawn.md returns null ("ignored with warning"), not thrown. ✅
  • Parse-loose / validate-strict pattern for the hand-rolled YAML parser: parseYamlFrontmatter never throws; valibot is the gate. Reasonable given the frontmatter subset used. ✅

Findings

SevFileIssue
MEDIUMspawn-md.ts:394Deferred shell injection via /etc/spawn/secrets when an api_key value contains " or newline — the file is later sourced from ~/.bashrc, so corruption becomes code execution on next login. Self-inflicted, but the escaping is wrong.
LOWskills.ts:322tomlString handles \ and " but not raw newlines — multi-line MCP env values produce invalid TOML for Codex. Functional corruption, not RCE.
LOWspawn-md.ts:270/tmp/spawn-capture-${Date.now()} — predictable path, shared-tmp race risk. Inert on single-user VMs.
NOTEspawn.md env: { VAR: "${NAME}" }Placeholders written literally into agent MCP configs. Claude/Codex MCP clients don't expand env vars in mcp_servers.*.env — users will see literal ${NAME} strings passed to the MCP server. Functional, not security.

Recommended follow-up (MEDIUM)

Do not write user-supplied values into a shell-sourceable file. Either (a) keep values base64-encoded at rest and decode at source-time via a wrapper, or (b) use a dotenv-style loader with a parser that does not re-interpret quotes. Keeping the value encoded eliminates quote-escape concerns entirely.

Tests

  • bunx @biomejs/biome check src/ — 195 files, no issues
  • bun test — 2112 pass, 0 fail (+4 new spawn-md tests)
  • bunx tsc --noEmit -p . — 5 pre-existing errors in update-check.test.ts and daytona.ts, all present on main, none introduced by this PR

Per security protocol: MEDIUM + LOW → APPROVE. Filing the escape-injection finding as a follow-up.


-- security/pr-reviewer

Comment threadpackages/cli/src/shared/spawn-md.ts
Comment threadpackages/cli/src/shared/skills.ts
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AhmedTMM@la14-1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(cli): --repo flag clones a template repo and applies spawn.md - #3360

Merged
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag
Apr 25, 2026
Merged

feat(cli): --repo flag clones a template repo and applies spawn.md#3360
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag

Conversation

@AhmedTMM

Copy link
Copy Markdown
Collaborator

Summary

Adds `--repo user/template` to the spawn CLI. Clones the named GitHub repo to `~/project` on the VM, parses `spawn.md` (YAML frontmatter) at the repo root, and applies it as a setup contract before handing off to the agent.

```
spawn --repo user/my-template
```

What spawn.md can declare

  • `setup` — custom auth steps:
    • `oauth` — open a URL locally, wait for Enter
    • `cli_auth` — run an auth command on the VM (e.g. `vercel login`)
    • `api_key` — no-echo prompt → base64 → `/etc/spawn/secrets` (sourced from `~/.bashrc`)
    • `command` — run anything on the VM
  • `mcp_servers` — MCP entries with `${NAME}` placeholders for env values (so the template repo never holds secrets). Routes through the existing `skills.ts` install helpers — Claude → `/.claude/settings.json`, Cursor → `/.cursor/mcp.json`, Codex → `/.codex/config.toml` (new TOML installer), generic → `/./mcp.json`.
  • `setup_commands` — shell commands run inside `~/project`.

Built-in steps (github auth, auto-update, security-scan, etc.) stay on the CLI `--steps` flag — `spawn.md` only handles the custom-setup parts Spawn doesn't know about natively.

Launch behavior

If the clone succeeds, the agent launches with `cd ~/project && <agent.launchCmd>` so the user lands in their template's working directory. `saveLaunchCmd` persists this, so `spawn last` reconnects into the same dir. Invalid slugs / clone failures fall back to the standard launch — no broken-cd footgun.

Test plan

  • `bunx @biomejs/biome check src/` — 0 errors
  • `bunx tsc --noEmit -p .` — 0 production errors
  • `bun test` — 2169 pass, same 4 pre-existing failures as upstream/main (network/DNS, update-check)
  • New unit tests: `spawn-md.test.ts` (parser correctness, schema validation, malformed input)
  • `unknown-flags.test.ts` updated to include `--repo`
  • End-to-end: clone a template repo containing `spawn.md` + verify MCP servers + setup steps land
  • Verify reconnect (`spawn last`) still cd's into `~/project`

🤖 Generated with Claude Code

AhmedTMMand others added 2 commits April 24, 2026 23:28
 spawn <agent> <cloud> --repo user/template
Clones https://github.com/user/template.git to ~/project on the VM,
parses spawn.md (YAML frontmatter), and applies its custom-setup
contract:
- `setup`: oauth (open URL + wait for Enter), cli_auth (run on VM),
api_key (no-echo prompt → /etc/spawn/secrets, sourced from .bashrc),
command (run on VM)
- `mcp_servers`: env values stay as ${NAME} placeholders so secrets
never end up in the template repo. Replay routes through the
existing skills.ts helpers (Claude settings.json, Cursor mcp.json,
Codex config.toml) — no `node -e` injection.
- `setup_commands`: run inside ~/project
When the clone succeeds, the agent launches with `cd ~/project && ...`
so the user lands in their template's working directory. Reconnect via
`spawn last` replays the same launchCmd.
Built-in steps (github auth, auto-update, etc.) stay in the CLI
--steps flag — spawn.md only handles custom setup that Spawn doesn't
know about natively.
Bumps CLI to 1.0.22.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@la14-1la14-1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review

Verdict: APPROVE
Commit: 24cbdad

Threat model

The --repo user/template flag explicitly opts the user in to running arbitrary shell from a third-party GitHub repo (setup_commands, cli_auth.command, command.command are all unsandboxed). This is the feature, not a vulnerability — a user invoking spawn claude hetzner --repo X/Y is consenting to let X/Y's spawn.md drive setup. Reviewed under that threat model.

What I verified

  • Slug regex^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$ is tight — no shell metacharacters, no @ for host override, no ?/& for query injection. Clone URL always https://github.com/<slug>.git. ✅
  • openBrowser uses argv arrays, not shell — oauth.url and api_key.guide_url cannot trigger RCE via URL-shaped payloads. ✅
  • TOML writer (tomlString) escapes \ and " correctly for TOML basic strings. ✅
  • SpawnMdSchema via valibot rejects unknown type values and missing required fields — malformed spawn.md returns null ("ignored with warning"), not thrown. ✅
  • Parse-loose / validate-strict pattern for the hand-rolled YAML parser: parseYamlFrontmatter never throws; valibot is the gate. Reasonable given the frontmatter subset used. ✅

Findings

SevFileIssue
MEDIUMspawn-md.ts:394Deferred shell injection via /etc/spawn/secrets when an api_key value contains " or newline — the file is later sourced from ~/.bashrc, so corruption becomes code execution on next login. Self-inflicted, but the escaping is wrong.
LOWskills.ts:322tomlString handles \ and " but not raw newlines — multi-line MCP env values produce invalid TOML for Codex. Functional corruption, not RCE.
LOWspawn-md.ts:270/tmp/spawn-capture-${Date.now()} — predictable path, shared-tmp race risk. Inert on single-user VMs.
NOTEspawn.md env: { VAR: "${NAME}" }Placeholders written literally into agent MCP configs. Claude/Codex MCP clients don't expand env vars in mcp_servers.*.env — users will see literal ${NAME} strings passed to the MCP server. Functional, not security.

Recommended follow-up (MEDIUM)

Do not write user-supplied values into a shell-sourceable file. Either (a) keep values base64-encoded at rest and decode at source-time via a wrapper, or (b) use a dotenv-style loader with a parser that does not re-interpret quotes. Keeping the value encoded eliminates quote-escape concerns entirely.

Tests

  • bunx @biomejs/biome check src/ — 195 files, no issues
  • bun test — 2112 pass, 0 fail (+4 new spawn-md tests)
  • bunx tsc --noEmit -p . — 5 pre-existing errors in update-check.test.ts and daytona.ts, all present on main, none introduced by this PR

Per security protocol: MEDIUM + LOW → APPROVE. Filing the escape-injection finding as a follow-up.


-- security/pr-reviewer

Comment threadpackages/cli/src/shared/spawn-md.ts
Comment threadpackages/cli/src/shared/skills.ts
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AhmedTMM@la14-1
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(cli): --repo flag clones a template repo and applies spawn.md - #3360

Merged
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag
Apr 25, 2026
Merged

feat(cli): --repo flag clones a template repo and applies spawn.md#3360
la14-1 merged 2 commits into
OpenRouterLabs:mainfrom
AhmedTMM:feat/repo-flag

Conversation

@AhmedTMM

Copy link
Copy Markdown
Collaborator

Summary

Adds `--repo user/template` to the spawn CLI. Clones the named GitHub repo to `~/project` on the VM, parses `spawn.md` (YAML frontmatter) at the repo root, and applies it as a setup contract before handing off to the agent.

```
spawn --repo user/my-template
```

What spawn.md can declare

  • `setup` — custom auth steps:
    • `oauth` — open a URL locally, wait for Enter
    • `cli_auth` — run an auth command on the VM (e.g. `vercel login`)
    • `api_key` — no-echo prompt → base64 → `/etc/spawn/secrets` (sourced from `~/.bashrc`)
    • `command` — run anything on the VM
  • `mcp_servers` — MCP entries with `${NAME}` placeholders for env values (so the template repo never holds secrets). Routes through the existing `skills.ts` install helpers — Claude → `/.claude/settings.json`, Cursor → `/.cursor/mcp.json`, Codex → `/.codex/config.toml` (new TOML installer), generic → `/./mcp.json`.
  • `setup_commands` — shell commands run inside `~/project`.

Built-in steps (github auth, auto-update, security-scan, etc.) stay on the CLI `--steps` flag — `spawn.md` only handles the custom-setup parts Spawn doesn't know about natively.

Launch behavior

If the clone succeeds, the agent launches with `cd ~/project && <agent.launchCmd>` so the user lands in their template's working directory. `saveLaunchCmd` persists this, so `spawn last` reconnects into the same dir. Invalid slugs / clone failures fall back to the standard launch — no broken-cd footgun.

Test plan

  • `bunx @biomejs/biome check src/` — 0 errors
  • `bunx tsc --noEmit -p .` — 0 production errors
  • `bun test` — 2169 pass, same 4 pre-existing failures as upstream/main (network/DNS, update-check)
  • New unit tests: `spawn-md.test.ts` (parser correctness, schema validation, malformed input)
  • `unknown-flags.test.ts` updated to include `--repo`
  • End-to-end: clone a template repo containing `spawn.md` + verify MCP servers + setup steps land
  • Verify reconnect (`spawn last`) still cd's into `~/project`

🤖 Generated with Claude Code

AhmedTMMand others added 2 commits April 24, 2026 23:28
 spawn <agent> <cloud> --repo user/template
Clones https://github.com/user/template.git to ~/project on the VM,
parses spawn.md (YAML frontmatter), and applies its custom-setup
contract:
- `setup`: oauth (open URL + wait for Enter), cli_auth (run on VM),
api_key (no-echo prompt → /etc/spawn/secrets, sourced from .bashrc),
command (run on VM)
- `mcp_servers`: env values stay as ${NAME} placeholders so secrets
never end up in the template repo. Replay routes through the
existing skills.ts helpers (Claude settings.json, Cursor mcp.json,
Codex config.toml) — no `node -e` injection.
- `setup_commands`: run inside ~/project
When the clone succeeds, the agent launches with `cd ~/project && ...`
so the user lands in their template's working directory. Reconnect via
`spawn last` replays the same launchCmd.
Built-in steps (github auth, auto-update, etc.) stay in the CLI
--steps flag — spawn.md only handles custom setup that Spawn doesn't
know about natively.
Bumps CLI to 1.0.22.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@la14-1la14-1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review

Verdict: APPROVE
Commit: 24cbdad

Threat model

The --repo user/template flag explicitly opts the user in to running arbitrary shell from a third-party GitHub repo (setup_commands, cli_auth.command, command.command are all unsandboxed). This is the feature, not a vulnerability — a user invoking spawn claude hetzner --repo X/Y is consenting to let X/Y's spawn.md drive setup. Reviewed under that threat model.

What I verified

  • Slug regex^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$ is tight — no shell metacharacters, no @ for host override, no ?/& for query injection. Clone URL always https://github.com/<slug>.git. ✅
  • openBrowser uses argv arrays, not shell — oauth.url and api_key.guide_url cannot trigger RCE via URL-shaped payloads. ✅
  • TOML writer (tomlString) escapes \ and " correctly for TOML basic strings. ✅
  • SpawnMdSchema via valibot rejects unknown type values and missing required fields — malformed spawn.md returns null ("ignored with warning"), not thrown. ✅
  • Parse-loose / validate-strict pattern for the hand-rolled YAML parser: parseYamlFrontmatter never throws; valibot is the gate. Reasonable given the frontmatter subset used. ✅

Findings

SevFileIssue
MEDIUMspawn-md.ts:394Deferred shell injection via /etc/spawn/secrets when an api_key value contains " or newline — the file is later sourced from ~/.bashrc, so corruption becomes code execution on next login. Self-inflicted, but the escaping is wrong.
LOWskills.ts:322tomlString handles \ and " but not raw newlines — multi-line MCP env values produce invalid TOML for Codex. Functional corruption, not RCE.
LOWspawn-md.ts:270/tmp/spawn-capture-${Date.now()} — predictable path, shared-tmp race risk. Inert on single-user VMs.
NOTEspawn.md env: { VAR: "${NAME}" }Placeholders written literally into agent MCP configs. Claude/Codex MCP clients don't expand env vars in mcp_servers.*.env — users will see literal ${NAME} strings passed to the MCP server. Functional, not security.

Recommended follow-up (MEDIUM)

Do not write user-supplied values into a shell-sourceable file. Either (a) keep values base64-encoded at rest and decode at source-time via a wrapper, or (b) use a dotenv-style loader with a parser that does not re-interpret quotes. Keeping the value encoded eliminates quote-escape concerns entirely.

Tests

  • bunx @biomejs/biome check src/ — 195 files, no issues
  • bun test — 2112 pass, 0 fail (+4 new spawn-md tests)
  • bunx tsc --noEmit -p . — 5 pre-existing errors in update-check.test.ts and daytona.ts, all present on main, none introduced by this PR

Per security protocol: MEDIUM + LOW → APPROVE. Filing the escape-injection finding as a follow-up.


-- security/pr-reviewer

Comment threadpackages/cli/src/shared/spawn-md.ts
Comment threadpackages/cli/src/shared/skills.ts
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@AhmedTMM@la14-1