@tablation/crew 0.111.1 → 0.112.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -6131,6 +6131,61 @@ var State = class _State {
6131
6131
 
6132
6132
  // src/environment.ts
6133
6133
  import { basename as basename6 } from "path";
6134
+
6135
+ // src/sandbox-brief.ts
6136
+ var SANDBOX_SKILL_SWAPS = [
6137
+ ["Worktree Setup", "Sandbox Setup"]
6138
+ ];
6139
+ var SANDBOX_STUB_DIR = "/sandbox/.crew-bin";
6140
+ var CONTAINER_ENGINES = ["docker", "podman", "nerdctl", "docker-compose"];
6141
+ var CONTAINER_ENGINE_MESSAGE = 'crew: this run is inside a sandbox, which has no container engine. Run the service as a process in hooks.setup instead (ports from hooks.ports, overrides from hooks.isolate) \u2014 see "Services inside the sandbox" in REPO_SPEC.md.';
6142
+ function swapSandboxSkills(text2) {
6143
+ let out = text2;
6144
+ for (const [host, sandbox] of SANDBOX_SKILL_SWAPS) out = out.split(`\`${host}\``).join(`\`${sandbox}\``);
6145
+ return out;
6146
+ }
6147
+ function containerEngineStubScript(dir = SANDBOX_STUB_DIR) {
6148
+ const body = `#!/bin/sh
6149
+ printf '%s\\n' ${shSingle(CONTAINER_ENGINE_MESSAGE)} >&2
6150
+ exit 125
6151
+ `;
6152
+ return [
6153
+ `mkdir -p ${shSingle(dir)}`,
6154
+ ...CONTAINER_ENGINES.map((e) => `printf '%b' ${shSingle(body)} > ${shSingle(`${dir}/${e}`)} && chmod 755 ${shSingle(`${dir}/${e}`)}`)
6155
+ ].join(" && ");
6156
+ }
6157
+ function shSingle(s) {
6158
+ return `'${s.replace(/'/g, `'\\''`)}'`;
6159
+ }
6160
+ function containerEngineUse(hook2) {
6161
+ const names = CONTAINER_ENGINES.map((e) => e.replace(/-/g, "\\-")).join("|");
6162
+ const re = new RegExp(`(?:^|[;&|(\`]|&&|\\|\\||\\$\\()\\s*(?:(?:then|do|else|elif|if|while|until|time|exec)\\s+)*(?:sudo\\s+)?(?:\\S*/)?(${names})(?=\\s|$)`, "m");
6163
+ const m = re.exec(hook2.split("\n").filter((l) => !/^\s*#/.test(l)).join("\n"));
6164
+ return m ? m[1] : null;
6165
+ }
6166
+ function sandboxCheckoutSection(workDir, heading) {
6167
+ return [
6168
+ "",
6169
+ heading,
6170
+ "",
6171
+ `You are already inside a sandbox created for this run. \`${workDir}\` is a fresh`,
6172
+ "clone of the repository on a fresh branch, and it is your working directory. There is",
6173
+ "no primary checkout and no other worktree: do not cut a worktree, do not run `crew",
6174
+ "worktree`, and do not look for files to copy from a main checkout. The digest's",
6175
+ "`worktree` column does not apply; its `branch` column is the name your work is pushed",
6176
+ "under, so create it with `git checkout -b <branch>` in the clone.",
6177
+ "",
6178
+ "The sandbox has no container engine, and nothing installs at run time: a service the",
6179
+ "tests need is a process the repository's `setup` hook starts. Fetch the `Sandbox Setup`",
6180
+ "skill for setup, ports and the hand-off push."
6181
+ ];
6182
+ }
6183
+ function sandboxEnv(ship) {
6184
+ return ship.runner === "sandbox" ? { sandbox: { workDir: ship.sandbox?.workDir ?? DEFAULT_SANDBOX_WORKDIR } } : {};
6185
+ }
6186
+ var DEFAULT_SANDBOX_WORKDIR = "/sandbox/work";
6187
+
6188
+ // src/environment.ts
6134
6189
  var API_KEY_VAR = "CREW_API_KEY";
6135
6190
  function authorshipBullets(a, contract) {
6136
6191
  if (!a) return [];
@@ -6414,7 +6469,9 @@ function renderEnvironment(i) {
6414
6469
  ] : []
6415
6470
  );
6416
6471
  }
6417
- if (only) {
6472
+ if (only && i.sandbox) {
6473
+ lines2.push(...sandboxCheckoutSection(i.sandbox.workDir, "## Your checkout"));
6474
+ } else if (only) {
6418
6475
  lines2.push(...repoSection(only, key, "## Your worktree"));
6419
6476
  } else {
6420
6477
  lines2.push(
@@ -6427,8 +6484,9 @@ function renderEnvironment(i) {
6427
6484
  "ignore the others. A ticket whose repo is not listed here has no checkout on",
6428
6485
  "this machine and is not yours to work."
6429
6486
  );
6487
+ if (i.sandbox) lines2.push(...sandboxCheckoutSection(i.sandbox.workDir, "### Your checkout"));
6430
6488
  for (const r of repos) {
6431
- lines2.push(...repoSection(r, key, `### ${r.name} \u2014 \`${r.dir}\``));
6489
+ lines2.push(...i.sandbox ? ["", `### ${r.name}`] : repoSection(r, key, `### ${r.name} \u2014 \`${r.dir}\``));
6432
6490
  lines2.push("", `Hooks for ${r.name} \u2014 the only commands you should run here:`, "", hookTable(r.config));
6433
6491
  }
6434
6492
  }
@@ -9714,7 +9772,7 @@ var execFileP = promisify(execFile);
9714
9772
  var DEFAULT_SANDBOX_CONFIG = {
9715
9773
  bin: "openshell",
9716
9774
  mirrorDir: "/cache",
9717
- workDir: "/sandbox/work"
9775
+ workDir: DEFAULT_SANDBOX_WORKDIR
9718
9776
  };
9719
9777
  var SANDBOX_STDIN_LIMIT = 4 * 1024 * 1024 - 64 * 1024;
9720
9778
  var SANDBOX_PROMPT_PATH = "/sandbox/prompt.txt";
@@ -9818,14 +9876,14 @@ var SandboxRunner = class {
9818
9876
  }
9819
9877
  try {
9820
9878
  const branch = `crew-run-${name.slice(-8)}`;
9821
- const clone = cloneScript({
9879
+ const clone = `${cloneScript({
9822
9880
  mirrorDir: cfg.mirrorDir,
9823
9881
  repo,
9824
9882
  workDir: cfg.workDir,
9825
9883
  origin,
9826
9884
  branch,
9827
9885
  lockfile: cfg.depsLockfile ?? DEFAULT_CACHE_SETTINGS.depsLockfile
9828
- });
9886
+ })} && ${containerEngineStubScript()}`;
9829
9887
  await this.os(["sandbox", "exec", "-n", name, "--no-login-shell", "--", "sh", "-c", clone]);
9830
9888
  const harness = cfg.agentBin ?? basename8(plan.bin);
9831
9889
  const viaUpload = Buffer.byteLength(plan.prompt) > SANDBOX_STDIN_LIMIT;
@@ -9839,7 +9897,8 @@ var SandboxRunner = class {
9839
9897
  rmSync12(tmp, { recursive: true, force: true });
9840
9898
  }
9841
9899
  }
9842
- const command2 = viaUpload ? ["sh", "-c", `exec ${[harness, ...plan.args].map(shQuote2).join(" ")} < ${SANDBOX_PROMPT_PATH}`] : [harness, ...plan.args];
9900
+ const withStubs = `PATH=${shQuote2(SANDBOX_STUB_DIR)}:"$PATH"; export PATH; `;
9901
+ const command2 = viaUpload ? ["sh", "-c", `${withStubs}exec ${[harness, ...plan.args].map(shQuote2).join(" ")} < ${SANDBOX_PROMPT_PATH}`] : ["sh", "-c", `${withStubs}exec "$@"`, "sh", harness, ...plan.args];
9843
9902
  const child = spawn2(cfg.bin, ["sandbox", "exec", "-n", name, "--no-login-shell", "--workdir", cfg.workDir, "--", ...command2], {
9844
9903
  env: process.env,
9845
9904
  stdio: ["pipe", o.streamFd, "inherit"],
@@ -10032,6 +10091,7 @@ function planAgentRun(o) {
10032
10091
  if (e instanceof SkillIncludeError) throw new AgentError(`${e.message} \u2014 refusing to run an unscoped session`);
10033
10092
  throw e;
10034
10093
  }
10094
+ if (o.ship.runner === "sandbox") expanded = { ...expanded, text: swapSandboxSkills(expanded.text) };
10035
10095
  const prompt = assemblePrompt({
10036
10096
  roster: o.roster,
10037
10097
  environment: o.environment,
@@ -13009,6 +13069,22 @@ async function runPoolMember(deps) {
13009
13069
  relayReported = null;
13010
13070
  continue;
13011
13071
  }
13072
+ if (body.action === "tokens") {
13073
+ if (body.leaseId !== current) {
13074
+ deps.log(`ignoring a tokens message for ${body.leaseId}; this member holds ${current ?? "no lease"}`);
13075
+ continue;
13076
+ }
13077
+ const applied = await deps.applyTokens(body.forges ?? []);
13078
+ if (!applied.ok) {
13079
+ deps.log(`tokens: ${applied.reason ?? "could not apply the delivered forge tokens"}`);
13080
+ continue;
13081
+ }
13082
+ deps.log(`tokens: replaced the forge tokens (${(body.forges ?? []).length} host${(body.forges ?? []).length === 1 ? "" : "s"})`);
13083
+ const r2 = await report("running", { leaseId: current });
13084
+ if (r2 === "credential") return POOL_EXIT.credential;
13085
+ if (r2 === "stopped") return POOL_EXIT.ok;
13086
+ continue;
13087
+ }
13012
13088
  if (current) {
13013
13089
  deps.log("ignoring a provision payload: a lease is already held");
13014
13090
  continue;
@@ -14023,6 +14099,27 @@ function realPoolMemberDeps(o) {
14023
14099
  if (outcome.code === PROVISION_EXIT.ok) return { ok: true };
14024
14100
  return { ok: false, reason: `crew provision exited ${outcome.code}: ${lines2.at(-1) ?? "no detail"}` };
14025
14101
  },
14102
+ async applyTokens(forges) {
14103
+ const want = new Map(forges.map((f) => [f.host.toLowerCase(), f.token]));
14104
+ for (const [host, token] of want) {
14105
+ const stored = provisionDeps.storedToken(host);
14106
+ if (stored === token) continue;
14107
+ const res = provisionDeps.crew(["forge", stored === null ? "set" : "rotate", host, "--token-stdin"], { input: `${token}
14108
+ ` });
14109
+ if (res.status !== 0) return { ok: false, reason: `forge ${host}: ${res.stderr.trim() || `crew forge exited ${res.status}`}` };
14110
+ }
14111
+ const cfg = loadShip(o.crewHome);
14112
+ if (!cfg) return { ok: false, reason: "no ship config to apply forge tokens to" };
14113
+ for (const host of Object.keys(cfg.ship.forges ?? {})) {
14114
+ if (want.has(host.toLowerCase())) continue;
14115
+ try {
14116
+ eraseForgeToken(cfg.ship.forges, host, cfg.ship.stateDir, env);
14117
+ } catch (e) {
14118
+ return { ok: false, reason: `forge ${host}: ${e.message}` };
14119
+ }
14120
+ }
14121
+ return { ok: true };
14122
+ },
14026
14123
  async reconcile() {
14027
14124
  const before = provisionDeps.installState();
14028
14125
  if (before.unitsStale || !before.running || !before.lingering) {
@@ -19204,6 +19301,10 @@ function checkRepoSpec(dir, opts = {}) {
19204
19301
  }
19205
19302
  const effective = resolveRepoConfig(parsed, opts.ship, dir);
19206
19303
  const readiness = validateEffective(effective);
19304
+ if (effective.sandbox) {
19305
+ const engine = effective.hooks.setup ? containerEngineUse(effective.hooks.setup) : null;
19306
+ if (engine) readiness.push(`hooks.setup runs \`${engine}\`, and a sandbox has no container engine \u2014 start the service as a process instead (REPO_SPEC.md, "Services inside the sandbox")`);
19307
+ }
19207
19308
  const hooks = Object.keys(effective.hooks).map((name) => ({
19208
19309
  name,
19209
19310
  label: hookLabel(effective, name),
@@ -21699,7 +21800,7 @@ async function buildEnvironment(route2, ship, ticket) {
21699
21800
  });
21700
21801
  const authorship = await tracker.authorshipProbe().catch(() => null);
21701
21802
  const attachments = await tracker.attachmentsProbe().catch(() => null);
21702
- return renderEnvironment({ route: route2, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket, authorship, attachments, shipName: ship.name });
21803
+ return renderEnvironment({ route: route2, userAgent: ship.userAgent, repos, contract, sourceTicket: ticket, authorship, attachments, shipName: ship.name, ...sandboxEnv(ship) });
21703
21804
  }
21704
21805
 
21705
21806
  // src/control-api.ts
@@ -23861,7 +23962,8 @@ ${changed2.map((c) => ` - ${c}
23861
23962
  sourceTicket: ticket,
23862
23963
  authorship: await tracker.authorshipProbe().catch(() => null),
23863
23964
  attachments: await tracker.attachmentsProbe().catch(() => null),
23864
- shipName: cfg.ship.name
23965
+ shipName: cfg.ship.name,
23966
+ ...sandboxEnv(cfg.ship)
23865
23967
  });
23866
23968
  }
23867
23969
  async function releaserLine(tracker, routeName, repo, release) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tablation/crew",
3
- "version": "0.111.1",
3
+ "version": "0.112.0",
4
4
  "description": "A standing team of headless agents that picks work off a Tablation board, does it on a machine you control, and reports back.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: Sandbox Setup
3
+ description: How a lane agent prepares and hands off from a sandboxed run — the checkout is already cut, then the setup, ports and handoff hooks and the push. Fetch where the brief names the Worktree Setup skill and your environment section says you are in a sandbox.
4
+ ---
5
+ # Setting up inside a sandbox
6
+
7
+ A sandboxed run starts in a fresh clone on a fresh branch. Your working
8
+ directory is that clone. There is no primary checkout, no other worktree and no
9
+ `crew` command to cut one: skip every step of the lane policy that cuts a
10
+ worktree, copies files across from a main checkout, or runs `crew sync` or
11
+ `crew worktree`. The Environment section names the checkout's path.
12
+
13
+ ## Branch
14
+
15
+ The clone starts on a throwaway branch. Create the ticket's branch from it —
16
+ the digest's `branch` column, verbatim — with `git checkout -b <branch>`, and
17
+ commit there. Never commit on the throwaway branch.
18
+
19
+ ## Making it runnable
20
+
21
+ Run the **`setup`** hook from the checkout, verbatim. Nothing installs from a
22
+ network at run time: the toolchains and packages are already in the image, and
23
+ a download that is not is blocked. If setup needs something the image lacks,
24
+ that is the repository's `sandbox:` declaration to fix, not yours: say so on
25
+ the ticket and stop.
26
+
27
+ - **Services.** A service the tests need (a database, a queue) is a process the
28
+ `setup` hook starts inside this sandbox. The sandbox has no container engine:
29
+ `docker` and `podman` exit at once naming this pattern. Use the ports the
30
+ **`ports`** hook prints and export what the **`isolate`** hook prints; both
31
+ are the same hooks a host run uses.
32
+ - **`isolate`** — run it, and export what it prints, whenever the ticket touches
33
+ stored state. The sandbox's own services are already private to this run,
34
+ but the hook is where the repository says which settings point at them.
35
+ - **`handoff`** — a preview server cannot be reached from outside a sandbox, so
36
+ there is nothing to leave running. Skip it unless the repository's own
37
+ instructions say otherwise, and say in your progress comment that you did.
38
+
39
+ ## Running the suite
40
+
41
+ Run the **`test`** and **`build`** hooks in the foreground, as the lane policy
42
+ says. Stop every process `setup` started before you finish; the sandbox is
43
+ deleted when the run ends, so nothing outlives it, but a service still writing
44
+ when the push happens makes the result harder to judge.
45
+
46
+ ## Handing off
47
+
48
+ Commit on the ticket's branch and push it as a plain fast-forward to `origin`,
49
+ exactly as the lane policy says: `git push --set-upstream origin <branch>`.
50
+ Push credentials are already in place. The sandbox is deleted at the end of the
51
+ run, so **anything not pushed is gone**; there is no worktree for QA to pick up
52
+ and QA cuts its own from the remote branch you pushed. Put which hooks you ran,
53
+ and their results, in your progress comment.
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: Sandbox Setup
3
+ description: How a lane agent prepares and hands off from a sandboxed run — the checkout is already cut, then the setup, ports and handoff hooks and the push. Fetch where the brief names the Worktree Setup skill and your environment section says you are in a sandbox.
4
+ ---
5
+ # Setting up inside a sandbox
6
+
7
+ A sandboxed run starts in a fresh clone on a fresh branch. Your working
8
+ directory is that clone. There is no primary checkout, no other worktree and no
9
+ `crew` command to cut one: skip every step of the lane policy that cuts a
10
+ worktree, copies files across from a main checkout, or runs `crew sync` or
11
+ `crew worktree`. The Environment section names the checkout's path.
12
+
13
+ ## Branch
14
+
15
+ The clone starts on a throwaway branch. Create the ticket's branch from it —
16
+ the digest's `branch` column, verbatim — with `git checkout -b <branch>`, and
17
+ commit there. Never commit on the throwaway branch.
18
+
19
+ ## Making it runnable
20
+
21
+ Run the **`setup`** hook from the checkout, verbatim. Nothing installs from a
22
+ network at run time: the toolchains and packages are already in the image, and
23
+ a download that is not is blocked. If setup needs something the image lacks,
24
+ that is the repository's `sandbox:` declaration to fix, not yours: say so on
25
+ the ticket and stop.
26
+
27
+ - **Services.** A service the tests need (a database, a queue) is a process the
28
+ `setup` hook starts inside this sandbox. The sandbox has no container engine:
29
+ `docker` and `podman` exit at once naming this pattern. Use the ports the
30
+ **`ports`** hook prints and export what the **`isolate`** hook prints; both
31
+ are the same hooks a host run uses.
32
+ - **`isolate`** — run it, and export what it prints, whenever the ticket touches
33
+ stored state. The sandbox's own services are already private to this run,
34
+ but the hook is where the repository says which settings point at them.
35
+ - **`handoff`** — a preview server cannot be reached from outside a sandbox, so
36
+ there is nothing to leave running. Skip it unless the repository's own
37
+ instructions say otherwise, and say in your progress comment that you did.
38
+
39
+ ## Running the suite
40
+
41
+ Run the **`test`** and **`build`** hooks in the foreground, as the lane policy
42
+ says. Stop every process `setup` started before you finish; the sandbox is
43
+ deleted when the run ends, so nothing outlives it, but a service still writing
44
+ when the push happens makes the result harder to judge.
45
+
46
+ ## Handing off
47
+
48
+ Commit on the ticket's branch and push it as a plain fast-forward to `origin`,
49
+ exactly as the lane policy says: `git push --set-upstream origin <branch>`.
50
+ Push credentials are already in place. The sandbox is deleted at the end of the
51
+ run, so **anything not pushed is gone**; there is no worktree for QA to pick up
52
+ and QA cuts its own from the remote branch you pushed. Put which hooks you ran,
53
+ and their results, in your progress comment.