mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-14 17:40:37 +02:00
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:
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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user