fix(connections): preserve provider workspaces and follow-up context

Keep selected model and skill paths current across disposable connection homes. Preserve private Grok history and assigned OpenCode and Hermes workspaces. Fix artifact helper paths and cover the boundaries with regression checks.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-06 08:41:41 -05:00
1 parent 089a417650
commit 01e756bc61
28 files changed
+529 -75

No files matched your search

@@ -52,6 +52,7 @@ import {
const tempRoots: string[] = [];
let defaultGeminiTestHome: string | undefined;
async function makeTempRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acpx-skills-"));
@@ -60,6 +61,7 @@ async function makeTempRoot() {
}
afterEach(async () => {
defaultGeminiTestHome = undefined;
// A remote run stages a process-session bridge whose detached event writer can
// still be flushing a trailing event file into `.../process-sessions/<id>/events`
// when the run's own best-effort `client.remove(sessionDir)` (which production
@@ -178,6 +180,13 @@ async function runExecutor(
startupTraceContext?: AdapterExecutionContext["startupTraceContext"];
} = {},
) {
// Skill reconciliation must never inspect or modify the developer's real
// Gemini home. Keep one private home across this test's resumed turns.
const configuredEnv = config.env as Record<string, unknown> | undefined;
if (config.agent === "gemini" && !(typeof configuredEnv?.HOME === "string" && configuredEnv.HOME.trim())) {
defaultGeminiTestHome ??= await makeTempRoot();
config = { ...config, env: { ...configuredEnv, HOME: defaultGeminiTestHome } };
}
const runtimeOptions: Record<string, unknown>[] = [];
const configOptions: Array<{ key: string; value: string }> = [];
const sessionInputs: Record<string, unknown>[] = [];
@@ -603,7 +612,7 @@ describe("shared ACPX engine runtime behavior", () => {
});
});
it("keeps Claude startup model handling and Gemini session config handling unchanged", async () => {
it("sets Claude and Gemini models at startup without unsupported picker calls", async () => {
const claude = await runExecutor({ agent: "claude", model: "claude-opus-4-7" });
expect((claude.meta[0]?.env as Record<string, string>).ANTHROPIC_MODEL).toBe(
"claude-opus-4-7",
@@ -614,9 +623,10 @@ describe("shared ACPX engine runtime behavior", () => {
agent: "gemini",
model: "gemini-2.5-pro",
thinkingEffort: "high",
env: { GEMINI_MODEL: "stale-model" },
});
expect((gemini.meta[0]?.env as Record<string, string>).GEMINI_MODEL).toBe("gemini-2.5-pro");
expect(gemini.configOptions).toEqual([
{ key: "model", value: "gemini-2.5-pro" },
{ key: "effort", value: "high" },
]);
});
@@ -1584,6 +1594,29 @@ describe("shared ACPX engine runtime behavior", () => {
expect(await pathExists(path.join(codexHome, "skills", operational.runtimeName, "SKILL.md"))).toBe(true);
});
it.each(["codex", "gemini"])("refreshes the %s skill root when a connection home changes between turns", async (agent) => {
const root = await makeTempRoot();
const operational = {
...await createSkill(path.join(root, "skills"), "paperclip"),
key: "paperclipai/paperclip/paperclip",
};
const homes = [path.join(root, "first-home"), path.join(root, "next-home")];
for (const home of homes) {
const { meta } = await runExecutor({
agent,
stateDir: path.join(root, "state"),
env: agent === "codex" ? { CODEX_HOME: home } : { HOME: home },
paperclipRuntimeSkills: [operational],
paperclipSkillSync: { desiredSkills: [] },
});
const skillsHome = agent === "codex" ? path.join(home, "skills") : path.join(home, ".gemini", "skills");
expect(String(meta[0]?.prompt ?? "")).toContain(`Skill root for this run: ${skillsHome}`);
expect(String(meta[0]?.prompt ?? "")).toContain("Resolve referenced scripts and files relative to that skill");
if (home === homes[1]) expect(String(meta[0]?.prompt ?? "")).not.toContain(homes[0]!);
await fs.rm(home, { recursive: true, force: true });
}
});
it.skipIf(process.platform === "win32")("removes legacy ACPX Codex skill symlinks when a skill is no longer desired", async () => {
const root = await makeTempRoot();
const skillRoot = path.join(root, "skills");
@@ -2870,6 +2903,21 @@ describe("findAncestorBin", () => {
});
describe("gemini ACP flag selection", () => {
it("uses the canonical local Gemini workspace for ACP filesystem requests", async () => {
const root = await makeTempRoot();
const workspace = path.join(root, "workspace");
const alias = path.join(root, "workspace-alias");
await fs.mkdir(workspace);
await fs.symlink(workspace, alias, process.platform === "win32" ? "junction" : "dir");
const { runtimeOptions, sessionInputs } = await runExecutor({
agent: "gemini", agentCommand: "node ./fake-acp.js", cwd: alias,
stateDir: path.join(root, "state"),
});
const canonical = await fs.realpath(workspace);
expect(runtimeOptions[0]!.cwd).toBe(canonical);
expect(sessionInputs[0]!.cwd).toBe(canonical);
});
it("parses semantic version parts from gemini --version output", () => {
expect(parseGeminiVersionParts("0.30.0")).toEqual([0, 30, 0]);
expect(parseGeminiVersionParts("gemini-cli v1.2.3\n")).toEqual([1, 2, 3]);
@@ -3526,6 +3574,16 @@ describe("ACPX engine remote sandbox staging seam (PR 1: workspace + cwd)", () =
expect(sessionInputs[0]?.cwd).not.toBe(localCwd);
});
it("preserves Gemini's in-sandbox workspace without host canonicalization", async () => {
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
const { sessionInputs, runtimeOptions } = await runExecutor(
{ agent: "gemini", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd },
{ authToken: "real-run-jwt", executionTarget },
);
expect(runtimeOptions[0]?.cwd).toBe(remoteCwd);
expect(sessionInputs[0]?.cwd).toBe(remoteCwd);
});
it("test_remote_warm_handle_reused_after_cwd_change", async () => {
const { stateDir, localCwd, remoteCwd, executionTarget } = await setupRemoteSandbox();
const ensureInputs: Array<Record<string, unknown>> = [];
@@ -2039,6 +2039,12 @@ async function buildRuntime(input: {
if (requestedModel && acpxAgent === "claude" && !env.ANTHROPIC_MODEL) {
env.ANTHROPIC_MODEL = requestedModel;
}
// Gemini CLI reads GEMINI_MODEL when creating each ACP session. Its ACP
// implementation supports set_model, but not set_config_option; configure
// the selected model before startup without changing the transport.
if (requestedModel && acpxAgent === "gemini") {
env.GEMINI_MODEL = requestedModel;
}
if (acpxAgent === "codex") {
const codexStartupConfig = buildCodexStartupConfig({
existingConfig: env.CODEX_CONFIG,
@@ -2184,8 +2190,9 @@ async function buildRuntime(input: {
// (fingerprint, compat, persist, ensureSession, error) bind to THIS single
// value so a warm/resumable session created with the in-sandbox cwd is reused
// — not invalidated — on the next run. Remote runner-backed → the in-sandbox
// workspace dir; local and the runner-less fallback → the HOST cwd,
// byte-identical to today.
// workspace dir; local and the runner-less fallback → the HOST cwd.
// Local Gemini resolves tool paths through realpath. Give its ACP filesystem
// client the same canonical root so workspace aliases stay within that root.
//
// PR 3: the staging transport derives the in-sandbox workspace dir
// deterministically from the target's `remoteCwd` (it is exactly `remoteCwd`
@@ -2198,7 +2205,9 @@ async function buildRuntime(input: {
const sessionCwd =
useRemoteProcessSession && executionTarget?.kind === "remote"
? executionTarget.remoteCwd
: cwd;
: acpxAgent === "gemini" && !executionTargetIsRemote
? await fs.realpath(cwd)
: cwd;
// The 17 fields the session fingerprint hashes. Company, agent, and task
// identifiers are NOT here; they scope the outer session key only (see
// `keyIdentity`). The fingerprint builder accepts only this identity.
@@ -2542,6 +2551,28 @@ async function buildRuntime(input: {
await emitRunPhaseTiming(input.ctx, "start_transport", nowMs() - startTransportStart, "failed");
throw err;
}
const currentSkillRoot = acpxAgent === "codex" && runtimeEnv.CODEX_HOME
? path.join(runtimeEnv.CODEX_HOME, "skills")
: acpxAgent === "gemini" && !executionTargetIsRemote && typeof skillsIdentity.skillsHome === "string"
? skillsIdentity.skillsHome
: null;
if (currentSkillRoot && Array.isArray(skillsIdentity.selectedSkills) && skillsIdentity.selectedSkills.length > 0) {
// Managed connection homes are disposable. A resumed conversation may
// remember a previous home's skill paths; supply the current root on every
// turn, after the remote launch environment has rebased CODEX_HOME.
skillPromptInstructions = [
`Paperclip has materialized selected runtime skills for this ACPX ${acpxAgent === "codex" ? "Codex" : "Gemini"} run.`,
`Skill root for this run: ${currentSkillRoot}`,
`Selected skills: ${skillsIdentity.selectedSkills.join(", ")}`,
"When a task calls for one of these skills, read its SKILL.md from this root and follow it. Resolve referenced scripts and files relative to that skill. Use this run's root even when an earlier turn used a different path.",
...(acpxAgent === "gemini" && skillsIdentity.selectedSkills.includes("paperclip")
? [
"Before calling Paperclip APIs or delivering task artifacts, read the selected paperclip skill's SKILL.md and its referenced artifact workflow. A completion comment does not upload an artifact or change task status.",
"Gemini's shell tool rejects command substitution. Write JSON request bodies to a workspace file and use curl --data-binary @file, or use the selected skill's helpers, instead of constructing a body with command substitution.",
]
: []),
].join("\n");
}
// The relay runs on the host with the sanitized remote launch environment.
// Its /usr/bin/env node shebang cannot rely on that environment's PATH.
const overrideCommand = processSessionBridge?.agentCommand
@@ -2609,13 +2640,14 @@ async function buildRuntime(input: {
function sessionConfigOptions(prepared: AcpxPreparedRuntime): Array<{ key: string; value: string }> {
const options: Array<{ key: string; value: string }> = [];
// Claude and Codex runtime config is pre-set via startup env vars; skip
// Claude, Codex and Gemini runtime config is pre-set via startup env vars; skip
// set_config_option to avoid ACP-server picker validation rejecting valid
// backend model IDs that are not advertised by the local ACP server.
if (
prepared.requestedModel &&
prepared.acpxAgent !== "claude" &&
prepared.acpxAgent !== "codex"
prepared.acpxAgent !== "codex" &&
prepared.acpxAgent !== "gemini"
) {
options.push({ key: "model", value: prepared.requestedModel });
}
@@ -4761,6 +4793,8 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
? `Requested ACPX model: ${prepared.requestedModel} (set via ANTHROPIC_MODEL env at startup).`
: prepared.acpxAgent === "codex"
? `Requested ACPX model: ${prepared.requestedModel} (set via CODEX_CONFIG at startup).`
: prepared.acpxAgent === "gemini"
? `Requested ACPX model: ${prepared.requestedModel} (set via GEMINI_MODEL at startup).`
: `Requested ACPX model: ${prepared.requestedModel}.`,
]
: []),
@@ -0,0 +1,100 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, expect, it } from "vitest";
import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "acpx/runtime";
const roots: string[] = [];
const fixture = fileURLToPath(new URL("../../../../scripts/mcp-fixtures/servers/acp-filesystem-agent.mjs", import.meta.url));
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
async function workspace() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-acp-filesystem-"));
roots.push(root);
const cwd = path.join(root, "workspace");
await fs.mkdir(cwd);
return { root, cwd: await fs.realpath(cwd) };
}
async function drive(input: {
cwd: string;
root: string;
permissionMode?: "approve-all" | "deny-all";
operations: Array<{ method: string; path: string; content?: string }>;
}) {
let stderr = "";
const runtime = createAcpRuntime({
cwd: input.cwd,
sessionStore: createRuntimeStore({ stateDir: path.join(input.root, "state") }),
agentRegistry: createAgentRegistry({ overrides: { custom: `${JSON.stringify(process.execPath.replaceAll("\\", "/"))} ${JSON.stringify(fixture.replaceAll("\\", "/"))}` } }),
permissionMode: input.permissionMode ?? "approve-all",
nonInteractivePermissions: "deny",
inheritProcessEnv: false,
onAgentStderr: (chunk: string) => { stderr += chunk; },
});
const handle = await runtime.ensureSession({
sessionKey: "filesystem-protocol", agent: "custom", mode: "oneshot",
cwd: input.cwd, sessionOptions: { env: {} },
});
try {
for await (const _event of runtime.runTurn({
handle, text: JSON.stringify({ operations: input.operations }),
mode: "prompt", requestId: "filesystem-protocol", timeoutMs: 5_000,
})) { /* Drain the real ACP turn. */ }
return stderr.split("\n").filter((line) => line.startsWith("FS_RESULT "))
.map((line) => JSON.parse(line.slice("FS_RESULT ".length)));
} finally {
await runtime.close({ handle, reason: "done" });
}
}
it("reports a missing file as an ACP resource error and then creates and reads it", async () => {
const input = await workspace();
const file = path.join(input.cwd, "new.json");
const content = '{"proof":"synthetic"}\n';
const receipts = await drive({ ...input, operations: [
{ method: "fs/read_text_file", path: file },
{ method: "fs/write_text_file", path: file, content },
{ method: "fs/read_text_file", path: file },
] });
expect(receipts).toHaveLength(3);
expect(receipts[0].error).toMatchObject({ code: -32002, message: expect.stringContaining("Resource not found") });
expect(receipts[1].error).toBeUndefined();
expect(receipts[2].result.content).toBe(content);
expect(await fs.readFile(file, "utf8")).toBe(content);
});
it("keeps unrelated paths denied for both reads and writes", async () => {
const input = await workspace();
const outside = path.join(await fs.realpath(input.root), "outside.json");
await fs.writeFile(outside, "unchanged");
const receipts = await drive({ ...input, operations: [
{ method: "fs/read_text_file", path: outside },
{ method: "fs/write_text_file", path: outside, content: "changed" },
] });
expect(receipts).toHaveLength(2);
expect(receipts.every((receipt) => receipt.error && receipt.error.code !== -32002)).toBe(true);
expect(await fs.readFile(outside, "utf8")).toBe("unchanged");
});
it("preserves deny-all permissions instead of converting denials into missing files", async () => {
const input = await workspace();
const file = path.join(input.cwd, "denied.json");
const receipts = await drive({ ...input, permissionMode: "deny-all", operations: [
{ method: "fs/read_text_file", path: file },
{ method: "fs/write_text_file", path: file, content: "denied" },
] });
expect(receipts).toHaveLength(2);
expect(receipts.every((receipt) => receipt.error && receipt.error.code !== -32002)).toBe(true);
await expect(fs.stat(file)).rejects.toMatchObject({ code: "ENOENT" });
});
it.skipIf(process.platform === "win32")("keeps other filesystem failures distinct from missing files", async () => {
const input = await workspace();
const receipts = await drive({ ...input, operations: [{ method: "fs/read_text_file", path: input.cwd }] });
expect(receipts).toHaveLength(1);
expect(receipts[0].error.code).not.toBe(-32002);
});
@@ -60,6 +60,10 @@ Operational fields:
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- Explicit ACP models are set via \`GEMINI_MODEL\` before startup; this avoids unsupported \`session/set_config_option\` calls while preserving the ACP transport. Auto continues to use the installed CLI's model selection.
- Local turns include the current selected skill root and Paperclip artifact workflow guidance, including after a disposable connection home changes.
- Local ACP workspaces use their canonical directory so Gemini's resolved file paths match the client boundary. Missing files retain ACP resource-not-found semantics. Gemini CLI 0.58.0 still mishandles the plain error object during native new-file creation; the installed-CLI compatibility smoke documents this remaining limitation.
- Gemini shell commands may reject command substitution. Build JSON request bodies in workspace files and pass them with \`curl --data-binary @file\`, or use the selected skill's helpers.
- Gemini ACP is the preferred auto lane when Node >=24.11.0 and the local Gemini CLI command is available. It runs Gemini CLI's native \`gemini --acp\` server through Paperclip's shared ACP engine, including selected skill links, Paperclip runtime prompt/env guidance, model config, and persistent ACP session state. Missing prerequisites fail both default and explicit ACP runs with an actionable setup error; the adapter never switches engines automatically.
- Runs use --prompt for non-interactive execution, not stdin.
- The adapter sets a headless-safe terminal/browser environment for Gemini CLI child processes so unattended runs do not wait on browser auth or 256-color terminal prompts.
@@ -53,4 +53,5 @@ Notes:
- Use \`grok models\` to inspect authentication and available models on the host.
- Local subscription runs use the host \`grok login\` (\`~/.grok\`) until the company Grok home has a usable \`auth.json\` (sandbox device login). \`XAI_API_KEY\` authenticates without a home. Remote/sandbox runs never fall back to the host login.
- Without a usable company login, local runs preserve an inherited or configured \`GROK_HOME\`. Managed AI connections keep their selected home. An explicit empty \`XAI_API_KEY\` clears an inherited key and selects subscription authentication.
- Managed local connections retain private session transcripts separately from disposable credentials, scoped to company, agent and connection identity. Missing local history starts a fresh task handoff without attempting remote subscription recovery.
`;
@@ -163,6 +163,18 @@ async function makeCtx(runId: string, cwd: string): Promise<AdapterExecutionCont
}
describe("grok_local execute", () => {
it.each([false, true])("resumes a managed connection only when its isolated history exists (%s)", async present => {
const root = await makeTempRoot();
const ctx = await makeCtx("managed-resume", root);
ctx.config.managedAiConnection = true;
ctx.config.env = { GROK_HOME: path.join(root, "home"), XAI_API_KEY: "fixture-key" };
ctx.runtime.sessionParams = { sessionId: "fixture-session", cwd: root };
if (present) await fs.mkdir(path.join(root, "home", "sessions", "encoded-cwd", "fixture-session"), { recursive: true });
runProcessMock.mockResolvedValue(makeSuccessfulRunResult({ sessionId: "next-session" }));
const result = await execute(ctx);
expect((runProcessMock.mock.calls[0][3] as string[]).includes("--resume")).toBe(present);
expect(result.sessionParams?.sessionId).toBe("next-session");
});
it("resumes the conversation while rotating runtime tool access", async () => {
const root = await makeTempRoot();
runProcessMock.mockResolvedValue(makeSuccessfulRunResult({ sessionId: "existing-session" }));
@@ -47,7 +47,7 @@ import {
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
import { copyBackGrokAuth } from "./grok-auth-copyback.js";
import { grokHomeHasUsableAuth, resolveManagedGrokHomeDir, stageGrokHomeForSync } from "./grok-home.js";
import { grokHomeHasSession, grokHomeHasUsableAuth, resolveManagedGrokHomeDir, stageGrokHomeForSync } from "./grok-home.js";
import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -501,12 +501,17 @@ async function executeTurn(ctx: AdapterExecutionContext): Promise<AdapterExecuti
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
const missingManagedSession = !executionTargetIsRemote && Boolean(config.managedAiConnection) &&
Boolean(runtimeSessionId) && !await grokHomeHasSession(asString(env.GROK_HOME, ""), runtimeSessionId);
const canResumeSession =
runtimeSessionId.length > 0 &&
!missingManagedSession &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
const sessionId = canResumeSession ? runtimeSessionId : null;
if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
if (missingManagedSession) {
await onLog("stdout", "[paperclip] Selected Grok session history is unavailable. Starting a fresh session with the task handoff instead of remote subscription recovery.\n");
} else if (executionTargetIsRemote && runtimeSessionId && !canResumeSession) {
await onLog(
"stdout",
`[paperclip] Grok session "${runtimeSessionId}" does not match the current remote execution identity and will not be resumed in "${effectiveExecutionCwd}". Starting a fresh remote session.\n`,
@@ -653,7 +658,7 @@ async function executeTurn(ctx: AdapterExecutionContext): Promise<AdapterExecuti
stderrLine ||
`Grok exited with code ${attempt.proc.exitCode ?? -1}`;
const canFallbackToRuntimeSession = !isRetry;
const canFallbackToRuntimeSession = !isRetry && !missingManagedSession;
const resolvedSessionId = attempt.parsed.sessionId
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
const resolvedSessionParams = resolvedSessionId
@@ -722,7 +727,7 @@ async function executeTurn(ctx: AdapterExecutionContext): Promise<AdapterExecuti
return toResult(retry, true, true);
}
return toResult(initial);
return toResult(initial, missingManagedSession);
};
try {
@@ -3,7 +3,30 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { GROK_SYNC_ALLOWLIST, stageGrokHomeForSync } from "./grok-home.js";
import { GROK_SYNC_ALLOWLIST, grokHomeHasSession, stageGrokHomeForSync } from "./grok-home.js";
describe("grokHomeHasSession", () => {
it.skipIf(process.platform === "win32")("finds private linked history without following descendant links or accepting traversal", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-grok-history-"));
try {
const home = path.join(root, "home");
const sessions = path.join(root, "private-sessions");
const sessionId = "fixture-session";
const session = path.join(sessions, "encoded-workspace", sessionId);
await fs.mkdir(home);
await fs.mkdir(session, { recursive: true });
await fs.symlink(sessions, path.join(home, "sessions"), "dir");
expect(await grokHomeHasSession(home, sessionId)).toBe(true);
expect(await grokHomeHasSession(home, "missing-session")).toBe(false);
expect(await grokHomeHasSession(home, "../../private-sessions")).toBe(false);
await fs.rm(session, { recursive: true });
const outside = path.join(root, "outside", sessionId);
await fs.mkdir(outside, { recursive: true });
await fs.symlink(path.dirname(outside), path.join(sessions, "outside-link"), "dir");
expect(await grokHomeHasSession(home, sessionId)).toBe(false);
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
});
describe("GROK_SYNC_ALLOWLIST", () => {
it("holds exactly one name: auth.json", () => {
@@ -3,8 +3,8 @@ import os from "node:os";
import path from "node:path";
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
// The Grok credential home. `GROK_HOME` replaces `~/.grok` and holds one file,
// `auth.json`. Unlike Codex, a Grok `auth.json` has no fixed top-level key: it
// `GROK_HOME` replaces `~/.grok` and holds `auth.json` plus session history.
// Unlike Codex, a Grok `auth.json` has no fixed top-level key: it
// holds exactly one key, and that key is a composite `<issuer>::<uuid>` value.
// This module resolves the company-scoped home path and reads its usable-auth
// shape. It never writes to the managed home itself; {@link
@@ -14,6 +14,29 @@ import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-uti
const AUTH_FILE_NAME = "auth.json";
/** Inspect only the selected private home, never Grok's remote registry. */
export async function grokHomeHasSession(home: string, sessionId: string): Promise<boolean> {
if (!home || !/^[a-zA-Z0-9_-]{1,128}$/.test(sessionId)) return false;
let remaining = 5000;
async function inspect(directory: string, depth: number): Promise<boolean> {
let entries;
try { entries = await fs.opendir(directory); }
catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
for await (const entry of entries) {
if (--remaining < 0) throw new Error("Grok session history exceeds its lookup bound");
if (entry.isSymbolicLink()) continue;
if ((entry.isFile() || entry.isDirectory()) &&
(entry.name === sessionId || entry.name.startsWith(`${sessionId}.`))) return true;
if (entry.isDirectory() && depth > 0 && await inspect(path.join(directory, entry.name), depth - 1)) return true;
}
return false;
}
return inspect(path.join(home, "sessions"), 2);
}
/**
* The allowlist of managed `GROK_HOME` entries that the grok-local adapter
* stages into the sandbox `home` asset (see {@link stageGrokHomeForSync}).
+1
View File
@@ -237,6 +237,7 @@ Available toolsets: `terminal`, `file`, `web`, `browser`, `code_execution`, `vis
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `cwd` | string | *(assigned task workspace)* | Absolute working directory override. Without an override, use Paperclip's resolved task workspace. |
| `persistSession` | boolean | `true` | Resume sessions across heartbeats |
| `worktreeMode` | boolean | `false` | Git worktree isolation |
| `checkpoints` | boolean | `false` | Enable filesystem checkpoints for rollback |
+1
View File
@@ -99,6 +99,7 @@ tools, persistent memory, session persistence, skills, and MCP support.
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| cwd | string | (assigned task workspace) | Absolute working directory override; otherwise use Paperclip's resolved task workspace |
| persistSession | boolean | true | Resume sessions across heartbeats |
| worktreeMode | boolean | false | Use git worktree for isolated changes |
| checkpoints | boolean | false | Enable filesystem checkpoints |
@@ -113,6 +113,18 @@ describe("hermes-local adapter onSpawn forwarding", () => {
expect(call[2]).toContainEqual(expect.stringContaining("Current task brief"));
});
it("launches in the assigned task workspace when no cwd was configured", async () => {
const { ctx } = makeCtx();
await execute({ ...ctx, context: { ...ctx.context, paperclipWorkspace: { cwd: "/private/qa/agent-workspace" } } } as any);
expect(vi.mocked(serverUtils.runChildProcess).mock.calls.at(-1)![3].cwd).toBe("/private/qa/agent-workspace");
});
it("preserves an explicitly configured cwd", async () => {
const { ctx } = makeCtx({ cwd: "/private/qa/configured-workspace" });
await execute({ ...ctx, context: { ...ctx.context, paperclipWorkspace: { cwd: "/private/qa/agent-workspace" } } } as any);
expect(vi.mocked(serverUtils.runChildProcess).mock.calls.at(-1)![3].cwd).toBe("/private/qa/configured-workspace");
});
it("runChildProcess opts type includes onSpawn", () => {
// Type-level assertion: if onSpawn were removed from the type,
// this file would fail to compile. The runtime test above catches
@@ -510,8 +510,12 @@ export async function execute(
if (envCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = envCommentId;
// ── Resolve working directory ──────────────────────────────────────────
const workspace = ctx.context?.paperclipWorkspace;
const workspaceCwd = workspace && typeof workspace === "object"
? cfgString((workspace as Record<string, unknown>).cwd)
: undefined;
const cwd =
cfgString(config.cwd) || cfgString(ctx.config?.workspaceDir) || ".";
cfgString(config.cwd) || workspaceCwd || cfgString(ctx.config?.workspaceDir) || ".";
try {
await ensureAbsoluteDirectory(cwd);
} catch {
@@ -5,8 +5,10 @@ import {
mkdtemp,
readFile,
readdir,
realpath,
rm,
stat,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
@@ -804,7 +806,7 @@ describe("OpenCodeServerDriver", () => {
);
expect(config).toContain("openrouter/deepseek/deepseek-v4-flash-0731");
expect(config).toContain('"*": "allow"');
expect(config).toContain('"external_directory": "deny"');
expect(JSON.parse(config).permission.external_directory).toMatchObject({ "*": "deny", [`${workspace}/**`]: "allow" });
expect(
events.some((event) => event.eventType === "runtime_request.created"),
).toBe(false);
@@ -1337,8 +1339,10 @@ describe("OpenCodeServerDriver", () => {
}
expect(submittedPrompt).toMatchObject({
system: systemInstructions,
tools: { question: true },
});
// A prompt tools map replaces OpenCode's session permissions. Question is
// enabled in config without overwriting the allow/ask/deny or path policy.
expect(submittedPrompt).not.toHaveProperty("tools");
expect(JSON.stringify(submittedPrompt?.parts ?? null)).not.toContain(
PAPERCLIP_EXECUTION_PROMPT,
);
@@ -1436,7 +1440,6 @@ describe("OpenCodeServerDriver", () => {
expect(submittedPrompt).toMatchObject({
providerID: "openrouter",
modelID: "deepseek/deepseek-v4-flash-0731",
tools: { question: true },
parts: [
{
type: "text",
@@ -1444,6 +1447,7 @@ describe("OpenCodeServerDriver", () => {
},
],
});
expect(submittedPrompt).not.toHaveProperty("tools");
expect(submittedPrompt).not.toHaveProperty("system");
await recovered!.session!.close({ reason: "recovery-test" });
});
@@ -1831,7 +1835,7 @@ describe("OpenCodeServerDriver", () => {
);
expect(config.permission).toMatchObject({
"*": permissionMode,
external_directory: "deny",
external_directory: { "*": "deny", [`${workspace}/**`]: "allow" },
});
expect(config.provider.openrouter.models).toHaveProperty(
"deepseek/deepseek-v4-flash-0731",
@@ -1842,6 +1846,35 @@ describe("OpenCodeServerDriver", () => {
},
);
it.skipIf(process.platform === "win32")("allows the selected workspace alias and its canonical path while denying other directories", async () => {
await chmod(fixture, 0o755);
const root = await mkdtemp(join(tmpdir(), "paperclip-opencode-workspace-alias-"));
roots.push(root);
const workspace = join(root, "actual-workspace");
const alias = join(root, "workspace-alias");
await mkdir(workspace);
await symlink(workspace, alias, "dir");
const canonical = await realpath(workspace);
const driver = new OpenCodeServerDriver({
model: "openrouter/deepseek/deepseek-v4-flash-0731",
permissionMode: "allow",
runtimeDirectory: root,
command: fixture,
environment: { PATH: process.env.PATH, OPENROUTER_API_KEY: "fixture-key" },
});
const session = await driver.openSession({
runId: "run-workspace-alias", normalizedSessionId: "alias-session",
workingDirectory: alias,
});
try {
const config = JSON.parse(await readFile(join(root, "alias-session", "config", "opencode", "opencode.json"), "utf8"));
expect(config.permission.external_directory).toEqual({
"*": "deny", [alias]: "allow", [`${alias}/**`]: "allow",
[canonical]: "allow", [`${canonical}/**`]: "allow",
});
} finally { await session.close({ reason: "workspace alias test complete" }); }
});
it("clears a stale active turn that already has a persisted terminal fingerprint", async () => {
await chmod(fixture, 0o755);
const root = await mkdtemp(
@@ -5,6 +5,7 @@ import {
mkdir,
mkdtemp,
readFile,
realpath,
rm,
writeFile,
} from "node:fs/promises";
@@ -611,10 +612,9 @@ class OpenCodeHarnessSession implements HarnessSession {
// at this HTTP boundary.
providerID,
modelID,
// This exact OpenCode version passed the native-question conformance
// suite. question.asked is adapted into PRP v2 and its reply/reject API
// remains private to this driver.
tools: { question: true },
// Keep question enabled in the isolated config. OpenCode 1.18.32
// turns a prompt's deprecated `tools` map into replacement session
// permissions, so a sparse override here discards the session policy.
...(this.#sendFullContext
? { system: this.#systemInstructions }
: {}),
@@ -2032,6 +2032,15 @@ async function startRuntime(input: {
input.trace?.addSensitiveValues(sensitiveValues);
const instructionRoot =
input.options.runtimeContext?.instructions.bundle.rootPath;
// OpenCode canonicalizes tool paths (for example /var -> /private/var on
// macOS). Permit the assigned workspace under either spelling; operations
// within it still obey the selected allow/ask/deny permission mode.
const externalDirectories: Record<string, string> = { "*": "deny" };
for (const root of new Set([input.cwd, await realpath(input.cwd)])) {
externalDirectories[root] = "allow";
externalDirectories[`${root}/**`] = "allow";
}
if (instructionRoot) externalDirectories[`${instructionRoot}/**`] = "allow";
const [modelProvider, ...modelIdParts] = input.options.model.split("/");
const providerModelId = modelIdParts.join("/");
const config = {
@@ -2069,9 +2078,7 @@ async function startRuntime(input: {
question: "allow",
"paperclip_*": "allow",
"mcp__paperclip__*": "allow",
external_directory: instructionRoot
? { "*": "deny", [`${instructionRoot}/**`]: "allow" }
: "deny",
external_directory: externalDirectories,
},
mcp: {
paperclip: {
@@ -43,10 +43,19 @@ describe("tool app gallery URL matching", () => {
});
it("keeps tool gallery entries reachable through at least one pattern", () => {
for (const app of CONNECTABLE_APP_DEFINITIONS.filter((app) => app.methods.some((method) => method.purpose !== "channel"))) {
for (const app of CONNECTABLE_APP_DEFINITIONS.filter((app) => app.methods.some((method) => method.purpose !== "channel" && method.purpose !== "ai"))) {
const example = app.urlPatterns[0]?.replace("*", "example");
expect(example, `${app.slug} has a pattern`).toBeTruthy();
expect(getAppDefinitionForUrl(example!)?.slug).toBe(app.slug);
}
});
it("offers region and custom model connections without claiming arbitrary URLs", () => {
for (const slug of ["bedrock", "responses-api", "messages-api", "chat-completions-api", "local"]) {
const app = CONNECTABLE_APP_DEFINITIONS.find(app => app.slug === slug);
expect(app?.methods.every(method => method.purpose === "ai")).toBe(true);
expect(app?.urlPatterns).toEqual([]);
}
expect(getAppDefinitionForUrl("https://customer-gateway.example/v1")).toBeNull();
});
});
+13 -3
View File
@@ -1,5 +1,5 @@
diff --git a/dist/live-checkpoint-ClPCSdrW.js b/dist/live-checkpoint-ClPCSdrW.js
index 243c9d13bcba520923b63adfddad75cf2d94362d..e2a254d4e57fb2e246ccb742f2e9de5a8a461d20 100644
index 243c9d13bcba520923b63adfddad75cf2d94362d..b93d4b29edfce81c8eb3c769699712f490553506 100644
--- a/dist/live-checkpoint-ClPCSdrW.js
+++ b/dist/live-checkpoint-ClPCSdrW.js
@@ -1532,7 +1532,7 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([
@@ -96,7 +96,17 @@ index 243c9d13bcba520923b63adfddad75cf2d94362d..e2a254d4e57fb2e246ccb742f2e9de5a
};
}
logAgentLaunch(plan) {
@@ -6070,6 +6090,27 @@ async function withConnectedSession(options) {
@@ -4729,6 +4749,9 @@ var AcpClient = class {
return await this.filesystem.readTextFile(params);
} catch (error) {
this.recordPermissionError(params.sessionId, error);
+ // Preserve missing-file semantics across JSON-RPC. Gemini checks for
+ // ENOENT before creating a new file; a generic Internal error hides it.
+ if (error && typeof error === "object" && error.code === "ENOENT") throw RequestError.resourceNotFound(params.path);
throw error;
}
}
@@ -6070,6 +6093,27 @@ async function withConnectedSession(options) {
//#region src/runtime/engine/prompt-turn.ts
const SESSION_REPLY_IDLE_MS = 1e3;
const SESSION_REPLY_DRAIN_TIMEOUT_MS = 5e3;
@@ -124,7 +134,7 @@ index 243c9d13bcba520923b63adfddad75cf2d94362d..e2a254d4e57fb2e246ccb742f2e9de5a
async function runPromptTurn(params) {
try {
const promptPromise = params.client.prompt(params.sessionId, params.prompt);
@@ -6079,6 +6120,20 @@ async function runPromptTurn(params) {
@@ -6079,6 +6123,20 @@ async function runPromptTurn(params) {
idleMs: SESSION_REPLY_IDLE_MS,
timeoutMs: SESSION_REPLY_DRAIN_TIMEOUT_MS
}).catch(() => {});
@@ -0,0 +1,43 @@
#!/usr/bin/env node
// Credential-free ACP filesystem requests, driven through the real client.
import { createInterface } from "node:readline";
const pending = new Map();
let nextId = 100;
const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`);
const request = (method, params) => new Promise((resolve) => {
const id = nextId++;
pending.set(id, resolve);
send({ jsonrpc: "2.0", id, method, params });
});
createInterface({ input: process.stdin }).on("line", async (line) => {
const message = JSON.parse(line);
if (pending.has(message.id)) {
pending.get(message.id)(message);
pending.delete(message.id);
return;
}
let result;
if (message.method === "initialize") {
result = {
protocolVersion: 1,
agentCapabilities: { loadSession: false, sessionCapabilities: { close: {} } },
agentInfo: { name: "paperclip-acp-filesystem-fixture", version: "1.0.0" },
};
} else if (message.method === "session/new") {
result = { sessionId: "filesystem-fixture" };
} else if (message.method === "session/prompt") {
const { operations } = JSON.parse(message.params.prompt.find((entry) => entry.type === "text").text);
for (const { method, ...params } of operations) {
const response = await request(method, { sessionId: "filesystem-fixture", ...params });
process.stderr.write(`FS_RESULT ${JSON.stringify(response)}\n`);
}
result = { stopReason: "end_turn" };
} else if (message.method === "session/cancel") {
return;
} else {
result = {};
}
if (message.id !== undefined) send({ jsonrpc: "2.0", id: message.id, result });
});
@@ -117,7 +117,9 @@ describe("agent-created hires use managed AI connections", () => {
await db.update(agents).set({ runtimeConfig: { aiConnection: p.binding } }).where(eq(agents.id, f.agentId));
const response = await request(f.app).post(`/api/companies/${f.companyId}/agent-hires`).send({ name: "Incompatible teammate", role: "engineer", adapterType: "claude_local" });
expect(response.status, JSON.stringify(response.body)).toBe(422);
expect(response.body.error).toContain("no compatible harness");
// Fixed bindings contain identity only; compatibility is decided from
// authoritative connection metadata when selecting each pool member.
expect(response.body.details?.code).toBe("ai_connection_pool_no_eligible_member");
expect(await db.select().from(agents).where(eq(agents.companyId, f.companyId))).toHaveLength(1);
});
+26 -2
View File
@@ -4,7 +4,7 @@ import { issueRecoveryActionService } from "../services/issue-recovery-actions.j
import * as localCredentials from "../services/local-ai-credentials.js";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { mkdtemp, realpath, rm, access, readFile, writeFile, stat } from "node:fs/promises";
import { mkdtemp, realpath, rm, access, readFile, writeFile, stat, readlink, readdir } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq, sql } from "drizzle-orm";
@@ -50,6 +50,30 @@ beforeAll(async () => {
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); if (home) await rm(home, { recursive: true, force: true }); });
describe("managed AI connections", () => {
it("retains only Grok sessions across private runtime homes and isolates agents and accounts", async () => {
const save = (name: string) => service.save(companyId, "alice", { provider: "xai", method: "api_key", ownership: "personal", name, agentIds: [], allAgents: true }, `fixture-${name}`);
const account = await save("Grok history");
const other = await save("Other Grok history");
const runInput = { ...input, adapterType: "grok_local", responsibleUserId: "alice", config: {}, binding: { provider: "xai", method: "api_key", mode: "connection", connectionId: account.connectionId, grantId: account.grantId } as const };
const first = await prepareManagedAiRuntime(db, runInput);
const sessions = await readlink(path.join(String(first.config.env.GROK_HOME), "sessions"));
await writeFile(path.join(sessions, "history.json"), '{"fixture":"history"}', { mode: 0o600 });
await writeFile(path.join(String(first.config.env.GROK_HOME), "auth.json"), "disposable-auth", { mode: 0o600 });
await first.cleanup();
await expect(access(first.home!)).rejects.toThrow();
expect(await readdir(sessions)).toEqual(["history.json"]);
expect((await stat(sessions)).mode & 0o777).toBe(0o700);
const resumed = await prepareManagedAiRuntime(db, runInput);
const otherAgent = await prepareManagedAiRuntime(db, { ...runInput, agentId: randomUUID() });
const otherAccount = await prepareManagedAiRuntime(db, { ...runInput, binding: { ...runInput.binding, connectionId: other.connectionId, grantId: other.grantId } });
try {
expect(resumed.home).not.toBe(first.home);
expect(await readlink(path.join(String(resumed.config.env.GROK_HOME), "sessions"))).toBe(sessions);
expect(await readlink(path.join(String(otherAgent.config.env.GROK_HOME), "sessions"))).not.toBe(sessions);
expect(await readlink(path.join(String(otherAccount.config.env.GROK_HOME), "sessions"))).not.toBe(sessions);
await expect(access(path.join(String(resumed.config.env.GROK_HOME), "auth.json"))).rejects.toThrow();
} finally { await Promise.all([resumed.cleanup(), otherAgent.cleanup(), otherAccount.cleanup()]); }
});
it("authenticates local Gemini probes and runs with the saved key in an isolated home", async () => {
const root = await mkdtemp(path.join(home, "gemini-auth-"));
const command = path.join(root, "gemini");
@@ -103,7 +127,7 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello"
provider: "google", method: "api_key", ownership: "personal", name: "Google migration",
apiKey: "fixture", allAgents: true, agentIds: [],
}, "google-migration-key");
const migration = await readFile(new URL("../../../packages/db/src/migrations/0295_absurd_starhawk.sql", import.meta.url), "utf8");
const migration = await readFile(new URL("../../../packages/db/src/migrations/0300_absurd_starhawk.sql", import.meta.url), "utf8");
for (let attempt = 0; attempt < 2; attempt += 1) {
await db.transaction(async (tx) => {
for (const statement of migration.split("--> statement-breakpoint")) {
@@ -99,9 +99,9 @@ describe(`durable file delivery (${liveDaytona ? "Daytona" : "local processes"})
enableSandboxDuplexBridge: duplex,
});
expect(bridge?.env.PAPERCLIP_API_BRIDGE_MODE).toBe(duplex ? "http2_v1" : "queue_v1");
const env = { ...bridge!.env, PAPERCLIP_RUN_ID: fixture.runId,
const env = { ...bridge!.env, TMPDIR: path.join(remote.workspace, ".unavailable-controller-temp"), PAPERCLIP_RUN_ID: fixture.runId,
PAPERCLIP_COMPANY_ID: fixture.companyId, PAPERCLIP_TASK_ID: fixture.issueId,
PAPERCLIP_HELPER_STATE_DIR: path.join(remote.workspace, ".helper-state") };
PAPERCLIP_WORKSPACE_CWD: remote.workspace };
const headers = { authorization: `Bearer ${bridge!.env.PAPERCLIP_API_KEY}` };
const receipts: Array<{ attachment: { id: string; contentPath: string; downloadPath: string } }> = [];
try {
@@ -5172,9 +5172,18 @@ describeEmbeddedPostgres("tool access service", () => {
"github",
"github-code-review-bot",
"youcom",
"openrouter",
"bedrock",
"responses-api",
"messages-api",
"chat-completions-api",
"local",
]),
);
expect(res.body.apps).toHaveLength(59);
expect(res.body.apps).toHaveLength(65);
for (const slug of ["openrouter", "bedrock", "responses-api", "messages-api", "chat-completions-api", "local"]) {
expect(res.body.apps.find((app: { slug: string }) => app.slug === slug).tags).toContain("model-provider");
}
expect(
res.body.apps.find((app: { slug: string }) => app.slug === "gmail")
.ownershipAvailability,
+13 -1
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto";
import { HttpError, unprocessable } from "../errors.js";
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
import { mkdtemp, mkdir, symlink, writeFile, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { and, eq } from "drizzle-orm";
@@ -17,6 +17,7 @@ import { decideCodexAuthMerge } from "@paperclipai/adapter-codex-local/server";
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import { runAdapterExecutionTargetProcess } from "@paperclipai/adapter-utils/execution-target";
import { decideGrokAuthMerge } from "@paperclipai/adapter-grok-local/server";
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
export function isAiConnectionBusy(error: unknown): error is HttpError {
return error instanceof HttpError && error.status === 422 &&
@@ -339,6 +340,17 @@ export async function prepareManagedAiRuntime(
.slice(0, 16);
const identity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${generation}`;
const sessionIdentity = `${selection.grant.id}:${input.responsibleUserId ?? "shared"}:${credentialRef.secretId}:${freshness.epoch}`;
if (input.adapterType === "grok_local") {
// Retain only transcripts across disposable credential homes. Scope them
// to the company, agent and credential identity, including rotations.
// Removing the runtime home removes the link, never its private target.
const scope = createHash("sha256")
.update(JSON.stringify([input.companyId, input.agentId, sessionIdentity]))
.digest("hex");
const sessions = path.join(resolvePaperclipInstanceRootForAdapter(), "companies", input.companyId, "grok-sessions", scope);
await mkdir(sessions, { recursive: true, mode: 0o700 });
await symlink(sessions, path.join(providerHome, "sessions"), "dir");
}
return {
sessionIdentity,
config: {
+5
View File
@@ -156,3 +156,8 @@ confirms storage and response binding in Paperclip; it does not confirm delivery
to an external chat provider. If registration fails, use the returned error to
resolve the failure or explain the limitation; do not describe a workspace path
as an uploaded file.
The shell helper keeps temporary responses and retry receipts in the assigned
workspace's private `.paperclip/artifact-helper` directory, so CLI sandboxes can
write them even when the controller's `TMPDIR` is inaccessible.
`PAPERCLIP_HELPER_STATE_DIR` overrides that directory when explicitly configured.
@@ -113,6 +113,17 @@ sha256_text() {
fi
}
helper_state_directory() {
printf '%s' "${PAPERCLIP_HELPER_STATE_DIR:-${PAPERCLIP_WORKSPACE_CWD:-$PWD}/.paperclip/artifact-helper}"
}
helper_response_file() {
local directory
directory="$(helper_state_directory)"
# TMPDIR can name a controller-owned directory outside a CLI sandbox.
(umask 077; mkdir -p "$directory" && mktemp "$directory/response.XXXXXX")
}
request_json() {
local method="$1"
local url="$2"
@@ -120,7 +131,7 @@ request_json() {
local response_file
local status_code
response_file="$(mktemp)"
response_file="$(helper_response_file)"
if [[ -n "$body" ]]; then
status_code="$(
curl -sS -X "$method" -w '%{http_code}' -o "$response_file" \
@@ -163,7 +174,7 @@ upload_file() {
escaped_path="${path//\\/\\\\}"
escaped_path="${escaped_path//\"/\\\"}"
response_file="$(mktemp)"
response_file="$(helper_response_file)"
status_code="$(
curl -sS -X POST -w '%{http_code}' -o "$response_file" \
"$url" \
@@ -223,7 +234,7 @@ acquire_operation_lock() {
local attempts=0
umask 077
operation_state_root="${PAPERCLIP_HELPER_STATE_DIR:-${TMPDIR:-/tmp}/paperclip-upload-artifact}"
operation_state_root="$(helper_state_directory)"
mkdir -p "$operation_state_root"
operation_lock_path="$operation_state_root/$operation_key.lock"
operation_lock_owner="$$|$(process_start_identity "$$" || true)"
+12 -15
View File
@@ -135,25 +135,22 @@ test("explicit OpenAI API method survives continuing and reloading", async ({ pa
});
for (const [provider, label] of [["anthropic", "Claude"], ["openai", "OpenAI"]]) {
test(`Connections reuses the agent provider step for ${label}`, async ({ page }, testInfo) => {
test(`Connections reuses the local browser sign-in step for ${label} (simulated auth)`, async ({ page }, testInfo) => {
// UI contract only: real subscription qualification is provider-connections.
const base = `/api/companies/${companyId}/ai-connections/local`;
const sessionId = "22222222-2222-4222-8222-222222222222";
await page.route(`**${base}/attempts`, route => route.fulfill({ json: { sessionId, expiresAt: new Date(Date.now() + 300000).toISOString() } }));
await page.route(`**${base}/check`, route => route.fulfill({ json: { status: "sign_in_required", authorizationUrl: "https://provider.example/authorize", ...(provider === "openai" ? { code: "ABCD-EFGH" } : {}) } }));
await page.route(`**${base}/attempts/${sessionId}`, route => route.fulfill({ json: { ok: true } }));
await page.goto(`/${prefix}/apps/connect?source=${provider}&method=ai-subscription`);
await page.getByRole("button", { name: /^(Save and continue|Continue)$/ }).click();
await expect(page.getByRole("radiogroup", { name: "Connect your model provider" })).toBeVisible();
await expect(page.getByRole("button", { name: "Connect for tool access instead" })).toHaveCount(0);
await page.getByRole("radio", { name: new RegExp(label) }).click();
if (provider === "anthropic") {
await expect(page.getByText(/Connect uses your local/)).toBeVisible();
await expect(page.getByText("claude auth login", { exact: true })).toBeVisible();
} else {
await expect(page.getByText(/Your existing terminal login stays separate/)).toBeVisible();
const command = page.getByText(/^CODEX_HOME=.* codex login$/);
await expect(command).toBeVisible();
const preparedCommand = await command.textContent();
await page.reload();
await page.getByRole("radio", { name: new RegExp(label) }).click();
await expect(command).toHaveText(preparedCommand!);
}
await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeEnabled();
await expect(page.getByRole("link", { name: `Sign in to ${label}`, exact: true })).toHaveAttribute("href", "https://provider.example/authorize");
await expect(page.getByText(/CODEX_HOME=|claude auth login|existing terminal login/)).toHaveCount(0);
if (provider === "anthropic") await expect(page.getByRole("button", { name: "Submit code", exact: true })).toBeDisabled();
else await expect(page.getByText("ABCD-EFGH", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Connect", exact: true })).toBeDisabled();
// Let the shared tile-collapse and card-enter animations settle for visual review.
await page.waitForTimeout(1000);
await page.screenshot({ path: testInfo.outputPath(`${provider}-shared-provider-step.png`), fullPage: true });
@@ -721,7 +721,7 @@ function Setup({
center
/>
</div>
<AgentProviderConnection
{ready ? <AgentProviderConnection
key={environmentId ?? "local"}
companyId={companyId}
adapterType={connectionAdapter}
@@ -769,7 +769,7 @@ function Setup({
resetTest();
setScreen("runtime");
}}
/>
/> : <p role="status" className="text-sm text-muted-foreground">Loading connection settings…</p>}
</OnboardingCard>
) : screen === "saved" && created ? (
<div className="space-y-6">
+32 -18
View File
@@ -157,6 +157,7 @@ const pass = {
};
beforeEach(() => {
vi.clearAllMocks();
managedApi.list.mockResolvedValue({ currentUserId: "user-1", canManageConnections: true, connections: [] });
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
@@ -199,7 +200,20 @@ afterEach(async () => {
container.remove();
});
describe("New agent setup", () => {
it.each([false, true])("selects the Grok sandbox before connecting (managed-only=%s)", async (managedOnly) => {
it("waits for the resolved environment before allowing a credential-mode selection", async () => {
let resolveEnvironments!: (value: object[]) => void;
envApi.list.mockImplementationOnce(() => new Promise<object[]>(resolve => { resolveEnvironments = resolve; }));
await render("codex_local");
expect(container.textContent).toContain("Loading connection settings");
expect(container.querySelector('[role="radiogroup"][aria-label="Connection type"]')).toBeNull();
await act(async () => resolveEnvironments([{ id: "local-1", name: "Local", driver: "local", config: {} }]));
await settle();
await click("OpenAIAPI key");
expect(container.querySelector('[aria-label="API key"]')).not.toBeNull();
await settle();
expect(container.querySelector('[aria-label="API key"]')).not.toBeNull();
});
it.each([false, true])("selects the Grok environment in Configure (managed-only=%s)", async (managedOnly) => {
settings.getExperimental.mockResolvedValue({ enableNativeRunner: true, enableManagedSandboxOnly: managedOnly });
envApi.list.mockResolvedValue([
{ id: "local-1", name: "Local", status: "active", driver: "local", config: {} },
@@ -210,6 +224,9 @@ describe("New agent setup", () => {
secret: { companyId: "company-1", status: "active" },
}]);
await render("paperclip_runner", "grok");
expect(container.querySelector('select[aria-label="Environment"]')).toBeNull();
await click("GrokAPI key");
await click("Use saved API key");
const select = container.querySelector('select[aria-label="Environment"]') as HTMLSelectElement;
expect(select.disabled).toBe(false);
expect([...select.options].some((option) => option.value === "local-1")).toBe(!managedOnly);
@@ -218,8 +235,10 @@ describe("New agent setup", () => {
select.dispatchEvent(new Event("change", { bubbles: true }));
});
await settle();
await click("GrokAPI");
await click("Use saved API key");
if (container.querySelector('[role="radiogroup"][aria-label="Connection type"]')) {
await click("GrokAPI key");
await click("Use saved API key");
}
expect(api.testEnvironment).toHaveBeenCalledWith("company-1", "paperclip_runner", expect.objectContaining({ environmentId: "grok-sandbox" }));
await click("Finish setup");
expect(api.hire.mock.calls[0][1].defaultEnvironmentId).toBe("grok-sandbox");
@@ -265,8 +284,7 @@ describe("New agent setup", () => {
await click("GrokSubscription");
await click("Complete subscription login");
} else {
await click("Use API key insteadUse subscription insteadUse API key instead");
await click("GrokAPI");
await click("GrokAPI key");
await fill("API key", "example-test-secret");
await click("Connect");
expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
@@ -464,8 +482,7 @@ describe("New agent setup", () => {
["paperclip_runner", "codex", "OpenAI", "OPENAI_API_KEY"],
])("stores %s %s as a reusable connection before hiring", async (adapter, runner, provider, key) => {
await render(adapter, runner);
await click("Use API key insteadUse subscription insteadUse API key instead");
await click(provider + "API");
await click(provider + "API key");
await fill("API key", "connection-key");
await click("Connect");
const binding = { provider: key === "ANTHROPIC_API_KEY" ? "anthropic" : "openai", method: "api_key", mode: "responsible_user" };
@@ -491,7 +508,7 @@ describe("New agent setup", () => {
secret: { companyId: "company-1", status: "active" },
}]);
await render(adapter, runner);
await click(provider + "API");
await click(provider + "API key");
expect((container.querySelector("select[aria-label='Saved API key']") as HTMLSelectElement).value).toBe("user:existing-key");
await click("Use saved API key");
const binding = { type: "user_secret_ref", key, version: "latest" };
@@ -505,17 +522,16 @@ describe("New agent setup", () => {
});
it.each(["codex_local", "paperclip_runner"])("keeps a saved connection chosen in Connect when hiring %s", async (adapter) => {
await render(adapter, "codex");
await act(async () => cache.setQueryData(["ai-connections", "company-1", undefined], {
managedApi.list.mockResolvedValue({
currentUserId: "user-1",
connections: [{
id: "00000000-0000-4000-8000-000000000021", grantId: "00000000-0000-4000-8000-000000000022", companyId: "company-1",
provider: "openrouter", method: "api_key", name: "Company OpenRouter",
ownership: "shared", isDefault: false, status: "connected",
routing: { kind: "openrouter", protocol: "responses", auth: "bearer" },
routing: { kind: "openrouter", protocol: "responses", auth: "bearer", models: [] },
}],
}));
const disclosure = [...container.querySelectorAll("summary")].find(node => node.textContent === "Use another connection")!;
await act(async () => disclosure.click());
} as any);
await click("AdvancedCustom Gateway");
const picker = container.querySelector('[role="combobox"][aria-label="Connection"]')!;
await act(async () => picker.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })));
await settle();
@@ -523,6 +539,7 @@ describe("New agent setup", () => {
expect(option).toBeTruthy();
await act(async () => option.click());
await settle();
await click("Use connection");
expect(container.textContent).toContain("Configure your agent");
expect(container.querySelector('[role="combobox"][aria-label="Connection"]')).toBeNull();
await click("Run test");
@@ -576,9 +593,6 @@ describe("New agent setup", () => {
const openrouter = [...dialog.querySelectorAll("button")].find(node => node.textContent?.includes("OpenRouter"))!;
await act(async () => openrouter.click());
await settle();
const accessContinue = [...dialog.querySelectorAll("button")].find(node => node.textContent?.trim() === "Continue")!;
await act(async () => accessContinue.click());
await settle();
const input = dialog.querySelector('[aria-label="API key"]') as HTMLInputElement;
expect(input).toBeTruthy();
await act(async () => {
@@ -596,7 +610,7 @@ describe("New agent setup", () => {
expect(managedApi.create).toHaveBeenCalledWith("company-1", expect.objectContaining({
provider: "openrouter", method: "api_key", apiKey: "example-test-secret",
}));
const binding = { provider: "openrouter", method: "api_key", mode: "delegated", connectionId: "managed-connection", grantId: "managed-grant" };
const binding = { provider: "openrouter", method: "api_key", mode: "shared", connectionId: "managed-connection", grantId: "managed-grant" };
await click("Run test");
expect(api.testEnvironment.mock.calls[0][2]).toEqual(expect.objectContaining({
aiConnection: binding, testCredentials: {},
@@ -693,7 +707,7 @@ describe("New agent setup", () => {
{ type: "paperclip_runner", loaded: true, disabled: true },
];
await render("paperclip_runner");
await connect("OpenAI");
expect(container.textContent).toContain("This adapter is unavailable");
expect(api.testEnvironment).not.toHaveBeenCalled();
expect(api.hire).not.toHaveBeenCalled();
});