Merge final Boat legacy runtime and environment guards

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* commit '3673939bed4cb85f20d142f950547e3a60502bca':
  fix(computers): expose persistent instructions and managed CLI skills
  fix(adapters): expose Boat instructions and managed Claude skills
  fix(ui): honor forced Kubernetes before Boat engine checks
This commit is contained in:
DottaandPaperclip committed 2026-10-10 15:33:06 -05:00
commit 2df40da458
9 files changed
+298 -10

No files matched your search

@@ -1,5 +1,7 @@
import * as fs from "node:fs/promises";
import os from "node:os";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
@@ -22,7 +24,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
};
});
import { prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime } from "./claude-config.js";
import { buildRemoteClaudeConfigMaterializationCommand, prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime } from "./claude-config.js";
describe("prepareClaudeConfigSeed", () => {
const cleanupDirs: string[] = [];
@@ -267,3 +269,31 @@ describe("prepareSandboxClaudeProbeRuntime managed-config diagnostics", () => {
warnSpy.mockRestore();
});
});
describe("managed Claude user-scope skills", () => {
it("refreshes only its skill symlink and refuses an existing directory", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-user-skills-"));
const config = path.join(root, "config");
const seed = path.join(root, "seed");
const first = path.join(root, "first-skills");
const second = path.join(root, "second-skills");
const run = (remoteSkillsDir: string) => promisify(execFile)("sh", ["-c",
buildRemoteClaudeConfigMaterializationCommand({
remoteClaudeConfigDir: config, remoteClaudeConfigSeedDir: seed, remoteSkillsDir,
}),
], { env: { PATH: process.env.PATH, HOME: root } });
try {
await fs.mkdir(first); await fs.mkdir(second);
await run(first);
expect(await fs.realpath(path.join(config, "skills"))).toBe(await fs.realpath(first));
await run(second);
expect(await fs.realpath(path.join(config, "skills"))).toBe(await fs.realpath(second));
await fs.unlink(path.join(config, "skills"));
await fs.mkdir(path.join(config, "skills"));
await fs.writeFile(path.join(config, "skills", "keep.txt"), "user file");
await expect(run(first)).rejects.toThrow();
expect(await fs.readFile(path.join(config, "skills", "keep.txt"), "utf8")).toBe("user file");
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
});
@@ -221,7 +221,16 @@ export async function prepareClaudeConfigSeed(
export function buildRemoteClaudeConfigMaterializationCommand(input: {
remoteClaudeConfigDir: string;
remoteClaudeConfigSeedDir: string;
remoteSkillsDir?: string;
}): string {
// --setting-sources user excludes --add-dir's project-scoped skills. Mount
// only the approved bundle into this isolated runtime's user skill scope.
const skillsLink = shellQuote(path.posix.join(input.remoteClaudeConfigDir, "skills"));
const mountSkills = input.remoteSkillsDir
? ` && { if [ -e ${skillsLink} ] && [ ! -L ${skillsLink} ]; then ` +
`echo 'Managed Claude skills path is not a symlink' >&2; exit 1; fi; ` +
`ln -sfn ${shellQuote(input.remoteSkillsDir)} ${skillsLink}; }`
: "";
return `mkdir -p ${shellQuote(input.remoteClaudeConfigDir)} && ` +
`if [ -d ${shellQuote(input.remoteClaudeConfigSeedDir)} ]; then ` +
`cp -R ${shellQuote(`${input.remoteClaudeConfigSeedDir}/.`)} ${shellQuote(input.remoteClaudeConfigDir)}/; ` +
@@ -230,7 +239,7 @@ export function buildRemoteClaudeConfigMaterializationCommand(input: {
`if [ -n "\${HOME:-}" ] && [ -f "\${HOME}/.claude/\${file}" ] && [ ! -f ${shellQuote(input.remoteClaudeConfigDir)}/"\${file}" ]; then ` +
`cp "\${HOME}/.claude/\${file}" ${shellQuote(input.remoteClaudeConfigDir)}/"\${file}"; ` +
`fi; ` +
`done`;
`done` + mountSkills;
}
export async function materializeRemoteClaudeConfig(input: {
@@ -238,17 +247,22 @@ export async function materializeRemoteClaudeConfig(input: {
target: AdapterExecutionTarget | null | undefined;
remoteClaudeConfigDir: string;
remoteClaudeConfigSeedDir: string;
remoteSkillsDir?: string;
options: AdapterExecutionTargetShellOptions;
}): Promise<void> {
await runAdapterExecutionTargetShellCommand(
const result = await runAdapterExecutionTargetShellCommand(
input.runId,
input.target,
buildRemoteClaudeConfigMaterializationCommand({
remoteClaudeConfigDir: input.remoteClaudeConfigDir,
remoteClaudeConfigSeedDir: input.remoteClaudeConfigSeedDir,
remoteSkillsDir: input.remoteSkillsDir,
}),
input.options,
);
if (result.exitCode !== 0 || result.timedOut) {
throw new Error("Could not materialize the managed Claude config and skills");
}
}
function isNonEmptyString(value: unknown): value is string {
@@ -464,7 +464,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const hasExplicitClaudeConfigDir =
typeof configEnv.CLAUDE_CONFIG_DIR === "string" && configEnv.CLAUDE_CONFIG_DIR.trim().length > 0;
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
// Controller scratch supplies prompt bytes; persistent computer paths come
// from the registered instruction copy, including a nested entry file.
const persistentComputer = executionTarget?.kind === "remote" && executionTarget.transport === "computer";
const promptInstructionsFilePath = persistentComputer
? asString(workspaceContext.instructionsFilePath, "").trim()
: instructionsFilePath;
const instructionsFileDir = promptInstructionsFilePath ? `${(persistentComputer ? path.posix : path).dirname(promptInstructionsFilePath)}/` : "";
const runtimeConfig = await buildClaudeRuntimeConfig({
agentIdentity: ctx.agentIdentity,
runId,
@@ -513,12 +519,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (instructionsFilePath) {
try {
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
instructionsPathDirective =
`Agent instructions for this run were loaded from ${instructionsFilePath}. ` +
instructionsPathDirective = promptInstructionsFilePath ?
`Agent instructions for this run were loaded from ${promptInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsFileDir}. ` +
`This base directory is authoritative for sibling instruction files such as ` +
`./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory. ` +
`This location replaces any instruction file location from earlier turns.`;
`This location replaces any instruction file location from earlier turns.` : "";
combinedInstructionsContents = instructionsContent +
"\nUse the agent instruction file location supplied in the current run prompt to resolve relative file references.";
} catch (err) {
@@ -723,6 +729,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
target: executionTarget,
remoteClaudeConfigDir,
remoteClaudeConfigSeedDir,
remoteSkillsDir: config.managedAiConnection
? path.posix.join(effectivePromptBundleAddDir, ".claude", "skills") : undefined,
options: {
cwd,
env,
@@ -1090,15 +1090,23 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
);
}
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
// Controller scratch supplies prompt bytes; persistent computer paths come
// from the registered instruction copy, including a nested entry file.
const persistentComputer = executionTarget?.kind === "remote" && executionTarget.transport === "computer";
const promptInstructionsFilePath = persistentComputer
? asString(workspaceContext.instructionsFilePath, "").trim()
: instructionsFilePath;
const instructionsDir = promptInstructionsFilePath ? `${(persistentComputer ? path.posix : path).dirname(promptInstructionsFilePath)}/` : "";
let instructionsPrefix = "";
if (instructionsFilePath) {
try {
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
instructionsPrefix =
`${instructionsContents}\n\n` +
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`;
(promptInstructionsFilePath
? `The above agent instructions were loaded from ${promptInstructionsFilePath}. ` +
`Resolve any relative file references from ${instructionsDir}.\n\n`
: "");
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
await onLog(
@@ -983,6 +983,110 @@ describe("claude execute", () => {
}
}, 10_000);
it("uses registered persistent instruction paths in computer CLI prompts", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-"));
const localWorkspace = path.join(root, "workspace");
const remoteWorkspace = path.join(root, "sandbox-$HOME");
const binDir = path.join(root, "bin");
const commandPath = path.join(binDir, "claude");
const capturePath1 = path.join(remoteWorkspace, "capture-1.json");
const claudeRoot = path.join(root, ".claude");
const localInstructions = path.join(localWorkspace, "AGENTS.md");
const remoteInstructions = path.join(remoteWorkspace, "nested", "AGENTS.md");
const runner = createLocalSandboxRunner();
const previousHome = process.env.HOME;
const previousPath = process.env.PATH;
await fs.mkdir(localWorkspace, { recursive: true });
await fs.mkdir(remoteWorkspace, { recursive: true });
await fs.mkdir(binDir, { recursive: true });
await fs.mkdir(claudeRoot, { recursive: true });
await fs.writeFile(path.join(claudeRoot, "settings.json"), JSON.stringify({ theme: "test" }), "utf8");
await writeFakeClaudeCommand(commandPath);
await fs.writeFile(localInstructions, "Persistent guidance contents.");
process.env.HOME = root;
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
try {
const result = await execute({
runId: "run-sandbox-auth",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
engine: "cli",
managedAiConnection: true,
command: commandPath,
cwd: localWorkspace,
instructionsFilePath: localInstructions,
env: {
CLAUDE_CONFIG_DIR: claudeRoot,
PAPERCLIP_TEST_CAPTURE_PATH: capturePath1,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: { paperclipWorkspace: { instructionsFilePath: remoteInstructions, agentHome: remoteWorkspace } },
executionTarget: {
kind: "remote",
transport: "computer",
providerKey: "e2b",
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd: remoteWorkspace,
timeoutMs: 30_000,
runner,
processRunner: runner,
listenerPort: 12345,
resourceAuthority: { kind: "computer-owner", computerId: "computer-1", ownerId: "owner-1", generation: 1 },
fileAuthority: { kind: "remote-persistent", placementId: "placement-1", root: remoteWorkspace, agentHome: remoteWorkspace },
workspaceRealization: { mode: "in_place", authoritativeRoot: remoteWorkspace },
launch: async () => ({}),
inspectProcess: async () => ({ running: false, claim: null }),
retainWarm: async () => {},
retire: async () => true,
computerTool: { command: "unused", args: [] },
},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(result.exitCode).toBe(0);
const capture = JSON.parse(await fs.readFile(capturePath1, "utf8")) as CapturePayload;
expect(capture.argv).toContain("--dangerously-skip-permissions");
expect(capture.argv).not.toContain("--allowedTools");
expect(capture.claudeConfigDir).toContain(path.join(remoteWorkspace, ".paperclip-runtime", "claude", "owners"));
expect(capture.argv.slice(capture.argv.indexOf("--setting-sources"), capture.argv.indexOf("--setting-sources") + 2)).toEqual(["--setting-sources", "user"]);
const userSkills = path.join(capture.claudeConfigDir!, "skills");
expect(await fs.realpath(userSkills)).toBe(await fs.realpath(path.join(capture.addDir!, ".claude", "skills")));
expect(await fs.readFile(path.join(userSkills, "paperclip", "SKILL.md"), "utf8")).toContain("name: paperclip");
expect(capture.instructionsContents).toContain("Persistent guidance contents.");
expect(capture.claudeConfigEntries).toContain("settings.json");
expect(capture.prompt).toContain(remoteInstructions);
expect(capture.prompt).toContain(`${path.dirname(remoteInstructions)}/`);
expect(capture.prompt).not.toContain(localInstructions);
expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(capture.paperclipApiKey).not.toBe("run-jwt-token");
expect(capture.paperclipApiBridgeMode).toBe("queue_v1");
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
await fs.rm(root, { recursive: true, force: true });
}
}, 10_000);
it("omits --effort for sandbox-managed runs when the installed Claude CLI does not advertise it", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-sandbox-effort-"));
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root, {
@@ -538,6 +538,101 @@ describe("codex execute", () => {
}
});
it("uses registered persistent instruction paths in computer CLI prompts", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-sandbox-"));
const localWorkspace = path.join(root, "workspace");
const remoteWorkspace = path.join(root, "sandbox");
const binDir = path.join(root, "bin");
const commandPath = path.join(binDir, "codex");
const capturePath = path.join(remoteWorkspace, "capture.json");
const localInstructions = path.join(localWorkspace, "AGENTS.md");
const remoteInstructions = path.join(remoteWorkspace, "nested", "AGENTS.md");
const runner = createLocalSandboxRunner();
const previousHome = process.env.HOME;
const previousPath = process.env.PATH;
await fs.mkdir(localWorkspace, { recursive: true });
await fs.mkdir(remoteWorkspace, { recursive: true });
await fs.mkdir(binDir, { recursive: true });
await writeFakeCodexCommand(commandPath);
await fs.writeFile(localInstructions, "Persistent guidance contents.");
process.env.HOME = root;
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
await seedSharedCodexAuth(root);
try {
const result = await execute({
runId: "run-sandbox-auth",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Codex Coder",
adapterType: "codex_local",
adapterConfig: { engine: "cli" },
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
engine: "cli",
command: commandPath,
cwd: localWorkspace,
instructionsFilePath: localInstructions,
env: {
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
},
promptTemplate: "Follow the paperclip heartbeat.",
},
context: { paperclipWorkspace: { instructionsFilePath: remoteInstructions, agentHome: remoteWorkspace } },
executionTarget: {
kind: "remote",
transport: "computer",
providerKey: "e2b",
environmentId: "env-1",
leaseId: "lease-1",
remoteCwd: remoteWorkspace,
timeoutMs: 30_000,
runner,
processRunner: runner,
listenerPort: 12345,
resourceAuthority: { kind: "computer-owner", computerId: "computer-1", ownerId: "owner-1", generation: 1 },
fileAuthority: { kind: "remote-persistent", placementId: "placement-1", root: remoteWorkspace, agentHome: remoteWorkspace },
workspaceRealization: { mode: "in_place", authoritativeRoot: remoteWorkspace },
launch: async () => ({}),
inspectProcess: async () => ({ running: false, claim: null }),
retainWarm: async () => {},
retire: async () => true,
computerTool: { command: "unused", args: [] },
},
authToken: "run-jwt-token",
onLog: async () => {},
});
expect(result.exitCode).toBe(0);
expect(result.errorMessage).toBeNull();
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
expect(capture.codexHome).toContain(path.join(remoteWorkspace, ".paperclip-runtime", "codex", "owners"));
expect(capture.prompt).toContain("Persistent guidance contents.");
expect(capture.prompt).toContain(remoteInstructions);
expect(capture.prompt).toContain(`${path.dirname(remoteInstructions)}/`);
expect(capture.prompt).not.toContain(localInstructions);
expect(capture.paperclipApiUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(capture.paperclipApiKey).not.toBe("run-jwt-token");
expect(capture.paperclipApiBridgeMode).toBe("queue_v1");
} finally {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousPath === undefined) delete process.env.PATH;
else process.env.PATH = previousPath;
await fs.rm(root, { recursive: true, force: true });
}
});
it.each([false, true])("delivers oversized wake context through stdin (sandbox=%s)", async (sandbox) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-wake-"));
const workspace = path.join(root, "workspace");
+3
View File
@@ -4711,6 +4711,9 @@ export function heartbeatService(
if (isAgentDirectoryCopy(instructionCopy)) {
const workspace = parseObject(context.paperclipWorkspace);
context.paperclipWorkspace = { ...workspace, agentHome: instructionCopy.executionRoot,
...(instructionCopy.receipt?.fileAuthority === "remote-persistent" ? {
instructionsFilePath: path.posix.join(instructionCopy.executionRoot, instructionCopy.entryFile),
} : {}),
// Keep the pre-existing permission root stable for ACP session
// identity. The per-run copy is already under the company root.
agentHomeForPermissions: workspace.agentHome,
@@ -3923,6 +3923,31 @@ describe("AgentConfigForm managed-sandbox-only host surfaces", () => {
expect(result.onSave).toHaveBeenCalledWith(expect.objectContaining({ adapterConfig: expect.objectContaining({ engine: "cli" }) }));
});
it.each(["codex_local", "claude_local"] as const)("does not apply Boat engine restrictions when Kubernetes overrides %s execution", async adapterType => {
setManagedSandboxOnly(true);
mockInstanceSettingsApi.getGeneral.mockResolvedValue({ executionMode: "kubernetes" });
const saveActions = vi.fn();
const result = await renderForm(
[
makeEnvironment({ id: "boat-1", name: "Boat", driver: "computer", status: "active", config: { provider: "boat" } }),
makeEnvironment({ id: "kubernetes-1", name: "Kubernetes", driver: "sandbox", status: "active", config: { provider: "kubernetes" } }),
],
{ adapterType, defaultEnvironmentId: "boat-1", adapterConfig: { engine: "acp" } },
{ onSaveActionChange: saveActions, showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
await act(async () => {
for (const button of result.container.querySelectorAll("button")) {
if (["Advanced", "Advanced Run Policy"].includes(button.textContent?.trim() ?? "")) button.click();
}
});
await flushReact();
expect(result.container.textContent).not.toContain("CLI before saving or testing");
expect(fieldLabels(result.container)).not.toContain("Execution engine");
expect(findButton(result.container, "Test")?.disabled).toBe(false);
expect(saveActions.mock.lastCall?.[0]).toEqual(expect.any(Function));
});
it.each(["active", "archived"] as const)("only exposes the engine for a resolved active Boat, keeping managed host paths hidden (%s)", async status => {
setManagedSandboxOnly(true);
const result = await renderForm(
+1
View File
@@ -807,6 +807,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
[environments, effectiveLoginEnvironmentId],
);
const attachedBoat = experimentalSettings !== undefined
&& !forcedKubernetes
&& effectiveLoginEnvironment?.status === "active"
&& effectiveLoginEnvironment.driver === "computer"
&& effectiveLoginEnvironment.config.provider === "boat";