connect setup --launcher: run each worker through a sandbox launcher by jeremy · Pull Request #846 · basecamp/basecamp-cli · GitHub
Skip to content

connect setup --launcher: run each worker through a sandbox launcher - #846

Draft
jeremy wants to merge 1 commit into
mainfrom
connect-launcher
Draft

jeremy wants to merge 1 commit into
mainfrom
connect-launcher

Conversation

@jeremy

@jeremy jeremy commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Problem

Jorge's note on the connector (10-01, "Fine tuning") asked for it to "eventually let you configure a sandbox launcher command". When he wrote it, the connector started its own workers. Since #814 and #815 it starts nothing: basecamp connect only listens and prints each trusted request, and the session running /basecamp-connect hands each request to a subagent of its own. So there is no spawn site in the connector for a launcher to wrap, and adding one back would bring back the worker side #815 removed.

What's left is one place where a worker starts: the skill's dispatch step. That's where this PR puts the launcher.

Solution

  • connect.json gets an optional launcher, a list of argv words. Only setup writes it: --launcher is given once per word, in order, and --no-launcher removes it. The two flags can't be used together. Like every other setting, a setup run that doesn't mention it keeps it. Words are never passed through a shell. The first word must be a program name looked up on PATH or an absolute path. A relative path is refused, because the session runs the launcher from the repo it chose, so a relative path would name a program inside that repo. Empty words, control characters, a first word starting with -, more than 16 words, and words over 1 KiB are refused too. Parse refuses a hand-edited file with a launcher that setup would refuse.
  • The skill. With no launcher, nothing changes: the worker is a subagent started with the Agent tool. With a launcher, the session writes the worker's instructions to a prompt file and runs cd '<repo>' && '<word1>' '<word2>' claude -p < '<prompt file>' in the background. The launcher controls how the worker runs, including its permission mode. The session adds only claude -p. If the launcher fails, the skill never drops it or falls back to a subagent.
  • show prints the launcher one word at a time, quoted (or as a code span in Markdown), so a word with a space in it reads as one word and control bytes never reach the terminal. JSON gets the field as written.
  • doctor adds a Launcher check when one is set: whether the program can be found here. It doesn't start anything.
  • The connector doesn't read the setting, and the request line on stdout is unchanged.

How agent-sandbox plugs in

basecamp connect setup -P agent --launcher sandbox

Each worker then runs as sandbox claude -p < prompt from the chosen repo. In agent-sandbox-nono, that means a box working in a snapshot of that repo, with the agent user's Basecamp profile brokered in, and the operator's Claude settings regenerated into the box (that work is in progress). The connector and the orchestrating session stay on the host. The connector needs the operator identity, tailscale and the agent's credential, none of which the box has. The orchestrator only acknowledges and picks a repo; the worker is what reads the thread and runs code, and that part is what goes in the box.

Tests

  • go test ./internal/connector/setup/ covers: round-trip, admission reads the same policy from a file with a launcher, no key written when there's no launcher, a validation table, Parse refusing bad launchers (empty list, relative path, a string instead of a list), and Apply setting, keeping, replacing, clearing and refusing launchers without modifying its input.
  • go test ./internal/commands/ covers: setup recording, keeping, replacing and removing a launcher against the existing fake setup server; refused launchers leave nothing written; the flags count as scriptable, so a launcher is never handed to guided setup; show's display; doctor's check passing and failing, with its hint.
  • bin/ci (make check) passes locally. That's fmt, vet, golangci-lint (0 issues), unit and e2e tests, surface snapshot, skill drift, naming, provenance and tidy. .surface-skill-drift gains FLAG basecamp --launcher, because connect is hidden and never appears in .surface, the same way --serve and --operator-profile are baselined.
  • Not run: the skill evals (make -C skill-evals eval-connect), which need model calls. The new dispatch branch has no eval case yet.

