@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:
|
|
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
|
|
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
|
@@ -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.
|