Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 49 additions & 35 deletions apps/server/src/sandbox/SandboxProvisioner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
import { layerHttpClient, requestJson } from "./SandboxProvider.ts";
import { bridgeLinkSecretName } from "@t3tools/sandbox-bridge/link";
import { boatDriver } from "./providers/boat.ts";
import { SANDBOX_FILES_SKILL } from "./sandboxSetup.ts";
import { SANDBOX_AGENT_INSTRUCTIONS, SANDBOX_FILES_SKILL } from "./sandboxSetup.ts";
import { celestoDriver } from "./providers/celesto.ts";
import { daytonaDriver } from "./providers/daytona.ts";
import { upstashDriver } from "./providers/upstash.ts";
Expand Down Expand Up @@ -5825,6 +5825,12 @@ describe("SandboxProvisioner per-thread launches", () => {
expect(fake.commands.filter((command) => command.includes("userdata/sub-sandbox"))).toEqual(
[],
);
// Its agents still learn that the machine is disposable.
expect(
fake.commands.filter((command) =>
command.includes(Buffer.from(SANDBOX_AGENT_INSTRUCTIONS, "utf8").toString("base64")),
),
).toHaveLength(1);
// The same clone and env files as a thread's launch, at the parent's pushed branch.
const clone = fake.commands.find((command) =>
command.includes("https://github.com/octo/hello.git"),
Expand Down Expand Up @@ -6607,39 +6613,46 @@ describe("SandboxProvisioner per-thread launches", () => {
);

const skillWarning =
"T3 Code's t3-sandbox-files skill could not be added, so agents on this sandbox may keep files in /tmp, which a stop empties.";
"T3 Code's sandbox instructions could not be written, so agents on this sandbox may not know that a stop empties /tmp and the sandbox is deleted when its chat ends.";
const skillCommands = (fake: BoatFake) =>
fake.commands.filter((command) => command.includes("skills/t3-sandbox-files"));

it.effect("gives every launched sandbox T3 Code's skill, even with no skills chosen", () => {
const fake = boatFake();
const configs = memoryConfigs();
return Effect.gen(function* () {
const provisioner = yield* SandboxProvisioner.SandboxProvisioner;
const { config } = yield* provisioner.saveConfig({
config: configInput,
apiKey: "boat_test",
});
expect(config.skills ?? []).toEqual([]);
expect(config.claudeMd).not.toBe(true);
yield* provisioner.launch({ configId: config.id, threadId, project });
const done = yield* snapshotWhere(provisioner, (snapshot) => snapshot.phase === "done");
// A launch writes it whether or not a copy is there, so a template's older one goes.
const [written, ...rest] = skillCommands(fake);
expect(rest).toEqual([]);
expect(written).toContain('"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/t3-sandbox-files"');
expect(written).not.toContain("exit 0");
expect(written).toContain(Buffer.from(SANDBOX_FILES_SKILL, "utf8").toString("base64"));
// Nothing the config asked for, so the step still reads as nothing to set up.
expect(stageOf(done, "tools")).toMatchObject({ status: "skipped" });
expect(done.warnings).not.toContain(skillWarning);
}).pipe(
Effect.provide(provisionerLayer({}, memoryRepository().layer, { configs: configs.layer })),
Effect.provideService(HttpClient.HttpClient, fake.client),
);
});
it.effect(
"gives every launched sandbox T3 Code's skill and instructions, even with none chosen",
() => {
const fake = boatFake();
const configs = memoryConfigs();
return Effect.gen(function* () {
const provisioner = yield* SandboxProvisioner.SandboxProvisioner;
const { config } = yield* provisioner.saveConfig({
config: configInput,
apiKey: "boat_test",
});
expect(config.skills ?? []).toEqual([]);
expect(config.claudeMd).not.toBe(true);
yield* provisioner.launch({ configId: config.id, threadId, project });
const done = yield* snapshotWhere(provisioner, (snapshot) => snapshot.phase === "done");
const [written, ...rest] = skillCommands(fake);
expect(rest).toEqual([]);
expect(written).toContain('"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/t3-sandbox-files"');
expect(written).toContain(Buffer.from(SANDBOX_FILES_SKILL, "utf8").toString("base64"));
expect(written).toContain(
Buffer.from(SANDBOX_AGENT_INSTRUCTIONS, "utf8").toString("base64"),
);
for (const dir of ["CLAUDE_CONFIG_DIR", "CODEX_HOME", "PI_CODING_AGENT_DIR"]) {
expect(written).toContain(`"\${${dir}:-`);
}
// Nothing the config asked for, so the step still reads as nothing to set up.
expect(stageOf(done, "tools")).toMatchObject({ status: "skipped" });
expect(done.warnings).not.toContain(skillWarning);
}).pipe(
Effect.provide(provisionerLayer({}, memoryRepository().layer, { configs: configs.layer })),
Effect.provideService(HttpClient.HttpClient, fake.client),
);
},
);

it.effect("only warns when the launch cannot write T3 Code's skill", () => {
it.effect("only warns when the launch cannot write T3 Code's skill and instructions", () => {
const fake = boatFake({ failing: { text: "skills/t3-sandbox-files", times: 1 } });
const repository = memoryRepository();
const configs = memoryConfigs();
Expand All @@ -6665,7 +6678,7 @@ describe("SandboxProvisioner per-thread launches", () => {
});

it.effect(
"adds T3 Code's skill on a resume when it is missing, before T3 starts, and only warns when it cannot",
"rewrites T3 Code's skill and instructions on a resume, before T3 starts, and only warns when it cannot",
() => {
const fake = boatFake({ failing: { text: "skills/t3-sandbox-files", times: 1 } });
const config: SandboxEnvironmentConfig = {
Expand Down Expand Up @@ -6704,11 +6717,12 @@ describe("SandboxProvisioner per-thread launches", () => {
fake.commands.length = 0;
const resumed = yield* provisioner.resume({ instanceId: "before-skill" });
expect(resumed.warnings ?? []).not.toContain(skillWarning);
// Only a missing skill is written, so one already there keeps its copy.
const [backfill, ...rest] = skillCommands(fake);
const [rewrite, ...rest] = skillCommands(fake);
expect(rest).toEqual([]);
expect(backfill).toContain('[ -f "$dir/SKILL.md" ] && exit 0');
expect(fake.commands.indexOf(backfill!)).toBeLessThan(
expect(rewrite).toContain(
Buffer.from(SANDBOX_AGENT_INSTRUCTIONS, "utf8").toString("base64"),
);
expect(fake.commands.indexOf(rewrite!)).toBeLessThan(
fake.commands.findIndex((command) => command.includes("t3 serve")),
);
}).pipe(
Expand Down
30 changes: 15 additions & 15 deletions apps/server/src/sandbox/SandboxProvisioner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ import {
RAILWAY_LOGIN_SCRUB,
railwayLoginScript,
parseSetupScriptReport,
sandboxFilesSkillScript,
sandboxAgentGuidanceScript,
sandboxKeepAliveScript,
setupScriptScript,
TAILSCALE_PROXY_URL,
Expand Down Expand Up @@ -495,8 +495,8 @@ const MAPLE_KEY_SECONDS = 30 * 24 * 60 * 60;
const SUB_SANDBOX_HELPER_WARNING =
"T3 Code's sub-sandbox helper could not be added, so agents on this sandbox cannot ask this computer for a sandbox of their own.";

const SANDBOX_FILES_SKILL_WARNING =
"T3 Code's t3-sandbox-files skill could not be added, so agents on this sandbox may keep files in /tmp, which a stop empties.";
const SANDBOX_AGENT_GUIDANCE_WARNING =
"T3 Code's sandbox instructions could not be written, so agents on this sandbox may not know that a stop empties /tmp and the sandbox is deleted when its chat ends.";
// A sandbox's GitHub token is replaced this long before it expires.
const GITHUB_TOKEN_REFRESH_MS = 20 * 60 * 1000;
// A keep-alive key outlives any one running stretch and is replaced on a resume once it is close
Expand Down Expand Up @@ -3001,12 +3001,12 @@ mkdir -p ${parents.map(shellQuote).join(" ")}`,
steps += 1;
yield* copyClaudeFiles(machine, { claudeMd: config.claudeMd === true, skills }, fail);
}
// T3 Code's own skill goes on every sandbox, after the user's so a template's copy is replaced.
// It is not something the config asked for, so only a failure shows in the step.
const skillWritten = yield* machine.run(sandboxFilesSkillScript("write"), 30).pipe(Effect.exit);
if (Exit.isFailure(skillWritten)) {
// T3 Code's skill and instructions block go on every sandbox, after the user's CLAUDE.md and
// skills so the copy keeps the block. The config did not ask for them: only a failure shows.
const guidanceWritten = yield* machine.run(sandboxAgentGuidanceScript(), 30).pipe(Effect.exit);
if (Exit.isFailure(guidanceWritten)) {
steps += 1;
yield* fail(SANDBOX_FILES_SKILL_WARNING);
yield* fail(SANDBOX_AGENT_GUIDANCE_WARNING);
}
// A chat's agents can ask this host for sandboxes of their own; a subagent's sandbox has no
// chat on this host to hold their tasks, so it never gets the helper. Only a failure shows.
Expand Down Expand Up @@ -4082,18 +4082,18 @@ mkdir -p ${parents.map(shellQuote).join(" ")}`,
};
};
}
// A sandbox made before T3 Code's agent skill existed gets it before T3 starts, so the first
// Claude session after the resume already sees it; one that has it keeps its copy.
const skill = yield* onHttp(
// Every resume rewrites T3 Code's skill and instructions block before T3 starts, so a sandbox
// made before them, or with older text, has the current ones by the first agent session.
const guidance = yield* onHttp(
driver.execute({
config,
sandboxId: record.sandboxId,
command: sandboxFilesSkillScript("if-missing"),
timeoutSeconds: SANDBOX_RESUME_STEP_SECONDS.agentSkill,
command: sandboxAgentGuidanceScript(),
timeoutSeconds: SANDBOX_RESUME_STEP_SECONDS.agentGuidance,
}),
).pipe(
Effect.as<string | null>(null),
Effect.orElseSucceed(() => SANDBOX_FILES_SKILL_WARNING),
Effect.orElseSucceed(() => SANDBOX_AGENT_GUIDANCE_WARNING),
);
// Every resume rewrites the sub-sandbox helper, so one made before it existed gets it and an
// older copy is replaced, again before T3 starts. Only a chat's sandbox has it.
Expand Down Expand Up @@ -4190,7 +4190,7 @@ mkdir -p ${parents.map(shellQuote).join(" ")}`,
const warnings = [
rejoined.warning,
kept.warning,
skill,
guidance,
helper,
github.warning,
bridged.warning,
Expand Down
76 changes: 53 additions & 23 deletions apps/server/src/sandbox/sandboxSetup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ import {
parseTailscaleReport,
RAILWAY_LOGIN_SCRUB,
railwayLoginScript,
SANDBOX_AGENT_INSTRUCTIONS,
SANDBOX_FILES_SKILL,
SANDBOX_FILES_SKILL_NAME,
sandboxFilesSkillScript,
SANDBOX_INSTRUCTIONS_END,
SANDBOX_INSTRUCTIONS_START,
sandboxAgentGuidanceScript,
sandboxKeepAliveScript,
type SandboxScript,
tailscaleScript,
Expand Down Expand Up @@ -360,7 +363,7 @@ describe("setupScriptScript", () => {
);
});

describe("sandboxFilesSkillScript", () => {
describe("sandboxAgentGuidanceScript", () => {
it("names the skill so it can't replace one of the user's, and says when to load it", () => {
expect(SANDBOX_FILES_SKILL_NAME).toBe("t3-sandbox-files");
const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(SANDBOX_FILES_SKILL)?.[1] ?? "";
Expand All @@ -371,27 +374,54 @@ describe("sandboxFilesSkillScript", () => {
}
});

it.live("writes the skill into Claude's folder, and on a resume only when it is missing", () =>
withHome((home) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const file = `${home}/.claude/skills/t3-sandbox-files/SKILL.md`;
yield* runIn(home, sandboxFilesSkillScript("write"));
expect(yield* fs.readFileString(file)).toBe(SANDBOX_FILES_SKILL);

// A copy already there stays as it is on a resume, and goes on a launch.
yield* fs.writeFileString(file, "edited");
yield* runIn(home, sandboxFilesSkillScript("if-missing"));
expect(yield* fs.readFileString(file)).toBe("edited");
yield* runIn(home, sandboxFilesSkillScript("write"));
expect(yield* fs.readFileString(file)).toBe(SANDBOX_FILES_SKILL);

yield* fs.remove(`${home}/.claude/skills`, { recursive: true });
yield* runIn(home, sandboxFilesSkillScript("if-missing"));
expect(yield* fs.readFileString(file)).toBe(SANDBOX_FILES_SKILL);
expect(yield* fs.exists(`${file}.t3-tmp`)).toBe(false);
}),
),
it.live(
"writes the skill and one marked block per agent, keeping and rewriting around the user's text",
() =>
withHome((home) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const block = `${SANDBOX_INSTRUCTIONS_START}\n${SANDBOX_AGENT_INSTRUCTIONS}${SANDBOX_INSTRUCTIONS_END}\n`;
const skill = `${home}/.claude/skills/t3-sandbox-files/SKILL.md`;
const claude = `${home}/.claude/CLAUDE.md`;
const codex = `${home}/.codex/AGENTS.md`;
const pi = `${home}/.pi/agent/AGENTS.md`;
yield* runIn(home, sandboxAgentGuidanceScript());
expect(yield* fs.readFileString(skill)).toBe(SANDBOX_FILES_SKILL);
for (const file of [claude, codex, pi])
expect(yield* fs.readFileString(file)).toBe(block);

// A launch copies the user's CLAUDE.md over the old one; the block goes after their text.
yield* fs.writeFileString(claude, "# Mine\n\nKeep this.");
yield* fs.writeFileString(skill, "edited");
yield* runIn(home, sandboxAgentGuidanceScript());
expect(yield* fs.readFileString(claude)).toBe(`# Mine\n\nKeep this.\n\n${block}`);
expect(yield* fs.readFileString(skill)).toBe(SANDBOX_FILES_SKILL);

// A rerun, or a copied CLAUDE.md that already holds an older block, keeps one block.
const edited = `# Mine\n\n${SANDBOX_INSTRUCTIONS_START}\nold text\n${SANDBOX_INSTRUCTIONS_END}\nAfter.\n`;
yield* fs.writeFileString(claude, edited);
yield* runIn(home, sandboxAgentGuidanceScript());
yield* runIn(home, sandboxAgentGuidanceScript());
expect(yield* fs.readFileString(claude)).toBe(`# Mine\n\n${block}After.\n`);

// The block joins the file each agent actually reads, never shadowing the user's.
yield* fs.remove(pi);
yield* fs.writeFileString(`${home}/.pi/agent/CLAUDE.md`, "pi rules\n");
yield* fs.writeFileString(`${home}/.codex/AGENTS.override.md`, "codex rules\n");
yield* runIn(home, sandboxAgentGuidanceScript());
expect(yield* fs.exists(pi)).toBe(false);
expect(yield* fs.readFileString(`${home}/.pi/agent/CLAUDE.md`)).toBe(
`pi rules\n\n${block}`,
);
expect(yield* fs.readFileString(`${home}/.codex/AGENTS.override.md`)).toBe(
`codex rules\n\n${block}`,
);
expect(yield* fs.readFileString(codex)).toBe(block);
for (const file of [skill, claude, codex]) {
expect(yield* fs.exists(`${file}.t3-tmp`)).toBe(false);
}
}),
),
);
});

Expand Down
Loading