Open questions

  • Older CLIs. Parse refuses unknown keys, so an older CLI refuses a connect.json that has launcher and won't start the connector. It's the same for any new key. Do we want a version bump, or a note in the release?
  • Should the setting live here? connect.json is the connector's trust anchor. The connector never reads launcher, only the skill does. I put it here because setup is the only sanctioned writer and show is the safe reader. The alternative is an ordinary config key, connect.launcher through basecamp config.
  • Should the request line carry the launcher? As written, the skill reads the launcher from show --json when it starts. Restarting the session after a change is enough, since that's already needed for trust changes.
  • Permission mode. The session passes none. A launcher that doesn't contain anything (say --launcher env) gets a claude -p in default mode, which can't run tools without asking. That fails safe, but it isn't useful. Should setup refuse a known non-confining launcher, or should the skill pass a mode? I left that to the launcher on purpose.
  • Codex. The orchestrator and workers are Claude Code, as in the skill today. A Codex worker would need its own command after the launcher.
  • PATH. Doctor resolves the launcher with its own PATH. A session started with a different PATH (from herdr, say) could resolve it differently.

Summary by cubic

Lets connect setup run each Basecamp worker through a sandbox launcher. The session running the skill previously handed each request to an in-process subagent; with a launcher set, it instead starts <launcher words> claude -p < promptfile> from the chosen repo. Without a launcher, behavior is unchanged; with one, failures are reported to the person, never silently dropped.

  • --launcher (repeated, one argv word each) records the command in connect.json; --no-launcher removes it. Both flags are scriptable, never guided.
  • Setup and Parse refuse empty words, control characters, relative program paths, a program starting with -, more than 16 words, or words over 1 KiB; the program must be a name on PATH or an absolute path.
  • show prints the launcher word by word; doctor checks the program resolves without starting it. The connector itself never reads the setting.

Written for commit 5702996. Summary will update on new commits.

Review in cubic

The connector starts nothing since #814/#815: the session running the
basecamp-connect skill hands each request to a subagent of its own. That
dispatch is the one place a worker starts, so it is where a launcher goes.

connect.json gains an optional "launcher", argv words set with repeated
--launcher and removed with --no-launcher. With one, the skill runs each
worker as `<launcher words> claude -p < prompt` from the repo it chose,
instead of an in-process subagent. Words are never passed through a shell;
the program is a name on PATH or an absolute path. Show prints it word by
word, doctor checks the program can be found, and Parse refuses a launcher
setup would refuse.
Copilot AI balanced review requested due to automatic review settings October 6, 2026 05:00
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) skills Agent skills labels Oct 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

3 issues found across 9 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/commands/connect.go">

<violation number="1" location="internal/commands/connect.go:876">
P2: This accepts launcher words that the dispatch command cannot safely represent. Reject apostrophes in the shared launcher validation or execute the launcher with an argv-safe API instead of interpolating words into single-quoted Bash.</violation>
</file>

<file name="internal/connector/setup/file.go">

<violation number="1" location="internal/connector/setup/file.go:278">
P2: `Parse` can accept malformed launcher strings after `encoding/json` silently rewrites them, so the executed argv differs from the value in `connect.json` despite this validation. Reject decoder-lossy raw JSON strings before decoding, as admission does for legacy paths.</violation>

<violation number="2" location="internal/connector/setup/file.go:280">
P3: `ValidLauncher` rejects only Cc (control) characters, but the class the comment says must be refused — "a control character, which a terminal would act on when the launcher is shown" — also covers Unicode format characters (Cf): U+202E (right-to-left override), U+200B, U+FEFF. Those pass through the check, and show is safe (strconv.Quote/escapeControls escape non-printable runes) but `launcherCheck` is not: `ShellQuote` + `SanitizeSingleLine` leave Cf runes intact, so a hand-edited connect.json can put a bidi override into `connect doctor`'s "Workers run through …" message and render spoofed text in the terminal. Reject Cf runes as well, e.g. `unicode.IsControl(r) || unicode.Is(unicode.Cf, r)`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This accepts launcher words that the dispatch command cannot safely represent. Reject apostrophes in the shared launcher validation or execute the launcher with an argv-safe API instead of interpolating words into single-quoted Bash.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/commands/connect.go, line 876:

<comment>This accepts launcher words that the dispatch command cannot safely represent. Reject apostrophes in the shared launcher validation or execute the launcher with an argv-safe API instead of interpolating words into single-quoted Bash.</comment>

<file context>
@@ -839,6 +869,14 @@ func (f *connectSetupFlags) changes() (setup.Changes, error) {
+		if err := setup.ValidLauncher(f.launcher); err != nil {
+			return ch, output.ErrUsage("Invalid --launcher: " + err.Error())
+		}
+		ch.Launcher = slices.Clone(f.launcher)
+	}
+	ch.ClearLauncher = f.noLauncher
</file context>

return fmt.Errorf("word %d is empty", i+1)
case len(w) > MaxLauncherWordBytes:
return fmt.Errorf("word %d is longer than %d bytes", i+1, MaxLauncherWordBytes)
case !utf8.ValidString(w):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: Parse can accept malformed launcher strings after encoding/json silently rewrites them, so the executed argv differs from the value in connect.json despite this validation. Reject decoder-lossy raw JSON strings before decoding, as admission does for legacy paths.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/connector/setup/file.go, line 278:

<comment>`Parse` can accept malformed launcher strings after `encoding/json` silently rewrites them, so the executed argv differs from the value in `connect.json` despite this validation. Reject decoder-lossy raw JSON strings before decoding, as admission does for legacy paths.</comment>

<file context>
@@ -225,6 +238,56 @@ func (f File) Validate() error {
+			return fmt.Errorf("word %d is empty", i+1)
+		case len(w) > MaxLauncherWordBytes:
+			return fmt.Errorf("word %d is longer than %d bytes", i+1, MaxLauncherWordBytes)
+		case !utf8.ValidString(w):
+			return fmt.Errorf("word %d is not valid UTF-8", i+1)
+		case strings.IndexFunc(w, unicode.IsControl) >= 0:
</file context>

return fmt.Errorf("word %d is longer than %d bytes", i+1, MaxLauncherWordBytes)
case !utf8.ValidString(w):
return fmt.Errorf("word %d is not valid UTF-8", i+1)
case strings.IndexFunc(w, unicode.IsControl) >= 0:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: ValidLauncher rejects only Cc (control) characters, but the class the comment says must be refused — "a control character, which a terminal would act on when the launcher is shown" — also covers Unicode format characters (Cf): U+202E (right-to-left override), U+200B, U+FEFF. Those pass through the check, and show is safe (strconv.Quote/escapeControls escape non-printable runes) but launcherCheck is not: ShellQuote + SanitizeSingleLine leave Cf runes intact, so a hand-edited connect.json can put a bidi override into connect doctor's "Workers run through …" message and render spoofed text in the terminal. Reject Cf runes as well, e.g. unicode.IsControl(r) || unicode.Is(unicode.Cf, r).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/connector/setup/file.go, line 280:

<comment>`ValidLauncher` rejects only Cc (control) characters, but the class the comment says must be refused — "a control character, which a terminal would act on when the launcher is shown" — also covers Unicode format characters (Cf): U+202E (right-to-left override), U+200B, U+FEFF. Those pass through the check, and show is safe (strconv.Quote/escapeControls escape non-printable runes) but `launcherCheck` is not: `ShellQuote` + `SanitizeSingleLine` leave Cf runes intact, so a hand-edited connect.json can put a bidi override into `connect doctor`'s "Workers run through …" message and render spoofed text in the terminal. Reject Cf runes as well, e.g. `unicode.IsControl(r) || unicode.Is(unicode.Cf, r)`.</comment>

<file context>
@@ -225,6 +238,56 @@ func (f File) Validate() error {
+			return fmt.Errorf("word %d is longer than %d bytes", i+1, MaxLauncherWordBytes)
+		case !utf8.ValidString(w):
+			return fmt.Errorf("word %d is not valid UTF-8", i+1)
+		case strings.IndexFunc(w, unicode.IsControl) >= 0:
+			return fmt.Errorf("word %d holds a control character", i+1)
+		}
</file context>
Suggested change

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands CLI command implementations skills Agent skills tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants