Merge upstream/master (53 commits) into local
Build: Production / build (push) Failing after 13m4s

Resolved conflicts:
- ui CompanySettingsSidebar.tsx: keep both Secrets (local) and Cloud upstream (master) nav items
- ui CompanySettingsNav.tsx + test: take master's cloud-upstream/members (drops deprecated `access` tab now consolidated into `members`)
- server plugin-worker-manager.ts: take master's 15min RPC timeout cap
- pnpm-lock.yaml: regenerated via `pnpm install` against merged package.json files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 08:01:31 -04:00
536 changed files with 60296 additions and 2542 deletions
@@ -1,20 +1,57 @@
export const REDACTED_COMMAND_TEXT_VALUE = "***REDACTED***";
const COMMAND_CLI_SECRET_OPTION_RE =
/(\B-{1,2}(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)(?:\s+|=)(["']?))[^\s"'`]+(\2)/gi;
const COMMAND_ENV_SECRET_ASSIGNMENT_RE =
/(\b[A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD|PASSWD|AUTHORIZATION|JWT)[A-Za-z0-9_]*\s*=\s*)[^\s"'`]+/gi;
const SECRET_NAME_PATTERN =
String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|(?:access[-_]?|auth[-_]?)?token|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*`;
const COMMAND_CLI_SECRET_OPTION_RE = new RegExp(
String.raw`(\B-{1,2}${SECRET_NAME_PATTERN}(?:\s+|=)(["']?))[^\s"'` + "`" + String.raw`]+(\2)`,
"gi",
);
const COMMAND_ENV_SECRET_ASSIGNMENT_RE = new RegExp(
String.raw`(\b${SECRET_NAME_PATTERN}\s*=\s*)(?:(["'])([^"'` + "`" + String.raw`\r\n]*)\2|([^\s"'` + "`" + String.raw`]+))`,
"gi",
);
const COMMAND_AUTHORIZATION_BEARER_RE = /(\bAuthorization\s*:\s*Bearer\s+)[^\s"'`]+/gi;
const COMMAND_OPENAI_KEY_RE = /\bsk-[A-Za-z0-9_-]{12,}\b/g;
const COMMAND_GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g;
const COMMAND_JWT_RE =
/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?:\.[A-Za-z0-9_-]{8,})?\b/g;
const COMMAND_SECRET_HINTS = [
"api",
"key",
"token",
"auth",
"bearer",
"secret",
"pass",
"credential",
"jwt",
"private",
"cookie",
"connectionstring",
"sk-",
"ghp_",
"gho_",
"ghu_",
"ghs_",
"ghr_",
] as const;
function maybeContainsSecretText(command: string) {
const lower = command.toLowerCase();
return COMMAND_SECRET_HINTS.some((hint) => lower.includes(hint)) || command.includes(".");
}
export function redactCommandText(command: string, redactedValue = REDACTED_COMMAND_TEXT_VALUE): string {
if (!maybeContainsSecretText(command)) return command;
return command
.replace(COMMAND_AUTHORIZATION_BEARER_RE, `$1${redactedValue}`)
.replace(COMMAND_CLI_SECRET_OPTION_RE, `$1${redactedValue}$3`)
.replace(COMMAND_ENV_SECRET_ASSIGNMENT_RE, `$1${redactedValue}`)
.replace(
COMMAND_ENV_SECRET_ASSIGNMENT_RE,
(_match, prefix: string, quote: string | undefined) =>
quote ? `${prefix}${quote}${redactedValue}${quote}` : `${prefix}${redactedValue}`,
)
.replace(COMMAND_OPENAI_KEY_RE, redactedValue)
.replace(COMMAND_GITHUB_TOKEN_RE, redactedValue)
.replace(COMMAND_JWT_RE, redactedValue);
@@ -138,6 +138,13 @@ async function createTarballFromDirectory(input: {
const excludeArgs = ["._*", ...(input.exclude ?? [])].flatMap((entry) => ["--exclude", entry]);
await execTar([
"-c",
// Prevent macOS bsdtar from embedding LIBARCHIVE.xattr.* PAX extended
// headers for extended attributes (e.g. com.apple.provenance). GNU tar on
// Linux does not recognise these proprietary headers and fails extraction
// with "This does not look like a tar archive". COPYFILE_DISABLE=1 (set in
// execTar) already suppresses AppleDouble ._* sidecar files; --no-xattrs
// additionally suppresses the inline PAX xattr entries.
"--no-xattrs",
...(input.followSymlinks ? ["-h"] : []),
"-f",
input.archivePath,
@@ -53,13 +53,14 @@ describe("buildInvocationEnvForLogs", () => {
const loggedEnv = buildInvocationEnvForLogs(
{ SAFE_VALUE: "visible" },
{
resolvedCommand: "env OPENAI_API_KEY=sk-live-example custom-acp --token ghp_example_secret",
resolvedCommand:
"env OPENAI_API_KEY=sk-live-example PAPERCLIP_API_KEY='paperclip-quoted-secret' custom-acp --paperclip-api-key=paperclip-flag-secret --token ghp_example_secret",
},
);
expect(loggedEnv.SAFE_VALUE).toBe("visible");
expect(loggedEnv.PAPERCLIP_RESOLVED_COMMAND).toBe(
"env OPENAI_API_KEY=***REDACTED*** custom-acp --token ***REDACTED***",
"env OPENAI_API_KEY=***REDACTED*** PAPERCLIP_API_KEY='***REDACTED***' custom-acp --paperclip-api-key=***REDACTED*** --token ***REDACTED***",
);
});
});
@@ -462,6 +463,50 @@ describe("renderPaperclipWakePrompt", () => {
expect(prompt).toContain("named unblock owner/action");
});
it("preserves Chinese, Japanese, and Hindi issue and comment text in scoped wake prompts", () => {
const title = "验证中文任务";
const commentBody = [
"请用中文回复。",
"日本語: 次の手順を書いてください。",
"हिन्दी: कृपया स्थिति बताएं।",
].join("\n");
const payload = {
reason: "issue_commented",
issue: {
id: "issue-1",
identifier: "PAP-9452",
title,
status: "in_progress",
workMode: "standard",
},
commentIds: ["comment-1"],
latestCommentId: "comment-1",
commentWindow: { requestedCount: 1, includedCount: 1, missingCount: 0 },
comments: [
{
id: "comment-1",
body: commentBody,
author: { type: "user", id: "board-user-1" },
createdAt: "2026-05-15T16:30:00.000Z",
},
],
fallbackFetchNeeded: false,
};
const serialized = stringifyPaperclipWakePayload(payload);
expect(serialized).toContain(title);
expect(serialized).toContain("日本語");
expect(serialized).toContain("हिन्दी");
expect(JSON.parse(serialized ?? "{}")).toMatchObject({
issue: { title },
comments: [{ body: commentBody }],
});
const prompt = renderPaperclipWakePrompt(payload);
expect(prompt).toContain(`- issue: PAP-9452 ${title}`);
expect(prompt).toContain(commentBody);
});
it("renders planning-mode directives for assignment and comment wakes", () => {
const assignmentPrompt = renderPaperclipWakePrompt({
reason: "issue_assigned",
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { AcpRuntimeOptions } from "acpx/runtime";
import { createAcpxLocalExecutor } from "./execute.js";
const tempRoots: string[] = [];
@@ -376,6 +377,126 @@ describe("acpx_local runtime skill isolation", () => {
expect(envFiles.filter((contents) => contents.includes("PAPERCLIP_API_KEY='second-key'"))).toHaveLength(1);
});
it("enriches acpx.error diagnostics and child stderr when ensureSession rejects", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const runStderrDir = path.join(stateDir, "run-stderr");
await fs.mkdir(runStderrDir, { recursive: true });
const stderrTail = "claude-agent-acp: SDK init failed (auth missing)";
await fs.writeFile(path.join(runStderrDir, "run-1.log"), `${stderrTail}\n`, "utf8");
class FakeAcpRuntimeError extends Error {
readonly code = "ACP_SESSION_INIT_FAILED";
readonly cause: Error;
readonly retryable = false;
constructor(message: string, cause: Error) {
super(message);
this.name = "AcpRuntimeError";
this.cause = cause;
}
}
const logs: Array<{ stream: string; text: string }> = [];
const execute = createAcpxLocalExecutor({
createRuntime: () => ({
ensureSession: async () => {
throw new FakeAcpRuntimeError(
"session/new failed: backend rejected initialize",
new Error("upstream timeout"),
);
},
startTurn: () => ({
events: (async function* () {})(),
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
cancel: async () => {},
}),
close: async () => {},
}) as never,
});
const result = await execute({
runId: "run-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
},
context: {},
onLog: async (stream: "stdout" | "stderr", text: string) => {
logs.push({ stream, text });
},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(1);
expect(result.errorCode).toBe("acpx_session_init_failed");
const meta = result.errorMeta ?? {};
expect(meta.errorName).toBe("AcpRuntimeError");
expect(meta.acpCode).toBe("ACP_SESSION_INIT_FAILED");
expect(meta.causeMessage).toBe("upstream timeout");
expect(meta.retryable).toBe(false);
expect(typeof meta.stackPreview).toBe("string");
expect(meta.phase).toBe("ensure_session");
const errorLogLine = logs.find((entry) => entry.stream === "stdout" && entry.text.includes("\"type\":\"acpx.error\""));
expect(errorLogLine).toBeTruthy();
const errorPayload = JSON.parse(errorLogLine!.text.trim());
expect(errorPayload.phase).toBe("ensure_session");
expect(errorPayload.errorName).toBe("AcpRuntimeError");
expect(errorPayload.acpCode).toBe("ACP_SESSION_INIT_FAILED");
expect(errorPayload.causeMessage).toBe("upstream timeout");
expect(errorPayload.childStderrTail).toContain("SDK init failed");
const stderrLog = logs.find((entry) => entry.stream === "stderr" && entry.text.includes("ACPX child stderr tail"));
expect(stderrLog).toBeTruthy();
expect(stderrLog!.text).toContain(stderrTail);
});
it("writes wrapper that redirects child stderr to a per-run log file", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const runtimeOptions: AcpRuntimeOptions[] = [];
const execute = createAcpxLocalExecutor({
createRuntime: (options) => {
runtimeOptions.push(options as unknown as AcpRuntimeOptions);
return buildRuntime() as never;
},
});
const result = await execute({
runId: "run-stderr-1",
agent: { id: "agent-1", companyId: "company-1" },
runtime: {},
config: {
agent: "custom",
agentCommand: "node ./fake-acp.js",
stateDir,
},
context: {},
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(0);
const verboseFlags = runtimeOptions.map((options) => (options as { verbose?: boolean }).verbose);
// verbose is scoped to the claude agent (PAPA-388); the custom agent here
// should not opt in to ACPX runtime verbose session-event logs.
expect(verboseFlags.every((flag) => flag === false)).toBe(true);
const wrappers = await fs.readdir(path.join(stateDir, "wrappers"));
const wrapperFile = wrappers.find((name) => name.endsWith(".sh"));
expect(wrapperFile).toBeTruthy();
const wrapper = await fs.readFile(path.join(stateDir, "wrappers", wrapperFile!), "utf8");
expect(wrapper).toContain("stderr_dir=");
expect(wrapper).toContain("run-stderr");
expect(wrapper).toContain("PAPERCLIP_RUN_ID");
expect(wrapper).toContain("tee -a");
expect(wrapper).toContain("exec node ./fake-acp.js");
});
it("passes Paperclip env through the ACP agent wrapper instead of process.env", async () => {
let observedApiKeyDuringStream: string | undefined;
const execute = createAcpxLocalExecutor({
@@ -422,4 +543,160 @@ describe("acpx_local runtime skill isolation", () => {
else process.env.PAPERCLIP_API_KEY = previousApiKey;
}
});
it("writes a Paperclip-managed .claude/settings.local.json for the claude agent so it can reach the Paperclip API", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const cwd = path.join(root, "worktree");
await fs.mkdir(cwd, { recursive: true });
const { meta } = await runExecutor(
{ agent: "claude", stateDir, cwd },
{ context: { paperclipWorkspace: { cwd, agentHome: path.join(root, "agent-home") } } },
);
const settingsPath = path.join(cwd, ".claude", "settings.local.json");
const written = JSON.parse(await fs.readFile(settingsPath, "utf8")) as {
permissions?: {
allow?: unknown;
additionalDirectories?: unknown;
defaultMode?: unknown;
};
};
expect(written.permissions?.defaultMode).toBe("default");
const allow = written.permissions?.allow;
expect(Array.isArray(allow)).toBe(true);
expect(allow).toContain("Bash(curl:*)");
expect(allow).toContain(`Bash(${cwd}/scripts/paperclip-issue-update.sh:*)`);
const additionalDirectories = written.permissions?.additionalDirectories as string[] | undefined;
expect(Array.isArray(additionalDirectories)).toBe(true);
expect(additionalDirectories).toContain(stateDir);
expect(additionalDirectories).toContain(path.join(root, "agent-home"));
const note = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) =>
entry.includes("Paperclip-managed Claude settings"),
);
expect(note).toBeTruthy();
});
it("merges Paperclip allowlist into an existing .claude/settings.local.json without losing user entries", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const cwd = path.join(root, "worktree");
await fs.mkdir(path.join(cwd, ".claude"), { recursive: true });
await fs.writeFile(
path.join(cwd, ".claude", "settings.local.json"),
JSON.stringify(
{
statusLine: { type: "command", command: "preserve-me" },
permissions: {
allow: ["Bash(npm test:*)"],
additionalDirectories: ["/Users/example/custom"],
defaultMode: "acceptEdits",
},
},
null,
2,
),
"utf8",
);
await runExecutor(
{ agent: "claude", stateDir, cwd },
{ context: { paperclipWorkspace: { cwd } } },
);
const written = JSON.parse(
await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"),
) as {
statusLine?: unknown;
permissions?: {
allow?: string[];
additionalDirectories?: string[];
defaultMode?: string;
};
};
expect(written.statusLine).toEqual({ type: "command", command: "preserve-me" });
expect(written.permissions?.defaultMode).toBe("acceptEdits");
expect(written.permissions?.allow).toContain("Bash(npm test:*)");
expect(written.permissions?.allow).toContain("Bash(curl:*)");
expect(written.permissions?.additionalDirectories).toContain("/Users/example/custom");
expect(written.permissions?.additionalDirectories).toContain(stateDir);
});
it("overrides a user-supplied dontAsk defaultMode so ACPX can route Bash through canUseTool", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const cwd = path.join(root, "worktree");
await fs.mkdir(path.join(cwd, ".claude"), { recursive: true });
await fs.writeFile(
path.join(cwd, ".claude", "settings.local.json"),
JSON.stringify({ permissions: { defaultMode: "dontAsk" } }, null, 2),
"utf8",
);
const { meta } = await runExecutor(
{ agent: "claude", stateDir, cwd },
{ context: { paperclipWorkspace: { cwd } } },
);
const written = JSON.parse(
await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"),
) as { permissions?: { defaultMode?: string } };
expect(written.permissions?.defaultMode).toBe("default");
const overrideNote = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) =>
entry.includes("overrode user dontAsk"),
);
expect(overrideNote).toBeTruthy();
});
it("opts the claude agent into ACPX runtime verbose logs but leaves codex/custom agents quiet", async () => {
const root = await makeTempRoot();
const cwd = path.join(root, "worktree");
await fs.mkdir(cwd, { recursive: true });
const verboseByAgent: Record<string, boolean | undefined> = {};
for (const agent of ["claude", "codex", "custom"] as const) {
const runtimeOptions: AcpRuntimeOptions[] = [];
const execute = createAcpxLocalExecutor({
createRuntime: (options) => {
runtimeOptions.push(options as AcpRuntimeOptions);
return buildRuntime() as never;
},
});
const result = await execute({
runId: `run-${agent}`,
agent: { id: `agent-${agent}`, companyId: "company-1" },
runtime: {},
config:
agent === "custom"
? { agent, agentCommand: "node ./fake-acp.js", stateDir: path.join(root, `state-${agent}`), cwd }
: { agent, stateDir: path.join(root, `state-${agent}`), cwd },
context: { paperclipWorkspace: { cwd } },
onLog: async () => {},
onMeta: async () => {},
} as never);
expect(result.exitCode).toBe(0);
verboseByAgent[agent] = (runtimeOptions[0] as { verbose?: boolean } | undefined)?.verbose;
}
expect(verboseByAgent.claude).toBe(true);
expect(verboseByAgent.codex).toBe(false);
expect(verboseByAgent.custom).toBe(false);
});
it("does not touch .claude/settings.local.json for the codex agent", async () => {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
const cwd = path.join(root, "worktree");
await fs.mkdir(cwd, { recursive: true });
await runExecutor(
{ agent: "codex", stateDir, cwd },
{ context: { paperclipWorkspace: { cwd } } },
);
expect(await pathExists(path.join(cwd, ".claude", "settings.local.json"))).toBe(false);
});
});
@@ -94,6 +94,8 @@ interface AcpxPreparedRuntime {
remoteExecutionIdentity: Record<string, unknown> | null;
skillPromptInstructions: string;
skillsIdentity: Record<string, unknown>;
childStderrLogPath: string | null;
paperclipClaudeSettings: PaperclipClaudeSettingsResult | null;
}
const defaultWarmHandles = new Map<string, RuntimeCacheEntry>();
@@ -564,11 +566,105 @@ function buildSessionParams(input: {
};
}
interface PaperclipClaudeSettingsResult {
filePath: string;
allow: string[];
additionalDirectories: string[];
defaultMode: string;
overrodeDontAsk: boolean;
}
function uniqueSorted(values: Array<string | null | undefined>): string[] {
return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0))].sort();
}
// Phase 4.1 (PAPA-388): the Claude Code SDK that `claude-agent-acp` runs uses
// `settingSources: ["user", "project", "local"]`. By writing a per-worktree
// `.claude/settings.local.json` we override the user's potentially-restrictive
// `~/.claude/settings.json` (e.g. `defaultMode: "dontAsk"`, which silently
// denies every non-allowlisted tool and never reaches `canUseTool`), and we
// widen the SDK's Read sandbox to include the Paperclip state dirs the agent
// needs to talk to its own control plane.
async function writePaperclipClaudeSettings(input: {
cwd: string;
stateDir: string;
agentHome: string;
companyId: string;
}): Promise<PaperclipClaudeSettingsResult> {
const filePath = path.join(input.cwd, ".claude", "settings.local.json");
const instanceRoot = defaultPaperclipInstanceDir();
const companyRoot = path.join(instanceRoot, "companies", input.companyId);
const paperclipAdditionalDirectories = uniqueSorted([
input.stateDir,
input.agentHome,
companyRoot,
]);
const paperclipAllow = uniqueSorted([
"Bash(curl:*)",
"Bash(env:*)",
"Bash(env)",
`Bash(${input.cwd}/scripts/paperclip-issue-update.sh:*)`,
`Bash(${input.cwd}/scripts/paperclip:*)`,
]);
let existing: Record<string, unknown> = {};
const existingRaw = await fs.readFile(filePath, "utf8").catch(() => null);
if (existingRaw) {
try {
const parsed = JSON.parse(existingRaw);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed as Record<string, unknown>;
} catch {
// Malformed settings file — leave it alone in `existing` and our merge will replace it with a valid one.
}
}
const existingPerms =
existing.permissions && typeof existing.permissions === "object" && !Array.isArray(existing.permissions)
? (existing.permissions as Record<string, unknown>)
: {};
const existingAllow = Array.isArray(existingPerms.allow)
? (existingPerms.allow as unknown[]).filter((value): value is string => typeof value === "string")
: [];
const existingAdditionalDirectories = Array.isArray(existingPerms.additionalDirectories)
? (existingPerms.additionalDirectories as unknown[]).filter((value): value is string => typeof value === "string")
: [];
const mergedAllow = uniqueSorted([...existingAllow, ...paperclipAllow]);
const mergedAdditionalDirectories = uniqueSorted([
...existingAdditionalDirectories,
...paperclipAdditionalDirectories,
]);
const existingDefaultMode =
typeof existingPerms.defaultMode === "string" ? (existingPerms.defaultMode as string) : "";
const defaultMode =
existingDefaultMode && existingDefaultMode !== "dontAsk" ? existingDefaultMode : "default";
const overrodeDontAsk = existingDefaultMode === "dontAsk";
const nextPermissions: Record<string, unknown> = {
...existingPerms,
allow: mergedAllow,
additionalDirectories: mergedAdditionalDirectories,
defaultMode,
};
const next: Record<string, unknown> = { ...existing, permissions: nextPermissions };
await writeFileAtomically({
target: filePath,
contents: `${JSON.stringify(next, null, 2)}\n`,
mode: 0o600,
});
return {
filePath,
allow: mergedAllow,
additionalDirectories: mergedAdditionalDirectories,
defaultMode,
overrodeDontAsk,
};
}
async function writeAgentWrapper(input: {
stateDir: string;
acpxAgent: string;
agentCommandShell: string;
env: Record<string, string>;
childStderrDir: string;
}): Promise<{ wrapperPath: string; envFilePath: string }> {
const wrappersDir = path.join(input.stateDir, "wrappers");
await fs.mkdir(wrappersDir, { recursive: true });
@@ -580,6 +676,7 @@ async function writeAgentWrapper(input: {
agent: input.acpxAgent,
command: input.agentCommandShell,
env: envLines,
childStderrDir: input.childStderrDir,
});
const wrapperPath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.sh`);
const envFilePath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.env`);
@@ -592,6 +689,11 @@ async function writeAgentWrapper(input: {
" source \"$env_file\"",
" set +a",
"fi",
`stderr_dir=${shellQuote(input.childStderrDir)}`,
"if [[ -n \"${PAPERCLIP_RUN_ID:-}\" ]]; then",
" mkdir -p \"$stderr_dir\"",
" exec 2> >(tee -a \"$stderr_dir/$PAPERCLIP_RUN_ID.log\" >&2)",
"fi",
`exec ${input.agentCommandShell} "$@"`,
"",
].join("\n");
@@ -723,10 +825,20 @@ async function buildRuntime(input: {
if (typeof value === "string") env[key] = value;
}
if (!hasExplicitApiKey && authToken) env.PAPERCLIP_API_KEY = authToken;
// For the claude agent, set model via ANTHROPIC_MODEL at startup rather than
// via session/set_config_option — the ACP server's set_config_option handler
// validates the value against its internal available-models list and rejects
// bare model IDs (e.g. "claude-opus-4-7") that don't exactly match a model
// entry in some versions. ANTHROPIC_MODEL is read during initialization, so
// it reliably sets the model before any turns are run.
if (requestedModel && acpxAgent === "claude" && !env.ANTHROPIC_MODEL) {
env.ANTHROPIC_MODEL = requestedModel;
}
let skillPromptInstructions = "";
let skillsIdentity: Record<string, unknown> = { mode: "unsupported" };
const skillCommandNotes: string[] = [];
let paperclipClaudeSettings: PaperclipClaudeSettingsResult | null = null;
if (acpxAgent === "claude") {
const preparedSkills = await prepareClaudeSkillRuntime({
stateDir,
@@ -736,6 +848,17 @@ async function buildRuntime(input: {
skillPromptInstructions = preparedSkills.promptInstructions;
skillsIdentity = preparedSkills.identity;
skillCommandNotes.push(...preparedSkills.commandNotes);
paperclipClaudeSettings = await writePaperclipClaudeSettings({
cwd,
stateDir,
agentHome,
companyId: agent.companyId,
});
skillCommandNotes.push(
`Wrote Paperclip-managed Claude settings to ${paperclipClaudeSettings.filePath} (defaultMode=${paperclipClaudeSettings.defaultMode}${
paperclipClaudeSettings.overrodeDontAsk ? "; overrode user dontAsk" : ""
}, +${paperclipClaudeSettings.additionalDirectories.length} read root(s), +${paperclipClaudeSettings.allow.length} allow rule(s)).`,
);
} else if (acpxAgent === "codex") {
const preparedSkills = await prepareCodexSkillRuntime({
companyId: agent.companyId,
@@ -757,12 +880,15 @@ async function buildRuntime(input: {
const builtInCommand = resolveBuiltInAgentCommand(acpxAgent);
const agentCommand = configuredCommand || builtInCommand || null;
const agentCommandShell = configuredCommand || (builtInCommand ? shellQuote(builtInCommand) : "");
const childStderrDir = path.join(stateDir, "run-stderr");
const childStderrLogPath = agentCommand ? path.join(childStderrDir, `${runId}.log`) : null;
const wrapper = agentCommand
? await writeAgentWrapper({
stateDir,
acpxAgent,
agentCommandShell,
env,
childStderrDir,
})
: null;
const wrapperPath = wrapper?.wrapperPath ?? null;
@@ -781,6 +907,13 @@ async function buildRuntime(input: {
remoteExecutionIdentity,
skillsIdentity,
skillPromptInstructions,
paperclipClaudeSettings: paperclipClaudeSettings
? {
allow: paperclipClaudeSettings.allow,
additionalDirectories: paperclipClaudeSettings.additionalDirectories,
defaultMode: paperclipClaudeSettings.defaultMode,
}
: null,
});
const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default";
const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`;
@@ -817,12 +950,19 @@ async function buildRuntime(input: {
...skillsIdentity,
commandNotes: skillCommandNotes,
},
childStderrLogPath,
paperclipClaudeSettings,
};
}
function sessionConfigOptions(prepared: AcpxPreparedRuntime): Array<{ key: string; value: string }> {
const options: Array<{ key: string; value: string }> = [];
if (prepared.requestedModel) options.push({ key: "model", value: prepared.requestedModel });
// Model for the claude agent is pre-set via ANTHROPIC_MODEL env var at
// startup; skip set_config_option to avoid ACP-server model-name validation
// that rejects bare IDs like "claude-opus-4-7" in some runtime versions.
if (prepared.requestedModel && prepared.acpxAgent !== "claude") {
options.push({ key: "model", value: prepared.requestedModel });
}
if (prepared.requestedThinkingEffort) {
options.push({
key: prepared.acpxAgent === "codex" ? "reasoning_effort" : "effort",
@@ -999,33 +1139,151 @@ function resultErrorMessage(result: AcpRuntimeTurnResult): string | null {
return result.error.message;
}
function classifyError(err: unknown): Pick<AdapterExecutionResult, "errorCode" | "errorMeta"> {
const message = err instanceof Error ? err.message : String(err);
type AcpxExecutionPhase = "ensure_session" | "configure_session" | "turn";
function describeErrorDiagnostics(err: unknown): {
errorName: string;
acpCode: string | null;
causeMessage: string | null;
retryable: boolean | null;
stackPreview: string | null;
} {
const errorName =
err instanceof Error ? err.name || err.constructor.name : typeof err;
const maybeCode =
err && typeof err === "object" && typeof (err as { code?: unknown }).code === "string"
? (err as { code: string }).code
: null;
const acpCode = isAcpRuntimeError(err) || (maybeCode?.startsWith("ACP_") ?? false) ? maybeCode : null;
const acpCode =
isAcpRuntimeError(err) || (maybeCode?.startsWith("ACP_") ?? false) ? maybeCode : null;
const cause =
err && typeof err === "object" && (err as { cause?: unknown }).cause !== undefined
? (err as { cause?: unknown }).cause
: undefined;
const causeMessage =
cause instanceof Error
? cause.message
: typeof cause === "string"
? cause
: null;
const retryable =
err && typeof err === "object" && typeof (err as { retryable?: unknown }).retryable === "boolean"
? (err as { retryable: boolean }).retryable
: null;
const stack = err instanceof Error && typeof err.stack === "string" ? err.stack : "";
const stackPreview = stack ? stack.split("\n").slice(0, 6).join("\n") : null;
return { errorName, acpCode, causeMessage, retryable, stackPreview };
}
function classifyError(
err: unknown,
phase?: AcpxExecutionPhase,
): Pick<AdapterExecutionResult, "errorCode" | "errorMeta"> {
const message = err instanceof Error ? err.message : String(err);
const diagnostics = describeErrorDiagnostics(err);
const { acpCode, errorName, causeMessage, retryable, stackPreview } = diagnostics;
const baseMeta: Record<string, unknown> = {
errorName,
...(acpCode ? { acpCode } : {}),
...(causeMessage ? { causeMessage } : {}),
...(retryable !== null ? { retryable } : {}),
...(stackPreview ? { stackPreview } : {}),
...(phase ? { phase } : {}),
};
const lower = message.toLowerCase();
const authLike = lower.includes("auth") || lower.includes("login") || lower.includes("credential");
if (authLike) {
return {
errorCode: "acpx_auth_required",
errorMeta: { category: "auth", ...(acpCode ? { acpCode } : {}) },
errorMeta: { category: "auth", ...baseMeta },
};
}
const phaseCode = (() => {
if (acpCode === "ACP_SESSION_INIT_FAILED") return "acpx_session_init_failed";
if (acpCode === "ACP_TURN_FAILED") return "acpx_turn_failed";
if (acpCode === "ACP_BACKEND_MISSING") return "acpx_backend_missing";
if (acpCode === "ACP_BACKEND_UNAVAILABLE") return "acpx_backend_unavailable";
if (phase === "ensure_session") return "acpx_session_init_failed";
if (phase === "configure_session") return "acpx_session_config_failed";
if (phase === "turn") return "acpx_turn_failed";
return null;
})();
if (phaseCode) {
return {
errorCode: phaseCode,
errorMeta: { category: acpCode ? "protocol" : "runtime", ...baseMeta },
};
}
if (acpCode) {
return {
errorCode: "acpx_protocol_error",
errorMeta: { category: "protocol", acpCode },
errorMeta: { category: "protocol", ...baseMeta },
};
}
return {
errorCode: "acpx_runtime_error",
errorMeta: { category: "runtime" },
errorMeta: { category: "runtime", ...baseMeta },
};
}
async function readChildStderrTail(input: {
logPath: string | null;
maxBytes?: number;
}): Promise<string | null> {
if (!input.logPath) return null;
const maxBytes = input.maxBytes ?? 4096;
let handle: fs.FileHandle | null = null;
try {
const stat = await fs.stat(input.logPath);
if (stat.size === 0) return null;
handle = await fs.open(input.logPath, "r");
const readBytes = Math.min(stat.size, maxBytes);
const buffer = Buffer.alloc(readBytes);
await handle.read(buffer, 0, readBytes, Math.max(0, stat.size - readBytes));
const tail = buffer.toString("utf8").trim();
return tail.length > 0 ? tail : null;
} catch {
return null;
} finally {
if (handle) await handle.close().catch(() => {});
}
}
async function emitAcpxFailure(input: {
ctx: AdapterExecutionContext;
prepared: AcpxPreparedRuntime;
err: unknown;
phase: AcpxExecutionPhase;
// Replace the err-derived message in both the stderr-tail log header and the
// acpx.error payload. Used by the turn path to surface "Timed out after Ns"
// instead of the raw underlying error message.
messageOverride?: string;
}): Promise<{
classified: Pick<AdapterExecutionResult, "errorCode" | "errorMeta">;
message: string;
childStderrTail: string | null;
}> {
const { ctx, prepared, err, phase, messageOverride } = input;
const rawMessage = err instanceof Error ? err.message : String(err);
const message = messageOverride ?? rawMessage;
const classified = classifyError(err, phase);
const childStderrTail = await readChildStderrTail({ logPath: prepared.childStderrLogPath });
if (childStderrTail) {
await ctx.onLog(
"stderr",
`[paperclip] ACPX child stderr tail (${phase}):\n${childStderrTail}\n`,
);
}
await emitAcpxLog(ctx, {
type: "acpx.error",
message,
phase,
...classified.errorMeta,
...(childStderrTail ? { childStderrTail } : {}),
});
return { classified, message, childStderrTail };
}
function isResumeFailure(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return /resume|load|not found|no session|unknown session|conversation/i.test(message);
@@ -1136,6 +1394,11 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
permissionMode: prepared.permissionMode,
nonInteractivePermissions: prepared.nonInteractivePermissions,
timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined,
// Scope ACPX runtime verbose logs to the claude agent only — that's the
// surface we know needs the extra session-event detail (PAPA-388). codex
// and custom agents already emit their own per-tool output and don't
// benefit from doubling the log volume.
verbose: prepared.acpxAgent === "claude",
};
const runtime = cached?.runtime ?? createRuntime(runtimeOptions);
if (cached) clearWarmHandleTimer(cached);
@@ -1177,9 +1440,12 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
}
}
} catch (err) {
const classified = classifyError(err);
const message = err instanceof Error ? err.message : String(err);
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
const { classified, message } = await emitAcpxFailure({
ctx,
prepared,
err,
phase: "ensure_session",
});
return {
exitCode: 1,
signal: null,
@@ -1216,9 +1482,12 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
onLog: ctx.onLog,
});
} catch (err) {
const classified = classifyError(err);
const message = err instanceof Error ? err.message : String(err);
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
const { classified, message } = await emitAcpxFailure({
ctx,
prepared,
err,
phase: "configure_session",
});
await runtime.close({
handle: sessionHandle,
reason: "paperclip config cleanup",
@@ -1271,7 +1540,13 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
commandNotes: [
`ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`,
`Effective ACPX permission mode: ${prepared.permissionMode}.`,
...(prepared.requestedModel ? [`Requested ACPX model: ${prepared.requestedModel}.`] : []),
...(prepared.requestedModel
? [
prepared.acpxAgent === "claude"
? `Requested ACPX model: ${prepared.requestedModel} (set via ANTHROPIC_MODEL env at startup).`
: `Requested ACPX model: ${prepared.requestedModel}.`,
]
: []),
...(prepared.requestedThinkingEffort ? [`Requested ACPX thinking effort: ${prepared.requestedThinkingEffort}.`] : []),
...(prepared.fastMode ? ["Requested ACPX Codex fast mode."] : []),
...(Array.isArray(prepared.skillsIdentity.commandNotes)
@@ -1414,10 +1689,11 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
};
} catch (err) {
if (timeout) clearTimeout(timeout);
const classified = classifyError(err);
const message = timedOut ? `Timed out after ${prepared.timeoutSec}s` : err instanceof Error ? err.message : String(err);
const messageOverride = timedOut ? `Timed out after ${prepared.timeoutSec}s` : undefined;
const cancel = cancelActiveTurn as ((reason: string) => Promise<void>) | null;
if (cancel) await cancel(message).catch(() => {});
const preEmitMessage =
messageOverride ?? (err instanceof Error ? err.message : String(err));
if (cancel) await cancel(preEmitMessage).catch(() => {});
await runtime.close({
handle: sessionHandle,
reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup",
@@ -1428,7 +1704,13 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
clearWarmHandleTimer(existing);
warmHandles.delete(prepared.sessionKey);
}
await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta });
const { classified, message } = await emitAcpxFailure({
ctx,
prepared,
err,
phase: "turn",
messageOverride,
});
return {
exitCode: 1,
signal: timedOut ? "SIGTERM" : null,
@@ -212,6 +212,14 @@ export async function testEnvironment(
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
if (extraArgs.length > 0) args.push(...extraArgs);
// Sandbox bridges still add lease warmup and transport overhead, but
// the standard-2 Cloudflare tier now probes fast enough that a 90s
// budget leaves headroom without masking real hangs.
const helloProbeTimeoutSec = Math.max(
1,
asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 45),
);
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
@@ -220,7 +228,7 @@ export async function testEnvironment(
{
cwd,
env,
timeoutSec: 45,
timeoutSec: helloProbeTimeoutSec,
graceSec: 5,
stdin: "Respond with hello.",
onLog: async () => {},
+2 -1
View File
@@ -52,7 +52,8 @@ export const modelProfiles: AdapterModelProfileDefinition[] = [
description: "Use the lowest-cost known Codex local model lane without changing the primary model.",
adapterConfig: {
model: "gpt-5.3-codex-spark",
modelReasoningEffort: "low",
// Spark is the cheap lane by model price; high effort keeps Codex coding behavior usable for delegated work.
modelReasoningEffort: "high",
},
source: "adapter_default",
},
@@ -0,0 +1,57 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { prepareManagedCodexHome } from "./codex-home.js";
describe("codex managed home", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("treats a concurrently-created expected auth symlink as success", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-"));
const sharedCodexHome = path.join(root, "shared-codex-home");
const paperclipHome = path.join(root, "paperclip-home");
const managedCodexHome = path.join(
paperclipHome,
"instances",
"default",
"companies",
"company-1",
"codex-home",
);
const sharedAuth = path.join(sharedCodexHome, "auth.json");
const managedAuth = path.join(managedCodexHome, "auth.json");
await fs.mkdir(sharedCodexHome, { recursive: true });
await fs.writeFile(sharedAuth, '{"token":"shared"}\n', "utf8");
const originalSymlink = fs.symlink.bind(fs);
vi.spyOn(fs, "symlink").mockImplementationOnce(async (source, target, type) => {
await originalSymlink(source, target, type);
const error = new Error("file already exists") as NodeJS.ErrnoException;
error.code = "EEXIST";
throw error;
});
try {
await expect(
prepareManagedCodexHome(
{
CODEX_HOME: sharedCodexHome,
PAPERCLIP_HOME: paperclipHome,
PAPERCLIP_INSTANCE_ID: "default",
},
async () => {},
"company-1",
),
).resolves.toBe(managedCodexHome);
expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(sharedAuth));
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
@@ -45,11 +45,31 @@ async function ensureParentDir(target: string): Promise<void> {
await fs.mkdir(path.dirname(target), { recursive: true });
}
async function isExpectedSymlink(target: string, source: string): Promise<boolean> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing?.isSymbolicLink()) return false;
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return false;
return path.resolve(path.dirname(target), linkedPath) === path.resolve(source);
}
async function createExpectedSymlink(target: string, source: string): Promise<void> {
try {
await fs.symlink(source, target);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EEXIST" && await isExpectedSymlink(target, source)) return;
throw error;
}
}
async function ensureSymlink(target: string, source: string): Promise<void> {
const existing = await fs.lstat(target).catch(() => null);
if (!existing) {
await ensureParentDir(target);
await fs.symlink(source, target);
await createExpectedSymlink(target, source);
return;
}
@@ -57,14 +77,10 @@ async function ensureSymlink(target: string, source: string): Promise<void> {
return;
}
const linkedPath = await fs.readlink(target).catch(() => null);
if (!linkedPath) return;
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
if (resolvedLinkedPath === source) return;
if (await isExpectedSymlink(target, source)) return;
await fs.unlink(target);
await fs.symlink(source, target);
await createExpectedSymlink(target, source);
}
async function ensureCopiedFile(target: string, source: string): Promise<void> {
@@ -4,6 +4,7 @@ import type {
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import {
asNumber,
asString,
asStringArray,
parseObject,
@@ -98,6 +99,7 @@ export async function testEnvironment(
let command = asString(config.command, "agent");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
@@ -230,6 +232,12 @@ export async function testEnvironment(
hint: "Use `agent` or `cursor-agent` to run the automatic installation and auth probe.",
});
} else {
// Cursor's `agent` binary still pays cold-start overhead in container
// sandboxes, but standard-2 probes no longer need a 120s version budget.
const versionProbeTimeoutSec = Math.max(
1,
asNumber(config.versionProbeTimeoutSec, targetIsSandbox ? 60 : 45),
);
const versionProbe = await runAdapterExecutionTargetProcess(
runId,
target,
@@ -238,7 +246,7 @@ export async function testEnvironment(
{
cwd,
env,
timeoutSec: 45,
timeoutSec: versionProbeTimeoutSec,
graceSec: 5,
onLog: async () => {},
},
@@ -295,6 +303,12 @@ export async function testEnvironment(
if (extraArgs.length > 0) args.push(...extraArgs);
args.push("Respond with hello.");
// Sandbox bridges still add cursor CLI cold-start overhead, but the
// standard-2 tier now completes probes fast enough that 90s is ample.
const helloProbeTimeoutSec = Math.max(
1,
asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 45),
);
const probe = await runAdapterExecutionTargetProcess(
runId,
target,
@@ -303,7 +317,7 @@ export async function testEnvironment(
{
cwd,
env,
timeoutSec: 45,
timeoutSec: helloProbeTimeoutSec,
graceSec: 5,
onLog: async () => {},
},
+60
View File
@@ -0,0 +1,60 @@
{
"name": "@paperclipai/adapter-grok-local",
"version": "0.3.1",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/adapters/grok-local"
},
"type": "module",
"exports": {
".": "./src/index.ts",
"./server": "./src/server/index.ts",
"./ui": "./src/ui/index.ts",
"./cli": "./src/cli/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"import": "./dist/server/index.js"
},
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
},
"./cli": {
"types": "./dist/cli/index.d.ts",
"import": "./dist/cli/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@paperclipai/adapter-utils": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}
@@ -0,0 +1,24 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { printGrokStreamEvent } from "./format-event.js";
describe("printGrokStreamEvent", () => {
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
afterEach(() => {
spy.mockClear();
});
it("prints thought/text/end events", () => {
printGrokStreamEvent(JSON.stringify({ type: "thought", data: "Plan" }), false);
printGrokStreamEvent(JSON.stringify({ type: "text", data: "hello" }), false);
printGrokStreamEvent(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), false);
expect(spy.mock.calls.flat()).toEqual(
expect.arrayContaining([
expect.stringContaining("thinking: Plan"),
expect.stringContaining("assistant: hello"),
expect.stringContaining("Grok run completed"),
]),
);
});
});
@@ -0,0 +1,59 @@
import pc from "picocolors";
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
export function printGrokStreamEvent(raw: string, _debug: boolean): void {
const line = raw.trim();
if (!line) return;
let parsed: Record<string, unknown> | null = null;
try {
parsed = JSON.parse(line) as Record<string, unknown>;
} catch {
console.log(line);
return;
}
const type = asString(parsed.type).trim();
if (type === "thought") {
const text = asString(parsed.data);
if (text) console.log(pc.gray(`thinking: ${text}`));
return;
}
if (type === "text") {
const text = asString(parsed.data);
if (text) console.log(pc.green(`assistant: ${text}`));
return;
}
if (type === "end") {
const stopReason = asString(parsed.stopReason);
const sessionId = asString(parsed.sessionId);
const details = [stopReason ? `stopReason=${stopReason}` : "", sessionId ? `session=${sessionId}` : ""]
.filter(Boolean)
.join(" ");
console.log(pc.blue(`Grok run completed${details ? ` (${details})` : ""}`));
return;
}
if (type === "error") {
const text =
asString(parsed.data) ||
asString(parsed.message) ||
asString(parsed.error) ||
"Grok error";
console.log(pc.red(`error: ${text}`));
return;
}
const payload = asRecord(parsed);
console.log(pc.gray(`event: ${type || "unknown"} ${payload ? JSON.stringify(payload) : line}`));
}
@@ -0,0 +1 @@
export { printGrokStreamEvent } from "./format-event.js";
+45
View File
@@ -0,0 +1,45 @@
export const type = "grok_local";
export const label = "Grok Build (local)";
export const DEFAULT_GROK_LOCAL_MODEL = "grok-build";
export const models = [
{ id: DEFAULT_GROK_LOCAL_MODEL, label: DEFAULT_GROK_LOCAL_MODEL },
];
export const agentConfigurationDoc = `# grok_local agent configuration
Adapter: grok_local
Use when:
- You want Paperclip to run the native Grok Build CLI locally on the host machine
- You want resumable Grok sessions across heartbeats via \`--resume\`
- You want Paperclip-managed instructions and skills staged into the execution workspace using Grok's native discovery paths (\`Agents.md\` and \`.claude/skills\`)
Don't use when:
- You need a webhook-style external invocation (use http or openclaw_gateway)
- You only need a one-shot script without an AI coding agent loop (use process)
- Grok CLI is not installed or authenticated on the machine that runs Paperclip
Core fields:
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
- instructionsFilePath (string, optional): absolute path to a markdown instructions file. Paperclip stages it into the execution workspace as \`Agents.md\` when safe, otherwise falls back to \`--rules @file\`
- promptTemplate (string, optional): run prompt template
- model (string, optional): Grok model id. Defaults to grok-build.
- permissionMode (string, optional): Grok permission mode. Defaults to \`dontAsk\`
- reasoningEffort (string, optional): Grok reasoning effort passed via \`--reasoning-effort\`
- maxTurns (number, optional): maximum agent turns for the run
- command (string, optional): defaults to "grok"
- extraArgs (string[], optional): additional CLI args
- env (object, optional): KEY=VALUE environment variables
Operational fields:
- timeoutSec (number, optional): run timeout in seconds
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- Runs use \`grok --single\` with \`--output-format streaming-json\`.
- Sessions resume with \`--resume <sessionId>\` when the saved session cwd matches the current cwd.
- Paperclip stages desired runtime skills into \`.claude/skills\` inside the execution workspace so Grok discovers them as project skills.
- Use \`grok models\` to inspect authentication and available models on the host.
`;
@@ -0,0 +1,187 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
const ensureRuntimeInstalledMock = vi.hoisted(() => vi.fn(async () => {}));
const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {}));
const prepareRuntimeMock = vi.hoisted(() => vi.fn(async () => ({
workspaceRemoteDir: null,
restoreWorkspace: async () => {},
})));
const resolveCommandForLogsMock = vi.hoisted(() => vi.fn(async () => "grok"));
const runProcessMock = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({
adapterExecutionTargetIsRemote: () => false,
adapterExecutionTargetRemoteCwd: (_target: unknown, cwd: string) => cwd,
overrideAdapterExecutionTargetRemoteCwd: (target: unknown, _cwd: string) => target,
adapterExecutionTargetSessionIdentity: () => ({ kind: "local" }),
adapterExecutionTargetSessionMatches: () => true,
describeAdapterExecutionTarget: () => "local",
ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock,
ensureAdapterExecutionTargetRuntimeCommandInstalled: ensureRuntimeInstalledMock,
prepareAdapterExecutionTargetRuntime: prepareRuntimeMock,
readAdapterExecutionTarget: ({ executionTarget }: { executionTarget?: unknown }) => executionTarget ?? { kind: "local" },
resolveAdapterExecutionTargetCommandForLogs: resolveCommandForLogsMock,
resolveAdapterExecutionTargetTimeoutSec: (_target: unknown, timeoutSec: number) => timeoutSec,
runAdapterExecutionTargetProcess: runProcessMock,
}));
import { execute } from "./execute.js";
const tempRoots: string[] = [];
async function makeTempRoot() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-grok-local-"));
tempRoots.push(root);
return root;
}
async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
describe("grok_local execute", () => {
beforeEach(() => {
ensureRuntimeInstalledMock.mockClear();
ensureCommandMock.mockClear();
prepareRuntimeMock.mockClear();
resolveCommandForLogsMock.mockClear();
runProcessMock.mockReset();
});
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
it("stages Grok-native instructions and skills into the workspace for the run and cleans them up afterward", async () => {
const root = await makeTempRoot();
const instructionsPath = path.join(root, "managed", "AGENTS.md");
const skillSource = path.join(root, "runtime-skills", "paperclip");
await fs.mkdir(path.dirname(instructionsPath), { recursive: true });
await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8");
await fs.mkdir(skillSource, { recursive: true });
await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8");
runProcessMock.mockImplementation(async (_runId, _target, _command, args, options) => {
expect(args).toEqual(
expect.arrayContaining([
"--output-format",
"streaming-json",
"--always-approve",
"--permission-mode",
"dontAsk",
]),
);
expect(await fs.readFile(path.join(root, "Agents.md"), "utf8")).toContain("You are Grok.");
expect(await pathExists(path.join(root, ".claude", "skills", "paperclip", "SKILL.md"))).toBe(true);
await options.onLog?.("stdout", '{"type":"text","data":"done"}\n');
return {
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
JSON.stringify({ type: "text", data: "done" }),
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }),
].join("\n"),
stderr: "",
};
});
const logs: Array<{ stream: "stdout" | "stderr"; chunk: string }> = [];
const ctx: AdapterExecutionContext = {
runId: "run-1",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Grok Agent",
adapterType: "grok_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
cwd: root,
instructionsFilePath: instructionsPath,
paperclipRuntimeSkills: [{
key: "paperclip",
runtimeName: "paperclip",
source: skillSource,
required: false,
}],
paperclipSkillSync: { desiredSkills: ["paperclip"] },
},
context: {},
authToken: "run-token",
onLog: async (stream: "stdout" | "stderr", chunk: string) => {
logs.push({ stream, chunk });
},
};
const result = await execute(ctx);
expect(result).toMatchObject({
exitCode: 0,
errorMessage: null,
summary: "done",
sessionId: "sess-1",
sessionDisplayId: "sess-1",
});
expect(await pathExists(path.join(root, "Agents.md"))).toBe(false);
expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false);
expect(logs.map((entry) => entry.chunk)).not.toEqual([]);
});
it("cleans up staged assets when setup fails before the Grok process starts", async () => {
const root = await makeTempRoot();
const instructionsPath = path.join(root, "managed", "AGENTS.md");
const skillSource = path.join(root, "runtime-skills", "paperclip");
await fs.mkdir(path.dirname(instructionsPath), { recursive: true });
await fs.writeFile(instructionsPath, "You are Grok.\n", "utf8");
await fs.mkdir(skillSource, { recursive: true });
await fs.writeFile(path.join(skillSource, "SKILL.md"), "---\nname: paperclip\ndescription: test\n---\n", "utf8");
ensureCommandMock.mockRejectedValueOnce(new Error("grok not installed"));
const ctx: AdapterExecutionContext = {
runId: "run-setup-fail",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Grok Agent",
adapterType: "grok_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
cwd: root,
instructionsFilePath: instructionsPath,
paperclipRuntimeSkills: [{
key: "paperclip",
runtimeName: "paperclip",
source: skillSource,
required: false,
}],
paperclipSkillSync: { desiredSkills: ["paperclip"] },
},
context: {},
authToken: "run-token",
onLog: async () => {},
};
await expect(execute(ctx)).rejects.toThrow("grok not installed");
expect(runProcessMock).not.toHaveBeenCalled();
expect(await pathExists(path.join(root, "Agents.md"))).toBe(false);
expect(await pathExists(path.join(root, ".claude", "skills", "paperclip"))).toBe(false);
});
});
@@ -0,0 +1,583 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import {
adapterExecutionTargetIsRemote,
adapterExecutionTargetRemoteCwd,
adapterExecutionTargetSessionIdentity,
adapterExecutionTargetSessionMatches,
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetRuntimeCommandInstalled,
overrideAdapterExecutionTargetRemoteCwd,
prepareAdapterExecutionTargetRuntime,
readAdapterExecutionTarget,
resolveAdapterExecutionTargetCommandForLogs,
resolveAdapterExecutionTargetTimeoutSec,
runAdapterExecutionTargetProcess,
} from "@paperclipai/adapter-utils/execution-target";
import {
asBoolean,
asNumber,
asString,
asStringArray,
buildInvocationEnvForLogs,
buildPaperclipEnv,
ensureAbsoluteDirectory,
ensurePathInEnv,
joinPromptSections,
materializePaperclipSkillCopy,
parseObject,
readPaperclipIssueWorkModeFromContext,
readPaperclipRuntimeSkillEntries,
renderTemplate,
renderPaperclipWakePrompt,
resolvePaperclipDesiredSkillNames,
stringifyPaperclipWakePayload,
refreshPaperclipWorkspaceEnvForExecution,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
} from "@paperclipai/adapter-utils/server-utils";
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
const raw = env[key];
return typeof raw === "string" && raw.trim().length > 0;
}
function renderPaperclipEnvNote(env: Record<string, string>): string {
const paperclipKeys = Object.keys(env)
.filter((key) => key.startsWith("PAPERCLIP_"))
.sort();
if (paperclipKeys.length === 0) return "";
return [
"Paperclip runtime note:",
`The following PAPERCLIP_* environment variables are available in this run: ${paperclipKeys.join(", ")}`,
"Do not assume these variables are missing without checking your shell environment.",
"",
"",
].join("\n");
}
function renderApiAccessNote(env: Record<string, string>): string {
if (!hasNonEmptyEnvValue(env, "PAPERCLIP_API_URL") || !hasNonEmptyEnvValue(env, "PAPERCLIP_API_KEY")) return "";
return [
"Paperclip API access note:",
"Use shell commands with curl to make Paperclip API requests when needed.",
"Include X-Paperclip-Run-Id on mutating requests.",
"",
"",
].join("\n");
}
type StageCleanup = {
kind: "file" | "dir";
path: string;
};
type StagedGrokAssets = {
cleanup: () => Promise<void>;
stagedSkillsCount: number;
stagedInstructionsPath: string | null;
rulesFilePath: string | null;
};
async function pathExists(candidate: string): Promise<boolean> {
return fs.access(candidate).then(() => true).catch(() => false);
}
async function stageGrokProjectAssets(input: {
cwd: string;
instructionsFilePath: string;
skillEntries: Array<{ key: string; runtimeName: string; source: string }>;
desiredSkillNames: string[];
onLog: AdapterExecutionContext["onLog"];
}): Promise<StagedGrokAssets> {
const cleanup: StageCleanup[] = [];
const ensureCleanupDir = (candidate: string) => {
cleanup.push({ kind: "dir", path: candidate });
};
const ensureCleanupFile = (candidate: string) => {
cleanup.push({ kind: "file", path: candidate });
};
let stagedInstructionsPath: string | null = null;
let rulesFilePath: string | null = null;
let stagedSkillsCount = 0;
const instructionsTarget = path.join(input.cwd, "Agents.md");
if (input.instructionsFilePath) {
if (!await pathExists(instructionsTarget)) {
await fs.copyFile(input.instructionsFilePath, instructionsTarget);
ensureCleanupFile(instructionsTarget);
stagedInstructionsPath = instructionsTarget;
} else if (path.resolve(instructionsTarget) !== path.resolve(input.instructionsFilePath)) {
rulesFilePath = input.instructionsFilePath;
await input.onLog(
"stdout",
`[paperclip] Grok workspace already contains ${instructionsTarget}; using --rules @${input.instructionsFilePath} instead of overwriting it.\n`,
);
}
} else {
const canonicalAgents = path.join(input.cwd, "AGENTS.md");
if (!await pathExists(instructionsTarget) && await pathExists(canonicalAgents)) {
await fs.copyFile(canonicalAgents, instructionsTarget);
ensureCleanupFile(instructionsTarget);
stagedInstructionsPath = instructionsTarget;
}
}
const desiredSet = new Set(input.desiredSkillNames);
const selectedSkills = input.skillEntries.filter((entry) => desiredSet.has(entry.key));
if (selectedSkills.length > 0) {
const claudeDir = path.join(input.cwd, ".claude");
const skillsRoot = path.join(claudeDir, "skills");
if (!await pathExists(claudeDir)) {
await fs.mkdir(claudeDir, { recursive: true });
ensureCleanupDir(claudeDir);
}
if (!await pathExists(skillsRoot)) {
await fs.mkdir(skillsRoot, { recursive: true });
ensureCleanupDir(skillsRoot);
}
for (const skill of selectedSkills) {
const target = path.join(skillsRoot, skill.runtimeName);
if (await pathExists(target)) {
await input.onLog(
"stdout",
`[paperclip] Grok skill target already exists at ${target}; leaving it unchanged.\n`,
);
continue;
}
await materializePaperclipSkillCopy(skill.source, target);
ensureCleanupDir(target);
stagedSkillsCount += 1;
}
}
return {
stagedSkillsCount,
stagedInstructionsPath,
rulesFilePath,
cleanup: async () => {
for (const entry of [...cleanup].reverse()) {
if (entry.kind === "file") {
await fs.rm(entry.path, { force: true }).catch(() => undefined);
continue;
}
await fs.rm(entry.path, { recursive: true, force: true }).catch(() => undefined);
}
},
};
}
function resolveBillingType(env: Record<string, string>): "api" | "subscription" {
return hasNonEmptyEnvValue(env, "XAI_API_KEY") ? "api" : "subscription";
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, authToken } = ctx;
const executionTarget = readAdapterExecutionTarget({
executionTarget: ctx.executionTarget,
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
});
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
const promptTemplate = asString(
config.promptTemplate,
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
);
const command = asString(config.command, "grok");
const model = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim();
const permissionMode = asString(config.permissionMode, "dontAsk").trim() || "dontAsk";
const reasoningEffort = asString(config.reasoningEffort, "").trim();
const maxTurns = asNumber(config.maxTurns, 0);
const alwaysApprove = asBoolean(config.alwaysApprove, true);
const disableWebSearch = asBoolean(config.disableWebSearch, true);
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const workspaceSource = asString(workspaceContext.source, "");
const workspaceId = asString(workspaceContext.workspaceId, "");
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
const agentHome = asString(workspaceContext.agentHome, "");
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
? context.paperclipWorkspaces.filter(
(value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null,
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
const grokSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredGrokSkillNames = resolvePaperclipDesiredSkillNames(config, grokSkillEntries);
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const stagedAssets = await stageGrokProjectAssets({
cwd,
instructionsFilePath,
skillEntries: grokSkillEntries,
desiredSkillNames: desiredGrokSkillNames,
onLog,
});
let restoreRemoteWorkspace: (() => Promise<void>) | null = null;
try {
const envConfig = parseObject(config.env);
const hasExplicitApiKey =
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
env.PAPERCLIP_RUN_ID = runId;
const wakeTaskId =
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
null;
const wakeReason =
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
? context.wakeReason.trim()
: null;
const wakeCommentId =
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
null;
const approvalId =
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
? context.approvalId.trim()
: null;
const approvalStatus =
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
? context.approvalStatus.trim()
: null;
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((value: unknown): value is string => typeof value === "string" && value.trim().length > 0)
: [];
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
executionTarget,
asNumber(config.timeoutSec, 0),
);
const graceSec = asNumber(config.graceSec, 20);
await ensureAdapterExecutionTargetRuntimeCommandInstalled({
runId,
target: executionTarget,
installCommand: ctx.runtimeCommandSpec?.installCommand,
detectCommand: ctx.runtimeCommandSpec?.detectCommand,
cwd,
env,
timeoutSec,
graceSec,
onLog,
});
if (executionTargetIsRemote) {
await onLog(
"stdout",
`[paperclip] Syncing Grok workspace to ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
const preparedExecutionTargetRuntime = await prepareAdapterExecutionTargetRuntime({
runId,
target: executionTarget,
adapterKey: "grok",
workspaceLocalDir: cwd,
timeoutSec,
installCommand: ctx.runtimeCommandSpec?.installCommand ?? null,
detectCommand: ctx.runtimeCommandSpec?.detectCommand ?? command,
});
restoreRemoteWorkspace = () => preparedExecutionTargetRuntime.restoreWorkspace();
effectiveExecutionCwd = preparedExecutionTargetRuntime.workspaceRemoteDir ?? effectiveExecutionCwd;
refreshPaperclipWorkspaceEnvForExecution({
env,
envConfig,
workspaceCwd: effectiveWorkspaceCwd,
workspaceSource,
workspaceId,
workspaceRepoUrl,
workspaceRepoRef,
workspaceHints,
agentHome,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
}
const runtimeExecutionTarget = overrideAdapterExecutionTargetRemoteCwd(executionTarget, effectiveExecutionCwd);
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const runtimeEnv = ensurePathInEnv(effectiveEnv);
await ensureAdapterExecutionTargetCommandResolvable(command, executionTarget, cwd, runtimeEnv, {
installCommand: ctx.runtimeCommandSpec?.installCommand ?? null,
timeoutSec,
});
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
const loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const billingType = resolveBillingType(effectiveEnv);
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
const runtimeRemoteExecution = parseObject(runtimeSessionParams.remoteExecution);
const canResumeSession =
runtimeSessionId.length > 0 &&
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(effectiveExecutionCwd)) &&
adapterExecutionTargetSessionMatches(runtimeRemoteExecution, runtimeExecutionTarget);
const sessionId = canResumeSession ? runtimeSessionId : null;
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`,
);
} else if (runtimeSessionId && !canResumeSession) {
await onLog(
"stdout",
`[paperclip] Grok session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${effectiveExecutionCwd}".\n`,
);
}
const commandNotes = (() => {
const notes: string[] = ["Prompt is passed to Grok via --single in headless mode."];
if (alwaysApprove) notes.push("Added --always-approve for unattended execution.");
if (stagedAssets.stagedInstructionsPath) {
notes.push(`Staged project instructions at ${stagedAssets.stagedInstructionsPath} for native Grok discovery.`);
}
if (stagedAssets.rulesFilePath) {
notes.push(`Applied fallback instructions via --rules @${stagedAssets.rulesFilePath}.`);
}
if (stagedAssets.stagedSkillsCount > 0) {
notes.push(`Staged ${stagedAssets.stagedSkillsCount} Paperclip skill(s) into .claude/skills for native Grok discovery.`);
}
return notes;
})();
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
};
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const paperclipEnvNote = renderPaperclipEnvNote(env);
const apiAccessNote = renderApiAccessNote(env);
const prompt = joinPromptSections([
wakePrompt,
sessionHandoffNote,
paperclipEnvNote,
apiAccessNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
wakePromptChars: wakePrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
const buildArgs = (resumeSessionId: string | null) => {
const args = ["--cwd", effectiveExecutionCwd, "--output-format", "streaming-json"];
if (resumeSessionId) args.push("--resume", resumeSessionId);
if (model && model !== DEFAULT_GROK_LOCAL_MODEL) args.push("--model", model);
if (reasoningEffort) args.push("--reasoning-effort", reasoningEffort);
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
if (permissionMode) args.push("--permission-mode", permissionMode);
if (alwaysApprove) args.push("--always-approve");
if (disableWebSearch) args.push("--disable-web-search");
if (stagedAssets.rulesFilePath) args.push("--rules", `@${stagedAssets.rulesFilePath}`);
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
return asStringArray(config.args);
})();
if (extraArgs.length > 0) args.push(...extraArgs);
args.push("--single", prompt);
return args;
};
const runAttempt = async (resumeSessionId: string | null) => {
const args = buildArgs(resumeSessionId);
if (onMeta) {
await onMeta({
adapterType: "grok_local",
command: resolvedCommand,
cwd: effectiveExecutionCwd,
commandNotes,
commandArgs: args.map((value, index) => (
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
)),
env: loggedEnv,
prompt,
promptMetrics,
context,
});
}
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
cwd,
env,
timeoutSec,
graceSec,
onSpawn,
onLog,
});
return {
proc,
parsed: parseGrokJsonl(proc.stdout),
};
};
const toResult = (
attempt: {
proc: {
exitCode: number | null;
signal: string | null;
timedOut: boolean;
stdout: string;
stderr: string;
};
parsed: ReturnType<typeof parseGrokJsonl>;
},
clearSessionOnMissingSession = false,
isRetry = false,
): AdapterExecutionResult => {
if (attempt.proc.timedOut) {
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
clearSession: clearSessionOnMissingSession,
};
}
const failed = (attempt.proc.exitCode ?? 0) !== 0;
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
const fallbackErrorMessage =
parsedError ||
stderrLine ||
`Grok exited with code ${attempt.proc.exitCode ?? -1}`;
const canFallbackToRuntimeSession = !isRetry;
const resolvedSessionId = attempt.parsed.sessionId
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
const resolvedSessionParams = resolvedSessionId
? ({
sessionId: resolvedSessionId,
cwd: effectiveExecutionCwd,
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
...(executionTargetIsRemote
? {
remoteExecution: adapterExecutionTargetSessionIdentity(runtimeExecutionTarget),
}
: {}),
} as Record<string, unknown>)
: null;
return {
exitCode: attempt.proc.exitCode,
signal: attempt.proc.signal,
timedOut: false,
errorMessage: failed ? fallbackErrorMessage : null,
usage: {
inputTokens: 0,
outputTokens: 0,
cachedInputTokens: 0,
},
sessionId: resolvedSessionId,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId,
provider: "xai",
biller: billingType === "api" ? "xai" : "grok",
model,
billingType,
costUsd: null,
resultJson: {
stopReason: attempt.parsed.stopReason,
requestId: attempt.parsed.requestId,
...(failed ? { stderr: attempt.proc.stderr } : {}),
},
summary: attempt.parsed.summary,
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
};
};
const initial = await runAttempt(sessionId);
if (
sessionId &&
!initial.proc.timedOut &&
(initial.proc.exitCode ?? 0) !== 0 &&
isGrokUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
) {
await onLog(
"stdout",
`[paperclip] Grok resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
);
const retry = await runAttempt(null);
return toResult(retry, true, true);
}
return toResult(initial);
} finally {
await Promise.all([
restoreRemoteWorkspace?.(),
stagedAssets.cleanup(),
]);
}
}
@@ -0,0 +1,66 @@
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId =
readNonEmptyString(record.sessionId) ??
readNonEmptyString(record.session_id) ??
readNonEmptyString(record.sessionID);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId =
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return (
readNonEmptyString(params.sessionId) ??
readNonEmptyString(params.session_id) ??
readNonEmptyString(params.sessionID)
);
},
};
export { execute } from "./execute.js";
export { listGrokSkills, syncGrokSkills } from "./skills.js";
export { testEnvironment } from "./test.js";
export { parseGrokJsonl, isGrokUnknownSessionError } from "./parse.js";
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { isGrokUnknownSessionError, parseGrokJsonl } from "./parse.js";
describe("parseGrokJsonl", () => {
it("collects streamed thought/text content and final session metadata", () => {
const parsed = parseGrokJsonl([
JSON.stringify({ type: "thought", data: "Plan" }),
JSON.stringify({ type: "thought", data: " first." }),
JSON.stringify({ type: "text", data: "hel" }),
JSON.stringify({ type: "text", data: "lo" }),
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }),
].join("\n"));
expect(parsed).toEqual({
sessionId: "sess-1",
summary: "hello",
thought: "Plan first.",
errorMessage: null,
stopReason: "EndTurn",
requestId: "req-1",
});
});
it("reads structured error payloads", () => {
const parsed = parseGrokJsonl([
JSON.stringify({ type: "error", error: { message: "Authentication required" } }),
].join("\n"));
expect(parsed.errorMessage).toBe("Authentication required");
});
it("separates reasoning turns that grok streaming-json glues together", () => {
// PAPA-349: at turn boundaries grok drops the newline between turns; the
// aggregated thought should still read as two paragraphs.
const parsed = parseGrokJsonl([
JSON.stringify({ type: "thought", data: "The user uses `" }),
JSON.stringify({ type: "thought", data: "ls" }),
JSON.stringify({ type: "thought", data: "`" }),
JSON.stringify({ type: "thought", data: "The" }),
JSON.stringify({ type: "thought", data: " `" }),
JSON.stringify({ type: "thought", data: "ls" }),
JSON.stringify({ type: "thought", data: "`" }),
JSON.stringify({ type: "thought", data: " returned" }),
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }),
].join("\n"));
expect(parsed.thought).toBe("The user uses `ls`\nThe `ls` returned");
});
it("preserves assistant `text` chunks verbatim (no boundary heuristic)", () => {
// PAPA-349 review feedback: the turn-boundary helper is scoped to the
// reasoning stream only. Final assistant text is stored unmodified so
// user-visible responses cannot be reshaped by the heuristic.
const parsed = parseGrokJsonl([
JSON.stringify({ type: "text", data: "Done." }),
JSON.stringify({ type: "text", data: "Next" }),
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }),
].join("\n"));
expect(parsed.summary).toBe("Done.Next");
});
});
describe("isGrokUnknownSessionError", () => {
it("detects stale resume failures", () => {
expect(isGrokUnknownSessionError("", "session not found")).toBe(true);
expect(isGrokUnknownSessionError("", "everything fine")).toBe(false);
});
});
@@ -0,0 +1,89 @@
import { asString, parseJson, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { applyTurnBoundary, createTurnBoundaryState } from "../shared/turn-boundary.js";
export interface ParsedGrokJsonl {
sessionId: string | null;
summary: string;
thought: string;
errorMessage: string | null;
stopReason: string | null;
requestId: string | null;
}
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = parseObject(value);
const message =
asString(rec.message, "").trim() ||
asString(rec.error, "").trim() ||
asString(rec.detail, "").trim() ||
asString(rec.code, "").trim();
if (message) return message;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
export function parseGrokJsonl(stdout: string): ParsedGrokJsonl {
let sessionId: string | null = null;
let stopReason: string | null = null;
let requestId: string | null = null;
let errorMessage: string | null = null;
const thoughtParts: string[] = [];
const textParts: string[] = [];
const thoughtBoundary = createTurnBoundaryState();
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const event = parseJson(line);
if (!event) continue;
const type = asString(event.type, "").trim();
if (type === "thought") {
const text = asString(event.data, "");
if (text) thoughtParts.push(applyTurnBoundary(thoughtBoundary, text));
continue;
}
if (type === "text") {
const text = asString(event.data, "");
if (text) textParts.push(text);
continue;
}
if (type === "end") {
sessionId = asString(event.sessionId, "").trim() || sessionId;
stopReason = asString(event.stopReason, "").trim() || stopReason;
requestId = asString(event.requestId, "").trim() || requestId;
continue;
}
if (type === "error") {
const text = errorText(event.error ?? event.message ?? event.detail ?? event.data).trim();
if (text) errorMessage = text;
}
}
return {
sessionId,
summary: textParts.join("").trim(),
thought: thoughtParts.join("").trim(),
errorMessage,
stopReason,
requestId,
};
}
export function isGrokUnknownSessionError(stdout: string, stderr: string): boolean {
const haystack = `${stdout}\n${stderr}`
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.join("\n");
return /unknown\s+session|session(?:\s+.*)?\s+not\s+found|resume\s+.*\s+not\s+found|invalid\s+session/i.test(haystack);
}
@@ -0,0 +1,80 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import type {
AdapterSkillContext,
AdapterSkillEntry,
AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils";
import {
readPaperclipRuntimeSkillEntries,
resolvePaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
async function buildGrokSkillSnapshot(
config: Record<string, unknown>,
): Promise<AdapterSkillSnapshot> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
const desiredSkills = resolvePaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const entries: AdapterSkillEntry[] = availableEntries.map((entry) => ({
key: entry.key,
runtimeName: entry.runtimeName,
desired: desiredSet.has(entry.key),
managed: true,
state: desiredSet.has(entry.key) ? "configured" : "available",
origin: entry.required ? "paperclip_required" : "company_managed",
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desiredSet.has(entry.key)
? "Will be copied into `.claude/skills` in the execution workspace on the next run."
: null,
required: Boolean(entry.required),
requiredReason: entry.requiredReason ?? null,
}));
const warnings: string[] = [];
for (const desiredSkill of desiredSkills) {
if (availableByKey.has(desiredSkill)) continue;
warnings.push(`Desired skill "${desiredSkill}" is not available from the Paperclip skills directory.`);
entries.push({
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
sourcePath: null,
targetPath: null,
detail: "Paperclip cannot find this skill in the local runtime skills directory.",
});
}
entries.sort((left, right) => left.key.localeCompare(right.key));
return {
adapterType: "grok_local",
supported: true,
mode: "ephemeral",
desiredSkills,
entries,
warnings,
};
}
export async function listGrokSkills(ctx: AdapterSkillContext): Promise<AdapterSkillSnapshot> {
return buildGrokSkillSnapshot(ctx.config);
}
export async function syncGrokSkills(
ctx: AdapterSkillContext,
_desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
return buildGrokSkillSnapshot(ctx.config);
}
@@ -0,0 +1,142 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
const ensureDirectoryMock = vi.hoisted(() => vi.fn(async () => {}));
const ensureCommandMock = vi.hoisted(() => vi.fn(async () => {}));
const runProcessMock = vi.hoisted(() => vi.fn());
vi.mock("@paperclipai/adapter-utils/execution-target", () => ({
describeAdapterExecutionTarget: () => "local",
ensureAdapterExecutionTargetCommandResolvable: ensureCommandMock,
ensureAdapterExecutionTargetDirectory: ensureDirectoryMock,
resolveAdapterExecutionTargetCwd: (_target: unknown, configuredCwd: string, fallbackCwd: string) =>
configuredCwd || fallbackCwd,
runAdapterExecutionTargetProcess: runProcessMock,
}));
import { parseGrokModelsOutput, testEnvironment } from "./test.js";
describe("parseGrokModelsOutput", () => {
it("extracts auth state and models from `grok models` output", () => {
expect(parseGrokModelsOutput([
"You are logged in with grok.com.",
"",
"Default model: grok-build",
"",
"Available models:",
" * grok-build (default)",
" * grok-code",
].join("\n"))).toEqual({
authenticated: true,
defaultModel: "grok-build",
models: ["grok-build", "grok-code"],
});
});
});
describe("grok_local testEnvironment", () => {
beforeEach(() => {
ensureDirectoryMock.mockClear();
ensureCommandMock.mockClear();
runProcessMock.mockReset();
});
it("reports a healthy authenticated host with a working hello probe", async () => {
runProcessMock
.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
"You are logged in with grok.com.",
"",
"Default model: grok-build",
"",
"Available models:",
" * grok-build (default)",
].join("\n"),
stderr: "",
})
.mockResolvedValueOnce({
exitCode: 0,
signal: null,
timedOut: false,
stdout: [
JSON.stringify({ type: "text", data: "hello" }),
JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1", requestId: "req-1" }),
].join("\n"),
stderr: "",
});
const result = await testEnvironment({
companyId: "company-1",
adapterType: "grok_local",
config: {
command: "grok",
cwd: "/tmp/project",
model: "grok-build",
},
});
expect(result.status).toBe("pass");
expect(result.checks.map((check: { code: string }) => check.code)).toEqual(
expect.arrayContaining([
"grok_command_resolvable",
"grok_models_probe_passed",
"grok_model_configured",
"grok_hello_probe_passed",
]),
);
expect(runProcessMock).toHaveBeenNthCalledWith(
2,
expect.any(String),
null,
"grok",
expect.arrayContaining([
"--output-format",
"streaming-json",
"--always-approve",
"--permission-mode",
"dontAsk",
"--disable-web-search",
"--single",
"Respond with exactly hello.",
]),
expect.any(Object),
);
});
it("downgrades auth failures to warnings", async () => {
runProcessMock
.mockResolvedValueOnce({
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "Not logged in. Run `grok login`.",
})
.mockResolvedValueOnce({
exitCode: 1,
signal: null,
timedOut: false,
stdout: "",
stderr: "Not logged in. Run `grok login`.",
});
const result = await testEnvironment({
companyId: "company-1",
adapterType: "grok_local",
config: {
command: "grok",
cwd: "/tmp/project",
},
});
expect(result.status).toBe("warn");
expect(result.checks.map((check: { code: string }) => check.code)).toEqual(
expect.arrayContaining([
"grok_auth_required",
"grok_hello_probe_auth_required",
]),
);
});
});
@@ -0,0 +1,313 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import {
asNumber,
asString,
asStringArray,
ensurePathInEnv,
parseObject,
} from "@paperclipai/adapter-utils/server-utils";
import {
describeAdapterExecutionTarget,
ensureAdapterExecutionTargetCommandResolvable,
ensureAdapterExecutionTargetDirectory,
resolveAdapterExecutionTargetCwd,
runAdapterExecutionTargetProcess,
} from "@paperclipai/adapter-utils/execution-target";
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
import { parseGrokJsonl } from "./parse.js";
export interface GrokModelsProbe {
authenticated: boolean;
defaultModel: string | null;
models: string[];
}
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((check) => check.level === "error")) return "fail";
if (checks.some((check) => check.level === "warn")) return "warn";
return "pass";
}
function firstNonEmptyLine(text: string): string {
return (
text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? ""
);
}
function summarizeProbeDetail(stdout: string, stderr: string, parsedError: string | null): string | null {
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
if (!raw) return null;
const clean = raw.replace(/\s+/g, " ").trim();
const max = 240;
return clean.length > max ? `${clean.slice(0, max - 3)}...` : clean;
}
function normalizeEnv(input: unknown): Record<string, string> {
if (typeof input !== "object" || input === null || Array.isArray(input)) return {};
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (typeof value === "string") env[key] = value;
}
return env;
}
const GROK_AUTH_REQUIRED_RE =
/(?:not\s+logged\s+in|login\s+required|run\s+`?grok\s+login`?|authentication\s+required|unauthorized|invalid\s+credentials)/i;
export function parseGrokModelsOutput(stdout: string): GrokModelsProbe {
const trimmedLines = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const models: string[] = [];
let defaultModel: string | null = null;
let authenticated = false;
let inModelsBlock = false;
for (const line of trimmedLines) {
if (/logged in/i.test(line)) authenticated = true;
const defaultMatch = /^Default model:\s*(.+)$/i.exec(line);
if (defaultMatch?.[1]) {
defaultModel = defaultMatch[1].trim();
continue;
}
if (/^Available models:/i.test(line)) {
inModelsBlock = true;
continue;
}
if (!inModelsBlock) continue;
const bulletMatch = /^[*-]\s*(.+?)(?:\s+\(default\))?$/.exec(line);
if (bulletMatch?.[1]) {
models.push(bulletMatch[1].trim());
continue;
}
if (line.length > 0) {
models.push(line.replace(/\s+\(default\)$/, "").trim());
}
}
return {
authenticated,
defaultModel,
models: Array.from(new Set(models.filter(Boolean))),
};
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const command = asString(config.command, "grok");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
: null;
const runId = `grok-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
if (targetLabel) {
checks.push({
code: "grok_environment_target",
level: "info",
message: `Probing inside environment: ${targetLabel}`,
});
}
try {
await ensureAdapterExecutionTargetDirectory(runId, target, cwd, {
cwd,
env: {},
createIfMissing: true,
});
checks.push({
code: "grok_cwd_valid",
level: "info",
message: `Working directory is valid: ${cwd}`,
});
} catch (err) {
checks.push({
code: "grok_cwd_invalid",
level: "error",
message: err instanceof Error ? err.message : "Invalid working directory",
detail: cwd,
});
}
const env = normalizeEnv(config.env);
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
try {
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
checks.push({
code: "grok_command_resolvable",
level: "info",
message: `Command is executable: ${command}`,
});
} catch (err) {
checks.push({
code: "grok_command_unresolvable",
level: "error",
message: err instanceof Error ? err.message : "Command is not executable",
detail: command,
});
}
const canRunProbe =
checks.every((check) => check.code !== "grok_cwd_invalid" && check.code !== "grok_command_unresolvable");
const configuredModel = asString(config.model, DEFAULT_GROK_LOCAL_MODEL).trim();
if (canRunProbe) {
const modelsProbe = await runAdapterExecutionTargetProcess(
runId,
target,
command,
["models"],
{
cwd,
env,
timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)),
graceSec: 5,
onLog: async () => {},
},
);
const probeOutput = `${modelsProbe.stdout}\n${modelsProbe.stderr}`;
const parsedModels = parseGrokModelsOutput(modelsProbe.stdout);
const authRequired = GROK_AUTH_REQUIRED_RE.test(probeOutput);
if (modelsProbe.timedOut) {
checks.push({
code: "grok_models_probe_timed_out",
level: "warn",
message: "`grok models` timed out.",
hint: "Retry the probe. If this persists, run `grok models` manually from the target environment.",
});
} else if ((modelsProbe.exitCode ?? 1) !== 0) {
checks.push({
code: authRequired ? "grok_auth_required" : "grok_models_probe_failed",
level: authRequired ? "warn" : "error",
message: authRequired
? "Grok CLI is not authenticated."
: "`grok models` failed.",
detail: summarizeProbeDetail(modelsProbe.stdout, modelsProbe.stderr, null),
hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined,
});
} else {
checks.push({
code: "grok_models_probe_passed",
level: "info",
message: parsedModels.authenticated
? "Grok CLI authentication is configured."
: "`grok models` completed.",
detail: parsedModels.defaultModel ? `Default model: ${parsedModels.defaultModel}` : undefined,
});
if (parsedModels.models.length > 0) {
checks.push({
code: "grok_models_discovered",
level: "info",
message: `Discovered ${parsedModels.models.length} Grok model(s).`,
});
} else {
checks.push({
code: "grok_models_empty",
level: "warn",
message: "Grok returned no available models.",
hint: "Run `grok models` manually and verify the account has access to a model.",
});
}
if (configuredModel) {
checks.push({
code: parsedModels.models.includes(configuredModel) ? "grok_model_configured" : "grok_model_not_found",
level: parsedModels.models.includes(configuredModel) ? "info" : "warn",
message: parsedModels.models.includes(configuredModel)
? `Configured model: ${configuredModel}`
: `Configured model "${configuredModel}" not found in available models.`,
hint: parsedModels.models.includes(configuredModel)
? undefined
: "Run `grok models` and choose an available model id.",
});
}
}
}
if (canRunProbe) {
const probeArgs = [
"--output-format",
"streaming-json",
"--always-approve",
"--permission-mode",
"dontAsk",
"--disable-web-search",
];
if (configuredModel && configuredModel !== DEFAULT_GROK_LOCAL_MODEL) {
probeArgs.push("--model", configuredModel);
}
probeArgs.push("--single", "Respond with exactly hello.");
const helloProbe = await runAdapterExecutionTargetProcess(
runId,
target,
command,
probeArgs,
{
cwd,
env,
timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, 45)),
graceSec: 5,
onLog: async () => {},
},
);
const parsed = parseGrokJsonl(helloProbe.stdout);
const detail = summarizeProbeDetail(helloProbe.stdout, helloProbe.stderr, parsed.errorMessage);
const authRequired = GROK_AUTH_REQUIRED_RE.test(`${helloProbe.stdout}\n${helloProbe.stderr}`);
if (helloProbe.timedOut) {
checks.push({
code: "grok_hello_probe_timed_out",
level: "warn",
message: "Grok hello probe timed out.",
hint: "Retry the probe. If this persists, verify Grok can run a simple `--single` prompt manually.",
});
} else if ((helloProbe.exitCode ?? 1) !== 0) {
checks.push({
code: authRequired ? "grok_hello_probe_auth_required" : "grok_hello_probe_failed",
level: authRequired ? "warn" : "error",
message: authRequired
? "Grok CLI could not answer the hello probe because authentication is missing."
: "Grok hello probe failed.",
...(detail ? { detail } : {}),
hint: authRequired ? "Run `grok login` on the target host, then retry." : undefined,
});
} else if (/\bhello\b/i.test(parsed.summary)) {
checks.push({
code: "grok_hello_probe_passed",
level: "info",
message: "Grok hello probe succeeded.",
});
} else {
checks.push({
code: "grok_hello_probe_unexpected_output",
level: "warn",
message: "Grok hello probe succeeded but returned unexpected output.",
...(detail ? { detail } : {}),
});
}
}
return {
adapterType: "grok_local",
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { applyTurnBoundary, createTurnBoundaryState } from "./turn-boundary.js";
function run(chunks: string[]): string {
const state = createTurnBoundaryState();
return chunks.map((chunk) => applyTurnBoundary(state, chunk)).join("");
}
describe("applyTurnBoundary", () => {
it("inserts a newline when a closing backtick is followed by a new capitalized turn", () => {
expect(run(["The user uses `", "ls", "`", "The", " `", "ls", "`", " returned"]))
.toBe("The user uses `ls`\nThe `ls` returned");
});
it("inserts a newline after sentence-ending punctuation glued to a capitalized word", () => {
expect(run(["returned", ":", "Confirmed", ":", " 4 files"]))
.toBe("returned:\nConfirmed: 4 files");
});
it("does not break apart backtick-wrapped CamelCase identifiers within a turn", () => {
expect(run(["render `", "React", "` then "]))
.toBe("render `React` then ");
});
it("leaves natural token streams with proper whitespace alone", () => {
expect(run(["The", " user", " wants", " me", " to", ":\n", "1", ".", " List"]))
.toBe("The user wants me to:\n1. List");
});
it("does not insert a separator when the next chunk starts with whitespace", () => {
expect(run(["function", ".", " They"]))
.toBe("function. They");
});
it("does not insert a separator when the next chunk starts lowercase", () => {
expect(run(["`", "ls", "`"]))
.toBe("`ls`");
});
it("does not insert a separator when the next chunk is a single character", () => {
expect(run([":", "A"]))
.toBe(":A");
});
it("does not insert a separator after a self-contained backtick span in a single chunk", () => {
// Greptile review: a chunk like "`ls`" is a balanced span; the following
// capitalized word should be treated as a continuation, not a new turn.
expect(run(["`ls`", "Then"]))
.toBe("`ls`Then");
});
});
@@ -0,0 +1,54 @@
// Grok's `--output-format streaming-json` mode emits `thought` and `text` events
// token-by-token. Between reasoning turns (around tool calls) it drops the `\n`
// separator that the non-streaming `--output-format json` mode includes in the
// aggregated `thought` field. This helper inserts a single `\n` when a new chunk
// would otherwise glue two turns together (e.g. ``"`"`` then `"The"` => `` `The``).
export interface TurnBoundaryState {
lastChunk: string;
backtickParity: 0 | 1;
}
export function createTurnBoundaryState(): TurnBoundaryState {
return { lastChunk: "", backtickParity: 0 };
}
function countBackticks(text: string): number {
let count = 0;
for (const ch of text) if (ch === "`") count += 1;
return count;
}
function endsWithSentenceClose(ch: string): boolean {
return ch === "." || ch === "?" || ch === "!" || ch === ":" || ch === ";";
}
export function applyTurnBoundary(state: TurnBoundaryState, incoming: string): string {
if (!incoming) return incoming;
let output = incoming;
const prev = state.lastChunk;
if (
prev &&
!/\s$/.test(prev) &&
!/^\s/.test(incoming) &&
/^[A-Z]/.test(incoming) &&
incoming.length >= 2
) {
const lastChar = prev[prev.length - 1]!;
// Narrow the backtick trigger to a lone closing-backtick chunk (e.g. the
// stream "...`", "ls", "`" then "The"). A compound chunk like "`ls`" is a
// self-contained span and the following capitalized word is a continuation,
// not a new turn.
const closingLoneBacktick =
prev === "`" && state.backtickParity === 0;
const looksLikeNewTurn = endsWithSentenceClose(lastChar) || closingLoneBacktick;
if (looksLikeNewTurn) {
output = `\n${incoming}`;
}
}
state.lastChunk = incoming;
state.backtickParity = ((state.backtickParity + countBackticks(incoming)) % 2) as 0 | 1;
return output;
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { buildGrokLocalConfig } from "./build-config.js";
describe("buildGrokLocalConfig", () => {
it("maps create-form values into adapter config", () => {
expect(buildGrokLocalConfig({
cwd: "/tmp/project",
instructionsFilePath: "/tmp/AGENTS.md",
model: "grok-build",
thinkingEffort: "high",
envVars: "XAI_API_KEY=secret\n",
extraArgs: "--check, --verbatim",
} as never)).toEqual({
cwd: "/tmp/project",
instructionsFilePath: "/tmp/AGENTS.md",
model: "grok-build",
timeoutSec: 0,
graceSec: 20,
reasoningEffort: "high",
env: {
XAI_API_KEY: { type: "plain", value: "secret" },
},
extraArgs: ["--check", "--verbatim"],
});
});
});
@@ -0,0 +1,74 @@
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
import { DEFAULT_GROK_LOCAL_MODEL } from "../index.js";
function parseCommaArgs(value: string): string[] {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseEnvVars(text: string): Record<string, string> {
const env: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eq = trimmed.indexOf("=");
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1);
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
env[key] = value;
}
return env;
}
function parseEnvBindings(bindings: unknown): Record<string, unknown> {
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
const env: Record<string, unknown> = {};
for (const [key, raw] of Object.entries(bindings)) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
if (typeof raw === "string") {
env[key] = { type: "plain", value: raw };
continue;
}
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
const rec = raw as Record<string, unknown>;
if (rec.type === "plain" && typeof rec.value === "string") {
env[key] = { type: "plain", value: rec.value };
continue;
}
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
env[key] = {
type: "secret_ref",
secretId: rec.secretId,
...(typeof rec.version === "number" || rec.version === "latest"
? { version: rec.version }
: {}),
};
}
}
return env;
}
export function buildGrokLocalConfig(v: CreateConfigValues): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
ac.model = v.model || DEFAULT_GROK_LOCAL_MODEL;
ac.timeoutSec = 0;
ac.graceSec = 20;
if (v.thinkingEffort) ac.reasoningEffort = v.thinkingEffort;
const env = parseEnvBindings(v.envBindings);
const legacy = parseEnvVars(v.envVars);
for (const [key, value] of Object.entries(legacy)) {
if (!Object.prototype.hasOwnProperty.call(env, key)) {
env[key] = { type: "plain", value };
}
}
if (Object.keys(env).length > 0) ac.env = env;
if (v.command) ac.command = v.command;
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
return ac;
}
@@ -0,0 +1,2 @@
export { parseGrokStdoutLine, createGrokStdoutParser } from "./parse-stdout.js";
export { buildGrokLocalConfig } from "./build-config.js";
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { createGrokStdoutParser, parseGrokStdoutLine } from "./parse-stdout.js";
describe("parseGrokStdoutLine", () => {
const ts = "2026-05-15T00:00:00.000Z";
it("maps thought/text/end events into transcript entries", () => {
expect(parseGrokStdoutLine(JSON.stringify({ type: "thought", data: "Plan first." }), ts)).toEqual([
{ kind: "thinking", ts, text: "Plan first.", delta: true },
]);
expect(parseGrokStdoutLine(JSON.stringify({ type: "text", data: "hello" }), ts)).toEqual([
{ kind: "assistant", ts, text: "hello", delta: true },
]);
expect(parseGrokStdoutLine(JSON.stringify({ type: "end", stopReason: "EndTurn", sessionId: "sess-1" }), ts)).toEqual([
{ kind: "system", ts, text: "stop_reason=EndTurn session=sess-1" },
]);
});
it("surfaces structured Grok error payload text", () => {
expect(parseGrokStdoutLine(JSON.stringify({
type: "error",
error: { message: "Authentication required" },
}), ts)).toEqual([
{ kind: "stderr", ts, text: "Authentication required" },
]);
});
});
describe("createGrokStdoutParser", () => {
const ts = "2026-05-15T00:00:00.000Z";
function thoughtTexts(chunks: string[]): string {
const parser = createGrokStdoutParser();
return chunks
.map((data) => parser.parseLine(JSON.stringify({ type: "thought", data }), ts))
.flat()
.map((entry) => entry.kind === "thinking" ? entry.text : "")
.join("");
}
it("inserts a newline between reasoning turns that grok streaming-json glues together", () => {
// Reproduces PAPA-349: token stream "...using `ls`" then a new turn "The `ls` command returned"
expect(thoughtTexts(["The user uses `", "ls", "`", "The", " `", "ls", "`", " returned"]))
.toBe("The user uses `ls`\nThe `ls` returned");
});
it("inserts a newline when a turn ends with a colon and the next turn starts capitalized", () => {
expect(thoughtTexts(["returned", ":", "Confirmed", ":", " 4 files"]))
.toBe("returned:\nConfirmed: 4 files");
});
it("resets state between independent transcript builds", () => {
const parser = createGrokStdoutParser();
parser.parseLine(JSON.stringify({ type: "thought", data: "first:" }), ts);
parser.reset();
expect(parser.parseLine(JSON.stringify({ type: "thought", data: "Second" }), ts)).toEqual([
{ kind: "thinking", ts, text: "Second", delta: true },
]);
});
it("does not modify assistant `text` chunks", () => {
// PAPA-349 review feedback: keep final assistant text streaming verbatim;
// the boundary heuristic is scoped to reasoning.
const parser = createGrokStdoutParser();
parser.parseLine(JSON.stringify({ type: "text", data: "Done." }), ts);
expect(parser.parseLine(JSON.stringify({ type: "text", data: "Next" }), ts)).toEqual([
{ kind: "assistant", ts, text: "Next", delta: true },
]);
});
});
@@ -0,0 +1,87 @@
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import { applyTurnBoundary, createTurnBoundaryState, type TurnBoundaryState } from "../shared/turn-boundary.js";
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asString(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function extractErrorText(value: unknown): string {
if (typeof value === "string") return value;
const record = asRecord(value);
if (!record) return "";
return asString(record.message) || asString(record.detail) || asString(record.code);
}
function parseLineInternal(
line: string,
ts: string,
thoughtBoundary: TurnBoundaryState,
): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
return [{ kind: "stdout", ts, text: line }];
}
const type = asString(parsed.type).trim();
if (type === "thought") {
const text = asString(parsed.data);
if (!text) return [];
return [{ kind: "thinking", ts, text: applyTurnBoundary(thoughtBoundary, text), delta: true }];
}
if (type === "text") {
const text = asString(parsed.data);
if (!text) return [];
return [{ kind: "assistant", ts, text, delta: true }];
}
if (type === "error") {
const text = asString(parsed.data) || asString(parsed.message) || extractErrorText(parsed.error);
return text ? [{ kind: "stderr", ts, text }] : [{ kind: "stderr", ts, text: "Grok error" }];
}
if (type === "end") {
const stopReason = asString(parsed.stopReason).trim();
const sessionId = asString(parsed.sessionId).trim();
const parts = [
stopReason ? `stop_reason=${stopReason}` : "",
sessionId ? `session=${sessionId}` : "",
].filter(Boolean);
return [{ kind: "system", ts, text: parts.join(" ") || "run completed" }];
}
return [{ kind: "system", ts, text: `event: ${type || "unknown"}` }];
}
export function createGrokStdoutParser() {
let thoughtBoundary = createTurnBoundaryState();
return {
parseLine(line: string, ts: string): TranscriptEntry[] {
return parseLineInternal(line, ts, thoughtBoundary);
},
reset() {
thoughtBoundary = createTurnBoundaryState();
},
};
}
// Stateless fallback for callers that haven't migrated to the stateful factory.
// Without state, consecutive thought chunks at reasoning-turn boundaries can
// still appear merged; prefer createGrokStdoutParser for live transcripts.
export function parseGrokStdoutLine(line: string, ts: string): TranscriptEntry[] {
return parseLineInternal(line, ts, createTurnBoundaryState());
}
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
@@ -9,6 +9,7 @@ import type {
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
import {
asBoolean,
asNumber,
asString,
asStringArray,
parseObject,
@@ -72,6 +73,7 @@ export async function testEnvironment(
const command = asString(config.command, "opencode");
const target = ctx.executionTarget ?? null;
const targetIsRemote = target?.kind === "remote";
const targetIsSandbox = target?.kind === "remote" && target.transport === "sandbox";
const cwd = resolveAdapterExecutionTargetCwd(target, asString(config.cwd, ""), process.cwd());
const targetLabel = targetIsRemote
? ctx.environmentName ?? describeAdapterExecutionTarget(target)
@@ -334,6 +336,14 @@ export async function testEnvironment(
if (variant) args.push("--variant", variant);
if (extraArgs.length > 0) args.push(...extraArgs);
// Sandbox bridges still add cold-start and transport overhead, but the
// standard-2 Cloudflare tier now probes quickly enough that 90s keeps
// useful headroom without letting slow hangs linger.
const helloProbeTimeoutSec = Math.max(
1,
asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 60),
);
try {
const probe = await runAdapterExecutionTargetProcess(
runId,
@@ -343,7 +353,7 @@ export async function testEnvironment(
{
cwd: runtimeCwd,
env: runtimeEnv,
timeoutSec: 60,
timeoutSec: helloProbeTimeoutSec,
graceSec: 5,
stdin: "Respond with hello.",
onLog: async () => {},
+1 -1
View File
@@ -3,7 +3,7 @@ import type { AdapterModelProfileDefinition } from "@paperclipai/adapter-utils";
export const type = "pi_local";
export const label = "Pi (local)";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @mariozechner/pi-coding-agent";
export const SANDBOX_INSTALL_COMMAND = "npm install -g @earendil-works/pi-coding-agent@0.74.0";
export const models: Array<{ id: string; label: string }> = [];
+101
View File
@@ -309,6 +309,107 @@ describeEmbeddedPostgres("runDatabaseBackup", () => {
60_000,
);
it(
"preserves composite foreign key column order without duplicate referenced columns",
async () => {
const sourceConnectionString = await createTempDatabase();
const restoreConnectionString = await createSiblingDatabase(
sourceConnectionString,
"paperclip_composite_fk_restore_target",
);
const backupDir = createTempDir("paperclip-db-composite-fk-backup-");
const sourceSql = postgres(sourceConnectionString, { max: 1, onnotice: () => {} });
const restoreSql = postgres(restoreConnectionString, { max: 1, onnotice: () => {} });
try {
await sourceSql.unsafe(`
CREATE SCHEMA "plugin_composite_fk";
CREATE TABLE "plugin_composite_fk"."content_cases" (
"id" uuid PRIMARY KEY,
"company_id" uuid NOT NULL,
"title" text NOT NULL,
CONSTRAINT "content_cases_company_case_unique" UNIQUE ("company_id", "id")
);
CREATE TABLE "plugin_composite_fk"."content_case_signals" (
"company_id" uuid NOT NULL,
"case_id" uuid NOT NULL,
"signal" text NOT NULL,
"scopes" text[] NOT NULL,
"warnings" jsonb DEFAULT '[]'::jsonb NOT NULL,
CONSTRAINT "content_case_signals_company_case"
FOREIGN KEY ("company_id", "case_id")
REFERENCES "plugin_composite_fk"."content_cases" ("company_id", "id")
ON DELETE CASCADE
);
INSERT INTO "plugin_composite_fk"."content_cases" ("company_id", "id", "title")
VALUES (
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222',
'case'
);
INSERT INTO "plugin_composite_fk"."content_case_signals" ("company_id", "case_id", "signal", "scopes", "warnings")
VALUES (
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222',
'signal',
ARRAY['upstream_import:preview', 'scope with space', 'quoted "scope"', 'NULL', 'null'],
jsonb_build_array('json warning', jsonb_build_object('code', 'quoted "value"'))
);
`);
const result = await runDatabaseBackup({
connectionString: sourceConnectionString,
backupDir,
retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
filenamePrefix: "paperclip-composite-fk-test",
backupEngine: "javascript",
});
await runDatabaseRestore({
connectionString: restoreConnectionString,
backupFile: result.backupFile,
});
const rows = await restoreSql.unsafe<{
signal: string;
title: string;
scopes: string[];
warnings: Array<string | { code: string }>;
}[]>(`
SELECT s."signal", c."title", s."scopes", s."warnings"
FROM "plugin_composite_fk"."content_case_signals" s
JOIN "plugin_composite_fk"."content_cases" c
ON c."company_id" = s."company_id"
AND c."id" = s."case_id"
`);
expect(rows).toEqual([
{
signal: "signal",
title: "case",
scopes: ["upstream_import:preview", "scope with space", 'quoted "scope"', "NULL", "null"],
warnings: ["json warning", { code: 'quoted "value"' }],
},
]);
await expect(
restoreSql.unsafe(`
INSERT INTO "plugin_composite_fk"."content_case_signals" ("company_id", "case_id", "signal", "scopes")
VALUES (
'11111111-1111-4111-8111-111111111111',
'33333333-3333-4333-8333-333333333333',
'orphan',
ARRAY[]::text[]
)
`),
).rejects.toThrow();
} finally {
await sourceSql.end();
await restoreSql.end();
}
},
60_000,
);
it(
"restores legacy public-only backups without migration history",
async () => {
+82 -54
View File
@@ -249,12 +249,39 @@ function hasBackupTransforms(opts: RunDatabaseBackupOptions): boolean {
Object.keys(opts.nullifyColumns ?? {}).length > 0;
}
function formatSqlValue(rawValue: unknown, columnName: string | undefined, nullifiedColumns: Set<string>): string {
function formatPostgresArrayElement(value: unknown): string {
if (value === null || value === undefined) return "NULL";
if (Array.isArray(value)) return formatPostgresArrayLiteral(value);
const raw = value instanceof Date
? value.toISOString()
: typeof value === "object"
? JSON.stringify(value)
: String(value);
if (raw.length === 0 || /^null$/i.test(raw) || /[{}\s,"\\]/.test(raw)) {
return `"${raw.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
}
return raw;
}
function formatPostgresArrayLiteral(value: unknown[]): string {
return `{${value.map(formatPostgresArrayElement).join(",")}}`;
}
function formatSqlValue(
rawValue: unknown,
columnName: string | undefined,
nullifiedColumns: Set<string>,
dataType?: string,
): string {
const val = columnName && nullifiedColumns.has(columnName) ? null : rawValue;
if (val === null || val === undefined) return "NULL";
if (dataType === "json" || dataType === "jsonb") {
return formatSqlLiteral(JSON.stringify(val));
}
if (typeof val === "boolean") return val ? "true" : "false";
if (typeof val === "number") return String(val);
if (val instanceof Date) return formatSqlLiteral(val.toISOString());
if (Array.isArray(val)) return formatSqlLiteral(formatPostgresArrayLiteral(val));
if (typeof val === "object") return formatSqlLiteral(JSON.stringify(val));
return formatSqlLiteral(String(val));
}
@@ -745,58 +772,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
emit("");
}
// Foreign keys (after all tables created)
const allForeignKeys = await sql<{
constraint_name: string;
source_schema: string;
source_table: string;
source_columns: string[];
target_schema: string;
target_table: string;
target_columns: string[];
update_rule: string;
delete_rule: string;
}[]>`
SELECT
c.conname AS constraint_name,
srcn.nspname AS source_schema,
src.relname AS source_table,
array_agg(sa.attname ORDER BY array_position(c.conkey, sa.attnum)) AS source_columns,
tgtn.nspname AS target_schema,
tgt.relname AS target_table,
array_agg(ta.attname ORDER BY array_position(c.confkey, ta.attnum)) AS target_columns,
CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule,
CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
FROM pg_constraint c
JOIN pg_class src ON src.oid = c.conrelid
JOIN pg_namespace srcn ON srcn.oid = src.relnamespace
JOIN pg_class tgt ON tgt.oid = c.confrelid
JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = ANY(c.conkey)
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = ANY(c.confkey)
WHERE c.contype = 'f'
AND ${sql.unsafe(nonSystemSchemaPredicate("srcn.nspname"))}
GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype
ORDER BY srcn.nspname, src.relname, c.conname
`;
const fks = allForeignKeys.filter(
(fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table))
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
);
if (fks.length > 0) {
emit("-- Foreign keys");
for (const fk of fks) {
const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", ");
const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", ");
emitStatement(
`ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
);
}
emit("");
}
// Unique constraints
// Unique constraints must exist before foreign keys that reference them.
const allUniqueConstraints = await sql<{
constraint_name: string;
schema_name: string;
@@ -827,6 +803,58 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
emit("");
}
// Foreign keys (after all tables and referenced unique constraints are created)
const allForeignKeys = await sql<{
constraint_name: string;
source_schema: string;
source_table: string;
source_columns: string[];
target_schema: string;
target_table: string;
target_columns: string[];
update_rule: string;
delete_rule: string;
}[]>`
SELECT
c.conname AS constraint_name,
srcn.nspname AS source_schema,
src.relname AS source_table,
array_agg(sa.attname ORDER BY key_columns.ordinal_position) AS source_columns,
tgtn.nspname AS target_schema,
tgt.relname AS target_table,
array_agg(ta.attname ORDER BY key_columns.ordinal_position) AS target_columns,
CASE c.confupdtype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS update_rule,
CASE c.confdeltype WHEN 'a' THEN 'NO ACTION' WHEN 'r' THEN 'RESTRICT' WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'd' THEN 'SET DEFAULT' END AS delete_rule
FROM pg_constraint c
JOIN pg_class src ON src.oid = c.conrelid
JOIN pg_namespace srcn ON srcn.oid = src.relnamespace
JOIN pg_class tgt ON tgt.oid = c.confrelid
JOIN pg_namespace tgtn ON tgtn.oid = tgt.relnamespace
JOIN LATERAL unnest(c.conkey, c.confkey) WITH ORDINALITY AS key_columns(source_attnum, target_attnum, ordinal_position) ON true
JOIN pg_attribute sa ON sa.attrelid = src.oid AND sa.attnum = key_columns.source_attnum
JOIN pg_attribute ta ON ta.attrelid = tgt.oid AND ta.attnum = key_columns.target_attnum
WHERE c.contype = 'f'
AND ${sql.unsafe(nonSystemSchemaPredicate("srcn.nspname"))}
GROUP BY c.conname, srcn.nspname, src.relname, tgtn.nspname, tgt.relname, c.confupdtype, c.confdeltype
ORDER BY srcn.nspname, src.relname, c.conname
`;
const fks = allForeignKeys.filter(
(fk) => includedTableNames.has(tableKey(fk.source_schema, fk.source_table))
&& includedTableNames.has(tableKey(fk.target_schema, fk.target_table)),
);
if (fks.length > 0) {
emit("-- Foreign keys");
for (const fk of fks) {
const srcCols = fk.source_columns.map((c) => `"${c}"`).join(", ");
const tgtCols = fk.target_columns.map((c) => `"${c}"`).join(", ");
emitStatement(
`ALTER TABLE ${quoteQualifiedName(fk.source_schema, fk.source_table)} ADD CONSTRAINT "${fk.constraint_name}" FOREIGN KEY (${srcCols}) REFERENCES ${quoteQualifiedName(fk.target_schema, fk.target_table)} (${tgtCols}) ON UPDATE ${fk.update_rule} ON DELETE ${fk.delete_rule};`,
);
}
emit("");
}
// Indexes (non-primary, non-unique-constraint)
const allIndexes = await sql<{ schema_name: string; tablename: string; indexdef: string }[]>`
SELECT schemaname AS schema_name, tablename, indexdef
@@ -895,7 +923,7 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise
for await (const rows of rowCursor) {
for (const row of rows) {
const values = row.map((rawValue, index) =>
formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns),
formatSqlValue(rawValue, cols[index]?.column_name, nullifiedColumns, cols[index]?.data_type),
);
emitStatement(`INSERT INTO ${qualifiedTableName} (${colNames}) VALUES (${values.join(", ")});`);
}
@@ -0,0 +1,43 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { ensureLinuxSharedLibraryAliases } from "./embedded-postgres-native.js";
describe("embedded Postgres native runtime", () => {
const tempDirs: string[] = [];
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it.runIf(process.platform !== "win32")("creates soname aliases for bundled patch-level shared libraries", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-pg-libs-"));
tempDirs.push(tempDir);
fs.writeFileSync(path.join(tempDir, "libicuuc.so.60.2"), "");
fs.writeFileSync(path.join(tempDir, "libicui18n.so.60.2"), "");
fs.writeFileSync(path.join(tempDir, "README.md"), "");
const created = await ensureLinuxSharedLibraryAliases(tempDir);
expect(created.map((file) => path.basename(file)).sort()).toEqual([
"libicui18n.so.60",
"libicuuc.so.60",
]);
expect(fs.readlinkSync(path.join(tempDir, "libicuuc.so.60"))).toBe("libicuuc.so.60.2");
});
it.runIf(process.platform !== "win32")("is idempotent when aliases already exist", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-embedded-pg-libs-"));
tempDirs.push(tempDir);
fs.writeFileSync(path.join(tempDir, "libicuuc.so.60.2"), "");
await ensureLinuxSharedLibraryAliases(tempDir);
const second = await ensureLinuxSharedLibraryAliases(tempDir);
expect(second).toEqual([]);
expect(fs.readlinkSync(path.join(tempDir, "libicuuc.so.60"))).toBe("libicuuc.so.60.2");
});
});
@@ -0,0 +1,85 @@
import { promises as fs } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
const require = createRequire(import.meta.url);
function resolveNativePackageName(): string | null {
if (process.platform !== "linux") return null;
switch (process.arch) {
case "arm64":
return "linux-arm64";
case "arm":
return "linux-arm";
case "ia32":
return "linux-ia32";
case "ppc64":
return "linux-ppc64";
case "x64":
return "linux-x64";
default:
return null;
}
}
async function pathExists(value: string): Promise<boolean> {
try {
await fs.stat(value);
return true;
} catch {
return false;
}
}
function resolveEmbeddedPostgresPackageRoot(): string | null {
try {
const entry = require.resolve("embedded-postgres");
return path.dirname(path.dirname(entry));
} catch {
return null;
}
}
function prependPathEnv(name: string, value: string): void {
const current = process.env[name] ?? "";
const parts = current.split(path.delimiter).filter(Boolean);
if (parts.includes(value)) return;
process.env[name] = [value, ...parts].join(path.delimiter);
}
export async function ensureLinuxSharedLibraryAliases(libDir: string): Promise<string[]> {
const entries = await fs.readdir(libDir, { withFileTypes: true });
const created: string[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const match = entry.name.match(/^(lib.+\.so\.\d+)\.\d+(?:\.\d+)?$/);
if (!match) continue;
const aliasName = match[1];
const aliasPath = path.join(libDir, aliasName);
try {
await fs.symlink(entry.name, aliasPath);
created.push(aliasPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
throw error;
}
}
return created;
}
export async function prepareEmbeddedPostgresNativeRuntime(): Promise<void> {
const nativePackageName = resolveNativePackageName();
const packageRoot = resolveEmbeddedPostgresPackageRoot();
if (!nativePackageName || !packageRoot) return;
const nativeRoot = path.resolve(packageRoot, "..", "@embedded-postgres", nativePackageName);
const libDir = path.join(nativeRoot, "native", "lib");
if (!(await pathExists(libDir))) return;
prependPathEnv("LD_LIBRARY_PATH", libDir);
await ensureLinuxSharedLibraryAliases(libDir);
}
+4
View File
@@ -30,6 +30,10 @@ export {
createEmbeddedPostgresLogBuffer,
formatEmbeddedPostgresError,
} from "./embedded-postgres-error.js";
export {
ensureLinuxSharedLibraryAliases,
prepareEmbeddedPostgresNativeRuntime,
} from "./embedded-postgres-native.js";
export { issueRelations } from "./schema/issue_relations.js";
export { issueReferenceMentions } from "./schema/issue_reference_mentions.js";
export * from "./schema/index.js";
+2
View File
@@ -3,6 +3,7 @@ import { createServer } from "node:net";
import path from "node:path";
import { ensurePostgresDatabase, getPostgresDataDirectory } from "./client.js";
import { createEmbeddedPostgresLogBuffer, formatEmbeddedPostgresError } from "./embedded-postgres-error.js";
import { prepareEmbeddedPostgresNativeRuntime } from "./embedded-postgres-native.js";
import { resolveDatabaseTarget } from "./runtime-config.js";
type EmbeddedPostgresInstance = {
@@ -92,6 +93,7 @@ async function ensureEmbeddedPostgresConnection(
preferredPort: number,
): Promise<MigrationConnection> {
const EmbeddedPostgres = await loadEmbeddedPostgresCtor();
await prepareEmbeddedPostgresNativeRuntime();
const selectedPort = await findAvailablePort(preferredPort);
const postmasterPidFile = path.resolve(dataDir, "postmaster.pid");
const pgVersionFile = path.resolve(dataDir, "PG_VERSION");
@@ -0,0 +1,8 @@
ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "locked_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "locked_by_agent_id" uuid;--> statement-breakpoint
ALTER TABLE "documents" ADD COLUMN IF NOT EXISTS "locked_by_user_id" text;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'documents_locked_by_agent_id_agents_id_fk') THEN
ALTER TABLE "documents" ADD CONSTRAINT "documents_locked_by_agent_id_agents_id_fk" FOREIGN KEY ("locked_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;
@@ -0,0 +1,8 @@
ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "env" jsonb;--> statement-breakpoint
ALTER TABLE "routine_runs" ADD COLUMN IF NOT EXISTS "routine_revision_id" uuid;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'routine_runs_routine_revision_id_routine_revisions_id_fk') THEN
ALTER TABLE "routine_runs" ADD CONSTRAINT "routine_runs_routine_revision_id_routine_revisions_id_fk" FOREIGN KEY ("routine_revision_id") REFERENCES "public"."routine_revisions"("id") ON DELETE set null ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "routine_runs_revision_idx" ON "routine_runs" USING btree ("routine_revision_id");
@@ -0,0 +1,29 @@
INSERT INTO "principal_permission_grants" (
"company_id",
"principal_type",
"principal_id",
"permission_key",
"scope",
"granted_by_user_id",
"created_at",
"updated_at"
)
SELECT
"company_id",
'user',
"principal_id",
'environments:manage',
NULL,
NULL,
now(),
now()
FROM "company_memberships"
WHERE "principal_type" = 'user'
AND "status" = 'active'
AND "membership_role" IN ('owner', 'admin')
ON CONFLICT (
"company_id",
"principal_type",
"principal_id",
"permission_key"
) DO NOTHING;
@@ -0,0 +1,75 @@
INSERT INTO "company_memberships" (
"company_id",
"principal_type",
"principal_id",
"status",
"membership_role",
"created_at",
"updated_at"
)
SELECT
"company_id",
'agent',
"id",
'active',
'member',
now(),
now()
FROM "agents"
WHERE "status" NOT IN ('pending_approval', 'terminated')
ON CONFLICT (
"company_id",
"principal_type",
"principal_id"
) DO NOTHING;
INSERT INTO "principal_permission_grants" (
"company_id",
"principal_type",
"principal_id",
"permission_key",
"scope",
"granted_by_user_id",
"created_at",
"updated_at"
)
SELECT
memberships."company_id",
'user',
memberships."principal_id",
role_defaults."permission_key",
NULL,
NULL,
now(),
now()
FROM "company_memberships" memberships
JOIN (
VALUES
('owner', 'agents:create'),
('owner', 'environments:manage'),
('owner', 'users:invite'),
('owner', 'users:manage_permissions'),
('owner', 'tasks:assign'),
('owner', 'joins:approve'),
('admin', 'agents:create'),
('admin', 'environments:manage'),
('admin', 'users:invite'),
('admin', 'tasks:assign'),
('admin', 'joins:approve'),
('operator', 'tasks:assign')
) AS role_defaults("membership_role", "permission_key")
ON role_defaults."membership_role" = CASE
WHEN memberships."membership_role" = 'owner' THEN 'owner'
WHEN memberships."membership_role" = 'admin' THEN 'admin'
WHEN memberships."membership_role" = 'viewer' THEN 'viewer'
WHEN memberships."membership_role" = 'member' THEN 'operator'
ELSE 'operator'
END
WHERE memberships."principal_type" = 'user'
AND memberships."status" = 'active'
ON CONFLICT (
"company_id",
"principal_type",
"principal_id",
"permission_key"
) DO NOTHING;
@@ -0,0 +1,71 @@
CREATE TABLE IF NOT EXISTS "cloud_upstream_connections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"remote_url" text NOT NULL,
"source_instance_id" text NOT NULL,
"source_instance_fingerprint" text NOT NULL,
"source_public_key" text NOT NULL,
"private_key_pem" text NOT NULL,
"token_status" text NOT NULL,
"scopes" text[] DEFAULT '{}' NOT NULL,
"authorized_global_user_id" text,
"access_token" text,
"token_id" text,
"token_expires_at" timestamp with time zone,
"target_stack_id" text NOT NULL,
"target_stack_slug" text,
"target_stack_display_name" text,
"target_company_id" text NOT NULL,
"target_origin" text NOT NULL,
"target_primary_host" text NOT NULL,
"target_product" text NOT NULL,
"target_schema_major" integer NOT NULL,
"target_max_chunk_bytes" integer NOT NULL,
"pending_state" text,
"pending_code_verifier" text,
"pending_redirect_uri" text,
"pending_token_url" text,
"last_run_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "cloud_upstream_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"connection_id" uuid NOT NULL,
"company_id" uuid NOT NULL,
"remote_run_id" text,
"status" text NOT NULL,
"active_step" text NOT NULL,
"progress_percent" integer DEFAULT 0 NOT NULL,
"dry_run" boolean DEFAULT false NOT NULL,
"retry_of_run_id" uuid,
"summary" jsonb DEFAULT '[]'::jsonb NOT NULL,
"warnings" jsonb DEFAULT '[]'::jsonb NOT NULL,
"conflicts" jsonb DEFAULT '[]'::jsonb NOT NULL,
"events" jsonb DEFAULT '[]'::jsonb NOT NULL,
"report" jsonb DEFAULT '{}'::jsonb NOT NULL,
"idempotency_key" text NOT NULL,
"manifest_hash" text NOT NULL,
"target_url" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone
);--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_connections_company_id_companies_id_fk') THEN
ALTER TABLE "cloud_upstream_connections" ADD CONSTRAINT "cloud_upstream_connections_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_runs_connection_id_cloud_upstream_connections_id_fk') THEN
ALTER TABLE "cloud_upstream_runs" ADD CONSTRAINT "cloud_upstream_runs_connection_id_cloud_upstream_connections_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."cloud_upstream_connections"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cloud_upstream_runs_company_id_companies_id_fk') THEN
ALTER TABLE "cloud_upstream_runs" ADD CONSTRAINT "cloud_upstream_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "cloud_upstream_connections_company_idx" ON "cloud_upstream_connections" USING btree ("company_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_company_created_idx" ON "cloud_upstream_runs" USING btree ("company_id","created_at");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "cloud_upstream_runs_connection_idx" ON "cloud_upstream_runs" USING btree ("connection_id");
@@ -0,0 +1,55 @@
CREATE TABLE IF NOT EXISTS "agent_memberships" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
"user_id" text NOT NULL,
"state" text DEFAULT 'joined' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "project_memberships" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"project_id" uuid NOT NULL,
"user_id" text NOT NULL,
"state" text DEFAULT 'joined' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_memberships_company_id_companies_id_fk') THEN
ALTER TABLE "agent_memberships" ADD CONSTRAINT "agent_memberships_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'agent_memberships_agent_id_agents_id_fk') THEN
ALTER TABLE "agent_memberships" ADD CONSTRAINT "agent_memberships_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'project_memberships_company_id_companies_id_fk') THEN
ALTER TABLE "project_memberships" ADD CONSTRAINT "project_memberships_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'project_memberships_project_id_projects_id_fk') THEN
ALTER TABLE "project_memberships" ADD CONSTRAINT "project_memberships_project_id_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
END IF;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_memberships_company_user_idx" ON "agent_memberships" USING btree ("company_id","user_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "agent_memberships_agent_idx" ON "agent_memberships" USING btree ("agent_id");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "agent_memberships_company_user_agent_uq" ON "agent_memberships" USING btree ("company_id","user_id","agent_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "project_memberships_company_user_idx" ON "project_memberships" USING btree ("company_id","user_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "project_memberships_project_idx" ON "project_memberships" USING btree ("project_id");
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "project_memberships_company_user_project_uq" ON "project_memberships" USING btree ("company_id","user_id","project_id");
File diff suppressed because it is too large Load Diff
@@ -596,6 +596,48 @@
"when": 1778355326070,
"tag": "0084_issue_recovery_actions",
"breakpoints": true
},
{
"idx": 85,
"version": "7",
"when": 1778787362162,
"tag": "0085_tranquil_the_executioner",
"breakpoints": true
},
{
"idx": 86,
"version": "7",
"when": 1778976000000,
"tag": "0086_routine_env_runtime_contract",
"breakpoints": true
},
{
"idx": 87,
"version": "7",
"when": 1779360000000,
"tag": "0087_backfill_environment_manage_human_defaults",
"breakpoints": true
},
{
"idx": 88,
"version": "7",
"when": 1779446400000,
"tag": "0088_backfill_principal_access_compatibility",
"breakpoints": true
},
{
"idx": 89,
"version": "7",
"when": 1779129600000,
"tag": "0089_cloud_upstreams",
"breakpoints": true
},
{
"idx": 90,
"version": "7",
"when": 1779573019125,
"tag": "0090_resource_memberships",
"breakpoints": true
}
]
}
@@ -0,0 +1,25 @@
import { pgTable, uuid, text, timestamp, uniqueIndex, index } from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { companies } from "./companies.js";
export const agentMemberships = pgTable(
"agent_memberships",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
agentId: uuid("agent_id").notNull().references(() => agents.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
state: text("state").notNull().default("joined"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyUserIdx: index("agent_memberships_company_user_idx").on(table.companyId, table.userId),
agentIdx: index("agent_memberships_agent_idx").on(table.agentId),
companyUserAgentUq: uniqueIndex("agent_memberships_company_user_agent_uq").on(
table.companyId,
table.userId,
table.agentId,
),
}),
);
+75
View File
@@ -0,0 +1,75 @@
import { boolean, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
export const cloudUpstreamConnections = pgTable(
"cloud_upstream_connections",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
remoteUrl: text("remote_url").notNull(),
sourceInstanceId: text("source_instance_id").notNull(),
sourceInstanceFingerprint: text("source_instance_fingerprint").notNull(),
sourcePublicKey: text("source_public_key").notNull(),
// Stored through the Cloud Upstream service as an encrypted credential envelope.
privateKeyPem: text("private_key_pem").notNull(),
tokenStatus: text("token_status").notNull(),
scopes: text("scopes").array().notNull().default([]),
authorizedGlobalUserId: text("authorized_global_user_id"),
// Stored through the Cloud Upstream service as an encrypted credential envelope.
accessToken: text("access_token"),
tokenId: text("token_id"),
tokenExpiresAt: timestamp("token_expires_at", { withTimezone: true }),
targetStackId: text("target_stack_id").notNull(),
targetStackSlug: text("target_stack_slug"),
targetStackDisplayName: text("target_stack_display_name"),
targetCompanyId: text("target_company_id").notNull(),
targetOrigin: text("target_origin").notNull(),
targetPrimaryHost: text("target_primary_host").notNull(),
targetProduct: text("target_product").notNull(),
targetSchemaMajor: integer("target_schema_major").notNull(),
targetMaxChunkBytes: integer("target_max_chunk_bytes").notNull(),
pendingState: text("pending_state"),
pendingCodeVerifier: text("pending_code_verifier"),
pendingRedirectUri: text("pending_redirect_uri"),
pendingTokenUrl: text("pending_token_url"),
lastRunId: uuid("last_run_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
index("cloud_upstream_connections_company_idx").on(table.companyId),
],
);
export const cloudUpstreamRuns = pgTable(
"cloud_upstream_runs",
{
id: uuid("id").primaryKey().defaultRandom(),
connectionId: uuid("connection_id").notNull().references(() => cloudUpstreamConnections.id, { onDelete: "cascade" }),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
remoteRunId: text("remote_run_id"),
status: text("status").notNull(),
activeStep: text("active_step").notNull(),
progressPercent: integer("progress_percent").notNull().default(0),
dryRun: boolean("dry_run").notNull().default(false),
retryOfRunId: uuid("retry_of_run_id"),
summary: jsonb("summary").$type<import("@paperclipai/shared").CloudUpstreamSummaryCount[]>().notNull().default([]),
warnings: jsonb("warnings").$type<import("@paperclipai/shared").CloudUpstreamWarning[]>().notNull().default([]),
conflicts: jsonb("conflicts").$type<import("@paperclipai/shared").CloudUpstreamConflict[]>().notNull().default([]),
events: jsonb("events").$type<import("@paperclipai/shared").CloudUpstreamRunEvent[]>().notNull().default([]),
report: jsonb("report").$type<Record<string, unknown>>().notNull().default({}),
idempotencyKey: text("idempotency_key").notNull(),
manifestHash: text("manifest_hash").notNull(),
targetUrl: text("target_url"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp("completed_at", { withTimezone: true }),
},
(table) => [
index("cloud_upstream_runs_company_created_idx").on(table.companyId, table.createdAt),
index("cloud_upstream_runs_connection_idx").on(table.connectionId),
],
);
+3
View File
@@ -16,6 +16,9 @@ export const documents = pgTable(
createdByUserId: text("created_by_user_id"),
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
updatedByUserId: text("updated_by_user_id"),
lockedAt: timestamp("locked_at", { withTimezone: true }),
lockedByAgentId: uuid("locked_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
lockedByUserId: text("locked_by_user_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
+3
View File
@@ -2,9 +2,11 @@ export { companies } from "./companies.js";
export { companyLogos } from "./company_logos.js";
export { authUsers, authSessions, authAccounts, authVerifications } from "./auth.js";
export { instanceSettings } from "./instance_settings.js";
export { cloudUpstreamConnections, cloudUpstreamRuns } from "./cloud_upstreams.js";
export { instanceUserRoles } from "./instance_user_roles.js";
export { userSidebarPreferences } from "./user_sidebar_preferences.js";
export { agents } from "./agents.js";
export { agentMemberships } from "./agent_memberships.js";
export { boardApiKeys } from "./board_api_keys.js";
export { cliAuthChallenges } from "./cli_auth_challenges.js";
export { companyMemberships } from "./company_memberships.js";
@@ -20,6 +22,7 @@ export { agentRuntimeState } from "./agent_runtime_state.js";
export { agentTaskSessions } from "./agent_task_sessions.js";
export { agentWakeupRequests } from "./agent_wakeup_requests.js";
export { projects } from "./projects.js";
export { projectMemberships } from "./project_memberships.js";
export { projectWorkspaces } from "./project_workspaces.js";
export { executionWorkspaces } from "./execution_workspaces.js";
export { environments } from "./environments.js";
@@ -0,0 +1,25 @@
import { pgTable, uuid, text, timestamp, uniqueIndex, index } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { projects } from "./projects.js";
export const projectMemberships = pgTable(
"project_memberships",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
state: text("state").notNull().default("joined"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyUserIdx: index("project_memberships_company_user_idx").on(table.companyId, table.userId),
projectIdx: index("project_memberships_project_idx").on(table.projectId),
companyUserProjectUq: uniqueIndex("project_memberships_company_user_project_uq").on(
table.companyId,
table.userId,
table.projectId,
),
}),
);
+4 -1
View File
@@ -17,7 +17,7 @@ import { issues } from "./issues.js";
import { projects } from "./projects.js";
import { goals } from "./goals.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import type { RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared";
import type { RoutineEnvConfig, RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared";
export const routines = pgTable(
"routines",
@@ -35,6 +35,7 @@ export const routines = pgTable(
concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"),
catchUpPolicy: text("catch_up_policy").notNull().default("skip_missed"),
variables: jsonb("variables").$type<RoutineVariable[]>().notNull().default([]),
env: jsonb("env").$type<RoutineEnvConfig>(),
latestRevisionId: uuid("latest_revision_id"),
latestRevisionNumber: integer("latest_revision_number").notNull().default(1),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
@@ -131,6 +132,7 @@ export const routineRuns = pgTable(
source: text("source").notNull(),
status: text("status").notNull().default("received"),
triggeredAt: timestamp("triggered_at", { withTimezone: true }).notNull().defaultNow(),
routineRevisionId: uuid("routine_revision_id").references(() => routineRevisions.id, { onDelete: "set null" }),
idempotencyKey: text("idempotency_key"),
triggerPayload: jsonb("trigger_payload").$type<Record<string, unknown>>(),
dispatchFingerprint: text("dispatch_fingerprint"),
@@ -143,6 +145,7 @@ export const routineRuns = pgTable(
},
(table) => ({
companyRoutineIdx: index("routine_runs_company_routine_idx").on(table.companyId, table.routineId, table.createdAt),
routineRevisionIdx: index("routine_runs_revision_idx").on(table.routineRevisionId),
triggerIdx: index("routine_runs_trigger_idx").on(table.triggerId, table.createdAt),
dispatchFingerprintIdx: index("routine_runs_dispatch_fingerprint_idx").on(table.routineId, table.dispatchFingerprint),
linkedIssueIdx: index("routine_runs_linked_issue_idx").on(table.linkedIssueId),
@@ -3,6 +3,7 @@ import net from "node:net";
import os from "node:os";
import path from "node:path";
import { applyPendingMigrations, ensurePostgresDatabase } from "./client.js";
import { prepareEmbeddedPostgresNativeRuntime } from "./embedded-postgres-native.js";
type EmbeddedPostgresInstance = {
initialise(): Promise<void>;
@@ -48,6 +49,7 @@ function getReservedTestPorts(): Set<number> {
async function getEmbeddedPostgresCtor(): Promise<EmbeddedPostgresCtor> {
const mod = await import("embedded-postgres");
await prepareEmbeddedPostgresNativeRuntime();
return mod.default as EmbeddedPostgresCtor;
}
@@ -7,6 +7,7 @@ export const PAGE_ROUTE = "kitchensink";
export const SLOT_IDS = {
page: "kitchen-sink-page",
settingsPage: "kitchen-sink-settings-page",
companySettingsPage: "kitchen-sink-company-settings-page",
dashboardWidget: "kitchen-sink-dashboard-widget",
sidebar: "kitchen-sink-sidebar-link",
sidebarPanel: "kitchen-sink-sidebar-panel",
@@ -23,6 +24,7 @@ export const SLOT_IDS = {
export const EXPORT_NAMES = {
page: "KitchenSinkPage",
settingsPage: "KitchenSinkSettingsPage",
companySettingsPage: "KitchenSinkCompanySettingsPage",
dashboardWidget: "KitchenSinkDashboardWidget",
sidebar: "KitchenSinkSidebarLink",
sidebarPanel: "KitchenSinkSidebarPanel",
@@ -194,6 +194,13 @@ const manifest: PaperclipPluginManifestV1 = {
displayName: "Kitchen Sink Settings",
exportName: EXPORT_NAMES.settingsPage,
},
{
type: "companySettingsPage",
id: SLOT_IDS.companySettingsPage,
displayName: "Kitchen Sink",
exportName: EXPORT_NAMES.companySettingsPage,
routePath: "kitchen-sink",
},
{
type: "dashboardWidget",
id: SLOT_IDS.dashboardWidget,
@@ -10,6 +10,7 @@ import {
usePluginToast,
type PluginCommentAnnotationProps,
type PluginCommentContextMenuItemProps,
type PluginCompanySettingsPageProps,
type PluginDetailTabProps,
type PluginPageProps,
type PluginProjectSidebarItemProps,
@@ -2236,6 +2237,33 @@ export function KitchenSinkSettingsPage({ context }: PluginSettingsPageProps) {
);
}
export function KitchenSinkCompanySettingsPage({ context }: PluginCompanySettingsPageProps) {
const hostNavigation = useHostNavigation();
const overview = usePluginOverview(context.companyId);
const href = hostNavigation.resolveHref("/company/settings/kitchen-sink");
return (
<div style={layoutStack}>
<Section title="Company Settings Slot">
<div style={subtleCardStyle}>
<div style={{ display: "grid", gap: "8px" }}>
<strong>Mounted inside company settings</strong>
<div style={mutedTextStyle}>
This fixture proves a ready plugin can add a settings sidebar item and render with company context.
</div>
<JsonBlock value={{
companyId: context.companyId,
companyPrefix: context.companyPrefix,
route: href,
pluginId: overview.data?.pluginId ?? PLUGIN_ID,
}} />
</div>
</div>
</Section>
</div>
);
}
export function KitchenSinkDashboardWidget({ context }: PluginWidgetProps) {
const hostNavigation = useHostNavigation();
const overview = usePluginOverview(context.companyId);
@@ -140,37 +140,14 @@ ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_runs ALTER COLUMN
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_source_snapshots ALTER COLUMN space_id SET NOT NULL;
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings ALTER COLUMN space_id SET NOT NULL;
DO $$
DECLARE
target record;
constraint_name text;
BEGIN
FOR target IN
SELECT * FROM (VALUES
('wiki_pages', ARRAY['company_id', 'wiki_id', 'path']::text[]),
('paperclip_distillation_cursors', ARRAY['company_id', 'wiki_id', 'source_scope', 'scope_key', 'source_kind']::text[]),
('paperclip_distillation_work_items', ARRAY['company_id', 'wiki_id', 'idempotency_key']::text[]),
('paperclip_page_bindings', ARRAY['company_id', 'wiki_id', 'page_path']::text[])
) AS targets(table_name, column_names)
LOOP
FOR constraint_name IN
SELECT c.conname
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'plugin_llm_wiki_8f50da974f'
AND t.relname = target.table_name
AND c.contype = 'u'
AND (
SELECT array_agg(a.attname ORDER BY constraint_columns.ordinality)::text[]
FROM unnest(c.conkey) WITH ORDINALITY AS constraint_columns(attnum, ordinality)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = constraint_columns.attnum
) = target.column_names
LOOP
EXECUTE format('ALTER TABLE %I.%I DROP CONSTRAINT %I', 'plugin_llm_wiki_8f50da974f', target.table_name, constraint_name);
END LOOP;
END LOOP;
END $$;
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages
DROP CONSTRAINT IF EXISTS wiki_pages_company_id_wiki_id_path_key;
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_cursors
DROP CONSTRAINT IF EXISTS paperclip_distillation_cursor_company_id_wiki_id_source_sco_key;
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_distillation_work_items
DROP CONSTRAINT IF EXISTS paperclip_distillation_work_i_company_id_wiki_id_idempotenc_key;
ALTER TABLE plugin_llm_wiki_8f50da974f.paperclip_page_bindings
DROP CONSTRAINT IF EXISTS paperclip_page_bindings_company_id_wiki_id_page_path_key;
ALTER TABLE plugin_llm_wiki_8f50da974f.wiki_pages
DROP CONSTRAINT IF EXISTS wiki_pages_company_wiki_space_path_key;
@@ -4,6 +4,14 @@
"type": "module",
"private": true,
"description": "Local-file LLM Wiki plugin for source ingestion, wiki browsing, query, lint, and maintenance workflows.",
"files": [
"agents",
"dist",
"migrations",
"skills",
"templates",
"README.md"
],
"scripts": {
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
"build": "node ./esbuild.config.mjs",
@@ -46,7 +46,7 @@ export const DEFAULT_AGENT_INSTRUCTIONS = DEFAULT_AGENT_INSTRUCTION_FILES["AGENT
export const DEFAULT_IDEA = templateFile("IDEA.md");
export const DEFAULT_INDEX = templateFile("wiki/index.md");
export const DEFAULT_LOG = templateFile("wiki/log.md");
export const DEFAULT_GITIGNORE = templateFile(".gitignore");
export const DEFAULT_GITIGNORE = templateFile("gitignore.template");
export const QUERY_PROMPT = `Answer from the LLM Wiki using the installed wiki-query skill.
@@ -155,6 +155,11 @@ type ManagedRoutine = {
} | null;
};
type ManagedRoutineDefaultDrift = NonNullable<ManagedRoutine["defaultDrift"]>;
type ManagedRoutinesListItemWithDrift = ManagedRoutinesListItem & {
defaultDrift?: ManagedRoutineDefaultDrift | null;
};
type ManagedSkill = {
status: string;
skillId?: string | null;
@@ -5905,7 +5910,7 @@ function SettingsBody({ context, initialSection = "root" }: { context: { company
const effectiveSelectedProjectId = selectedProjectId || data.managedProject.projectId || "";
const currentProjectOption = projectOptions.find((project) => project.id === effectiveSelectedProjectId) ?? projectFallbackOption;
const currentEventPolicy = eventPolicy ?? data.eventIngestion;
const managedRoutineItems: ManagedRoutinesListItem[] = managedRoutines.map((routine) => {
const managedRoutineItems: ManagedRoutinesListItemWithDrift[] = managedRoutines.map((routine) => {
const fallback = routineFallbackFor(routine);
const key = routine.resourceKey ?? routine.routineId ?? fallback.title;
const status = managedRoutineStatus(routine);
@@ -6132,7 +6137,7 @@ function SettingsBody({ context, initialSection = "root" }: { context: { company
async function resetManagedRoutineToDefaults(routine: ManagedRoutinesListItem) {
if (!context.companyId || !routine.resourceKey) return;
const changedFields = routine.defaultDrift?.changedFields ?? [];
const changedFields = (routine as ManagedRoutinesListItemWithDrift).defaultDrift?.changedFields ?? [];
const fieldList = changedFields.length > 0 ? changedFields.join(", ") : "managed defaults";
const confirmed = typeof window === "undefined" || window.confirm(
`Update "${routine.title}" to the current LLM Wiki plugin defaults? This replaces ${fieldList}. Cancel to keep the current custom routine text.`,
@@ -1102,10 +1102,10 @@ export async function listPaperclipIngestionCandidates(ctx: PluginContext, input
return { projects, rootIssues: issues };
}
export async function updateEventIngestionSettings(
ctx: PluginContext,
export async function updateEventIngestionSettings(
ctx: PluginContext,
input: { companyId: string; settings: WikiEventIngestionSettingsUpdate },
): Promise<WikiEventIngestionSettings> {
): Promise<WikiEventIngestionSettings> {
await requirePaperclipIngestionPolicy(ctx, {
companyId: input.companyId,
wikiId: normalizeWikiId(input.settings.wikiId),
@@ -0,0 +1,72 @@
{
"name": "@paperclipai/plugin-workspace-diff",
"version": "0.1.0",
"description": "First-party execution workspace Changes tab powered by plugin-local workspace metadata",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/plugins/plugin-workspace-diff"
},
"type": "module",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
},
"keywords": [
"paperclip",
"plugin",
"workspace",
"diff"
],
"scripts": {
"postinstall": "node ../../../scripts/link-plugin-dev-sdk.mjs",
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
"build": "tsc && node ./scripts/build-ui.mjs",
"clean": "rm -rf dist",
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
"test": "vitest run",
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../scripts/generate-plugin-package-json.mjs",
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
},
"dependencies": {
"@paperclipai/plugin-sdk": "workspace:*",
"@pierre/diffs": "^1.1.22"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"esbuild": "^0.27.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
}
@@ -0,0 +1,24 @@
import esbuild from "esbuild";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageRoot = path.resolve(__dirname, "..");
await esbuild.build({
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
outfile: path.join(packageRoot, "dist/ui/index.js"),
bundle: true,
format: "esm",
platform: "browser",
target: ["es2022"],
sourcemap: true,
external: [
"react",
"react-dom",
"react/jsx-runtime",
"@paperclipai/plugin-sdk/ui",
],
logLevel: "info",
});
@@ -0,0 +1,144 @@
import { z } from "@paperclipai/plugin-sdk";
export const workspaceDiffViewSchema = z.enum(["working-tree", "head"]);
export const workspaceDiffFileStatusSchema = z.enum([
"added",
"modified",
"deleted",
"renamed",
"copied",
"type_changed",
"untracked",
"unknown",
]);
export const workspaceDiffPatchKindSchema = z.enum(["staged", "unstaged", "head", "untracked"]);
export const workspaceDiffWarningCodeSchema = z.enum([
"base_ref_missing",
"base_ref_invalid",
"binary_file",
"file_count_truncated",
"file_oversized",
"git_command_failed",
"missing_cwd",
"non_git_workspace",
"patch_truncated",
"path_filter_invalid",
"symlink_target_outside_workspace",
"workspace_path_invalid",
]);
const queryBooleanSchema = z
.union([z.boolean(), z.enum(["true", "false"])])
.transform((value) => value === true || value === "true");
function normalizePathQuery(value: unknown): string[] {
if (value == null) return [];
const values = Array.isArray(value) ? value : [value];
return values.flatMap((entry) => {
if (typeof entry !== "string") return [];
return entry
.split(",")
.map((filePath) => filePath.trim())
.filter(Boolean);
});
}
export const workspaceDiffQuerySchema = z
.object({
view: workspaceDiffViewSchema.optional().default("working-tree"),
baseRef: z.string().trim().min(1).max(240).optional().nullable(),
includeUntracked: queryBooleanSchema.optional().default(true),
path: z.union([z.string(), z.array(z.string())]).optional(),
paths: z.union([z.string(), z.array(z.string())]).optional(),
})
.passthrough()
.transform((value) => ({
view: value.view,
baseRef: value.baseRef?.trim() || null,
includeUntracked: value.includeUntracked,
paths: normalizePathQuery(value.paths ?? value.path),
}));
export const workspaceDiffWarningSchema = z.object({
code: workspaceDiffWarningCodeSchema,
message: z.string(),
path: z.string().nullable(),
}).strict();
export const workspaceDiffCapsSchema = z.object({
maxFiles: z.number().int().positive(),
maxFileBytes: z.number().int().positive(),
maxPatchBytes: z.number().int().positive(),
maxTotalPatchBytes: z.number().int().positive(),
}).strict();
export const workspaceDiffFilePatchSchema = z.object({
kind: workspaceDiffPatchKindSchema,
patch: z.string().nullable(),
additions: z.number().int().nonnegative(),
deletions: z.number().int().nonnegative(),
binary: z.boolean(),
oversized: z.boolean(),
truncated: z.boolean(),
warnings: z.array(workspaceDiffWarningSchema),
}).strict();
export const workspaceDiffFileSchema = z.object({
path: z.string(),
oldPath: z.string().nullable(),
status: workspaceDiffFileStatusSchema,
staged: z.boolean(),
unstaged: z.boolean(),
untracked: z.boolean(),
binary: z.boolean(),
oversized: z.boolean(),
truncated: z.boolean(),
additions: z.number().int().nonnegative(),
deletions: z.number().int().nonnegative(),
sizeBytes: z.number().int().nonnegative().nullable(),
patches: z.array(workspaceDiffFilePatchSchema),
warnings: z.array(workspaceDiffWarningSchema),
}).strict();
export const workspaceDiffStatsSchema = z.object({
fileCount: z.number().int().nonnegative(),
stagedFileCount: z.number().int().nonnegative(),
unstagedFileCount: z.number().int().nonnegative(),
untrackedFileCount: z.number().int().nonnegative(),
binaryFileCount: z.number().int().nonnegative(),
oversizedFileCount: z.number().int().nonnegative(),
truncatedFileCount: z.number().int().nonnegative(),
additions: z.number().int().nonnegative(),
deletions: z.number().int().nonnegative(),
}).strict();
export const workspaceDiffResponseSchema = z.object({
workspaceId: z.string(),
companyId: z.string(),
view: workspaceDiffViewSchema,
baseRef: z.string().nullable(),
defaultBaseRef: z.string().nullable(),
headSha: z.string().nullable(),
includeUntracked: z.boolean(),
paths: z.array(z.string()),
files: z.array(workspaceDiffFileSchema),
stats: workspaceDiffStatsSchema,
warnings: z.array(workspaceDiffWarningSchema),
caps: workspaceDiffCapsSchema,
truncated: z.boolean(),
}).strict();
export type WorkspaceDiffView = z.infer<typeof workspaceDiffViewSchema>;
export type WorkspaceDiffFileStatus = z.infer<typeof workspaceDiffFileStatusSchema>;
export type WorkspaceDiffPatchKind = z.infer<typeof workspaceDiffPatchKindSchema>;
export type WorkspaceDiffWarningCode = z.infer<typeof workspaceDiffWarningCodeSchema>;
export type WorkspaceDiffQueryOptions = z.infer<typeof workspaceDiffQuerySchema>;
export type WorkspaceDiffWarning = z.infer<typeof workspaceDiffWarningSchema>;
export type WorkspaceDiffCaps = z.infer<typeof workspaceDiffCapsSchema>;
export type WorkspaceDiffFilePatch = z.infer<typeof workspaceDiffFilePatchSchema>;
export type WorkspaceDiffFile = z.infer<typeof workspaceDiffFileSchema>;
export type WorkspaceDiffStats = z.infer<typeof workspaceDiffStatsSchema>;
export type WorkspaceDiffResponse = z.infer<typeof workspaceDiffResponseSchema>;
@@ -0,0 +1,143 @@
import type {
WorkspaceDiffFile,
WorkspaceDiffFilePatch,
WorkspaceDiffResponse,
WorkspaceDiffWarning,
} from "./contracts.js";
export type DiffRenderMode = "unified" | "split";
export interface DiffPatchViewModel {
kind: WorkspaceDiffFilePatch["kind"];
patch: string | null;
lineCount: number;
additions: number;
deletions: number;
binary: boolean;
oversized: boolean;
truncated: boolean;
warnings: WorkspaceDiffWarning[];
}
export interface DiffFileViewModel {
path: string;
oldPath: string | null;
status: WorkspaceDiffFile["status"];
additions: number;
deletions: number;
binary: boolean;
oversized: boolean;
truncated: boolean;
warnings: WorkspaceDiffWarning[];
patchKinds: WorkspaceDiffFilePatch["kind"][];
patches: DiffPatchViewModel[];
patch: string | null;
lineCount: number;
longDiff: boolean;
}
export interface DiffSummaryViewModel {
changedLabel: string;
lineLabel: string;
warningCount: number;
truncated: boolean;
}
const STATUS_LABELS: Record<WorkspaceDiffFile["status"], string> = {
added: "Added",
modified: "Modified",
deleted: "Deleted",
renamed: "Renamed",
copied: "Copied",
type_changed: "Type changed",
untracked: "Untracked",
unknown: "Changed",
};
export const LONG_DIFF_LINE_THRESHOLD = 400;
export function statusLabel(status: WorkspaceDiffFile["status"]) {
return STATUS_LABELS[status] ?? "Changed";
}
export function fileName(filePath: string) {
return filePath.split("/").filter(Boolean).pop() ?? filePath;
}
export function buildFilePatches(file: WorkspaceDiffFile): DiffPatchViewModel[] {
return file.patches.map((patch) => {
const textPatch = patch.patch?.trimEnd() ?? null;
const lineCount = textPatch ? textPatch.split("\n").length : 0;
return {
kind: patch.kind,
patch: textPatch && textPatch.length > 0 ? textPatch : null,
lineCount,
additions: patch.additions,
deletions: patch.deletions,
binary: patch.binary,
oversized: patch.oversized,
truncated: patch.truncated,
warnings: patch.warnings,
};
});
}
export function buildFilePatch(file: WorkspaceDiffFile): string | null {
return buildFilePatches(file).find((patch) => patch.patch)?.patch ?? null;
}
export function isLongDiffFile(file: Pick<DiffFileViewModel, "lineCount">) {
return file.lineCount > LONG_DIFF_LINE_THRESHOLD;
}
export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined): DiffFileViewModel[] {
return (diff?.files ?? []).map((file) => {
const patches = buildFilePatches(file);
const lineCount = patches.reduce((count, patch) => count + patch.lineCount, 0);
return {
path: file.path,
oldPath: file.oldPath,
status: file.status,
additions: file.additions,
deletions: file.deletions,
binary: file.binary,
oversized: file.oversized,
truncated: file.truncated,
warnings: file.warnings,
patchKinds: file.patches.map((patch) => patch.kind),
patches,
patch: patches.find((patch) => patch.patch)?.patch ?? null,
lineCount,
longDiff: isLongDiffFile({ lineCount }),
};
});
}
export function diffSummary(diff: WorkspaceDiffResponse | null | undefined): DiffSummaryViewModel {
const stats = diff?.stats;
const fileCount = stats?.fileCount ?? 0;
const additions = stats?.additions ?? 0;
const deletions = stats?.deletions ?? 0;
const warningCount = diff?.warnings.length ?? 0;
return {
changedLabel: `${fileCount} ${fileCount === 1 ? "file" : "files"}`,
lineLabel: `+${additions} / -${deletions}`,
warningCount,
truncated: Boolean(diff?.truncated),
};
}
export function nextExpandedFileSet(
current: ReadonlySet<string>,
filePath: string,
): Set<string> {
const next = new Set(current);
if (next.has(filePath)) next.delete(filePath);
else next.add(filePath);
return next;
}
export function initialExpandedFileSet(files: readonly DiffFileViewModel[]): Set<string> {
return new Set(files.filter((file) => !file.longDiff).map((file) => file.path));
}
@@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as worker } from "./worker.js";
@@ -0,0 +1,37 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const PLUGIN_ID = "paperclip.workspace-diff";
const CHANGES_TAB_SLOT_ID = "workspace-changes-tab";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: "0.1.0",
displayName: "Workspace Changes",
description: "Adds a Changes tab to execution and project workspaces using plugin-local Git diff computation and @pierre/diffs.",
author: "Paperclip",
categories: ["workspace", "ui"],
capabilities: [
"ui.detailTab.register",
"execution.workspaces.read",
"project.workspaces.read",
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui",
},
ui: {
slots: [
{
type: "detailTab",
id: CHANGES_TAB_SLOT_ID,
displayName: "Changes",
exportName: "ChangesTab",
entityTypes: ["execution_workspace", "project_workspace"],
order: 25,
},
],
},
};
export default manifest;
@@ -0,0 +1,824 @@
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs";
import type { PatchDiffProps } from "@pierre/diffs/react";
import { useFileDiffInstance } from "@pierre/diffs/react";
import {
createElement,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
diffSummary,
fileName,
initialExpandedFileSet,
nextExpandedFileSet,
statusLabel,
toFileViewModels,
type DiffFileViewModel,
type DiffPatchViewModel,
type DiffRenderMode,
} from "../diff-model.js";
import type { WorkspaceDiffResponse } from "../contracts.js";
type WorkspaceDiffData = WorkspaceDiffResponse;
type WorkspacePatchDiffOptions = PatchDiffProps<undefined>["options"];
type DiffViewMode = "working-tree" | "head";
type LucideIconProps = { size?: number };
const DEFAULT_FILE_SIDEBAR_WIDTH = 280;
const MIN_FILE_SIDEBAR_WIDTH = 220;
const MAX_FILE_SIDEBAR_WIDTH = 520;
const FILE_SIDEBAR_WIDTH_STEP = 16;
const FILE_SIDEBAR_WIDTH_STORAGE_KEY = "paperclip.workspace-diff.files-sidebar-width";
function makeLucideIcon(paths: ReactNode) {
return function LucideIcon({ size = 16 }: LucideIconProps) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ width: size, height: size, display: "block" }}
>
{paths}
</svg>
);
};
}
// Plugin bundles cannot import host-only lucide-react; this mirrors lucide RefreshCw.
const RefreshCwIcon = makeLucideIcon(
<>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</>,
);
function readInitialView(): DiffViewMode {
if (typeof window === "undefined") return "working-tree";
return new URLSearchParams(window.location.search).get("diffView") === "head" ? "head" : "working-tree";
}
function hasInitialViewParam() {
if (typeof window === "undefined") return false;
return new URLSearchParams(window.location.search).has("diffView");
}
function readInitialBaseRef() {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("baseRef") ?? "";
}
function buttonClass(active = false) {
return [
"inline-flex h-8 items-center justify-center rounded-md border px-2.5 text-xs font-medium transition-colors",
active
? "border-foreground/20 bg-foreground text-background"
: "border-border bg-background text-muted-foreground hover:text-foreground",
].join(" ");
}
function iconButtonClass(active = false) {
return [
"inline-flex h-7 w-7 items-center justify-center rounded-md border text-xs transition-colors",
active
? "border-foreground/20 bg-foreground text-background"
: "border-border bg-background text-muted-foreground hover:text-foreground",
].join(" ");
}
function clampFileSidebarWidth(width: number) {
return Math.min(MAX_FILE_SIDEBAR_WIDTH, Math.max(MIN_FILE_SIDEBAR_WIDTH, width));
}
function readStoredFileSidebarWidth() {
if (typeof window === "undefined") return DEFAULT_FILE_SIDEBAR_WIDTH;
try {
const stored = window.localStorage.getItem(FILE_SIDEBAR_WIDTH_STORAGE_KEY);
if (!stored) return DEFAULT_FILE_SIDEBAR_WIDTH;
const parsed = Number.parseInt(stored, 10);
return Number.isFinite(parsed) ? clampFileSidebarWidth(parsed) : DEFAULT_FILE_SIDEBAR_WIDTH;
} catch {
return DEFAULT_FILE_SIDEBAR_WIDTH;
}
}
function writeStoredFileSidebarWidth(width: number) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(FILE_SIDEBAR_WIDTH_STORAGE_KEY, String(clampFileSidebarWidth(width)));
} catch {
// Storage can be unavailable; keep resize interactive even when persistence fails.
}
}
function useIsDesktopDiffLayout() {
const [isDesktop, setIsDesktop] = useState(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(min-width: 1024px)").matches;
});
useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
const query = window.matchMedia("(min-width: 1024px)");
const update = () => setIsDesktop(query.matches);
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return isDesktop;
}
function warningText(file: DiffFileViewModel) {
if (file.binary) return "Binary file";
if (file.oversized) return "Too large to render";
if (file.truncated) return "Patch truncated";
if (file.warnings.length > 0) return file.warnings[0]?.message ?? "Diff warning";
if (file.patches.every((patch) => !patch.patch)) return "No text patch";
return null;
}
const PATCH_KIND_LABELS: Record<DiffPatchViewModel["kind"], string> = {
staged: "Staged",
unstaged: "Unstaged",
head: "Head",
untracked: "Untracked",
};
function patchKindLabel(kind: DiffPatchViewModel["kind"]) {
return PATCH_KIND_LABELS[kind] ?? "Patch";
}
function patchWarningText(patch: DiffPatchViewModel) {
if (patch.binary) return "Binary file";
if (patch.oversized) return "Too large to render";
if (patch.truncated) return "Patch truncated";
if (patch.warnings.length > 0) return patch.warnings[0]?.message ?? "Diff warning";
if (!patch.patch) return "No text patch";
return null;
}
function FileRow({
file,
active,
expanded,
onSelect,
onToggle,
onCopy,
}: {
file: DiffFileViewModel;
active: boolean;
expanded: boolean;
onSelect: () => void;
onToggle: () => void;
onCopy: () => void;
}) {
const warning = warningText(file);
const expandLabel = expanded ? "Collapse file" : "Expand file";
const fileAriaLabel = expanded ? `Collapse ${file.path}` : `Expand ${file.path}`;
return (
<div
className={[
"group border-b border-border/70 px-3 py-2 last:border-b-0",
active ? "bg-accent/60" : "bg-background hover:bg-muted/45",
].join(" ")}
>
<div key="main" className="flex min-w-0 items-start gap-2">
<button
key="toggle"
type="button"
className="mt-0.5 text-muted-foreground hover:text-foreground"
onClick={onToggle}
title={expandLabel}
aria-label={fileAriaLabel}
>
{expanded ? "" : "+"}
</button>
<button
key="select"
type="button"
className="min-w-0 flex-1 text-left"
onClick={onSelect}
>
<div key="name" className="truncate text-sm font-medium text-foreground">{fileName(file.path)}</div>
<div key="path" className="truncate font-mono text-[11px] text-muted-foreground">{file.path}</div>
</button>
<button
key="copy"
type="button"
className="text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
onClick={onCopy}
title="Copy path"
aria-label={`Copy ${file.path}`}
>
</button>
</div>
<div key="meta" className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 pl-5 text-[11px] text-muted-foreground">
<span key="status">{statusLabel(file.status)}</span>
<span key="additions" className="font-mono text-emerald-700 dark:text-emerald-300">{`+${file.additions}`}</span>
<span key="deletions" className="font-mono text-red-700 dark:text-red-300">{`-${file.deletions}`}</span>
{warning ? <span key="warning" className="text-amber-700 dark:text-amber-300">{warning}</span> : null}
</div>
</div>
);
}
// The upstream React wrapper emits React 19 key warnings for its internal slot array.
// This mounts the same Diffs custom element through the exported imperative hook.
function WorkspacePatchDiff({
patch,
options,
}: {
patch: string;
options: WorkspacePatchDiffOptions;
}) {
const fileDiff = useMemo(() => getSingularPatch(patch), [patch]);
const { ref } = useFileDiffInstance({
fileDiff,
options,
metrics: undefined,
lineAnnotations: undefined,
selectedLines: undefined,
prerenderedHTML: undefined,
hasGutterRenderUtility: false,
hasCustomHeader: false,
disableWorkerPool: false,
});
return createElement(DIFFS_TAG_NAME, { ref });
}
function EmptyState() {
return (
<div className="border border-dashed border-border bg-background px-4 py-8 text-center">
<div className="text-sm font-medium text-foreground">No workspace changes</div>
<div className="mt-1 text-sm text-muted-foreground">
The workspace matches its current comparison target.
</div>
</div>
);
}
function LoadingState() {
return (
<div className="border border-dashed border-border bg-background px-4 py-8 text-center text-sm text-muted-foreground">
Loading workspace changes
</div>
);
}
export function ErrorState({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}) {
return (
<div className="border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm" role="alert">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="font-medium text-foreground">Unable to load workspace changes.</div>
<div className="mt-1 text-muted-foreground">
Retry the request or open the details below for the technical error.
</div>
</div>
<button
type="button"
className={buttonClass(false)}
onClick={onRetry}
aria-label="Retry loading workspace changes"
>
Retry
</button>
</div>
<details className="mt-3">
<summary className="cursor-pointer text-xs font-medium text-muted-foreground hover:text-foreground">
Troubleshooting details
</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words border border-border bg-background px-3 py-2 font-mono text-xs text-muted-foreground">
{message || "No error message was provided."}
</pre>
</details>
</div>
);
}
function FileDiffPanel({
file,
mode,
lineWrap,
}: {
file: DiffFileViewModel;
mode: DiffRenderMode;
lineWrap: boolean;
}) {
const warning = warningText(file);
if (warning) {
return (
<div className="border border-dashed border-border bg-background px-4 py-6 text-sm text-muted-foreground">
{warning ?? "No renderable patch is available for this file."}
</div>
);
}
return (
<div className="space-y-3">
{file.patches.map((patch, index) => {
const patchWarning = patchWarningText(patch);
return (
<div key={`${patch.kind}:${index}`} className="overflow-hidden border border-border bg-background">
{file.patches.length > 1 ? (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{patchKindLabel(patch.kind)}</span>
<span className="font-mono text-emerald-700 dark:text-emerald-300">{`+${patch.additions}`}</span>
<span className="font-mono text-red-700 dark:text-red-300">{`-${patch.deletions}`}</span>
</div>
) : null}
{patchWarning || !patch.patch ? (
<div className="px-4 py-6 text-sm text-muted-foreground">
{patchWarning ?? "No renderable patch is available for this file."}
</div>
) : (
<WorkspacePatchDiff
patch={patch.patch}
options={{
diffStyle: mode,
overflow: lineWrap ? "wrap" : "scroll",
disableLineNumbers: false,
themeType: "system",
}}
/>
)}
</div>
);
})}
</div>
);
}
function CollapsedFilePanel({
file,
onExpand,
}: {
file: DiffFileViewModel;
onExpand: () => void;
}) {
const title = file.longDiff ? "Large diff folded" : "Diff folded";
const details = file.lineCount > 0
? `${file.lineCount.toLocaleString()} lines`
: statusLabel(file.status);
return (
<div className="border border-dashed border-border bg-background px-4 py-5 text-sm text-muted-foreground">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="font-medium text-foreground">{title}</div>
<div className="mt-1 font-mono text-xs">{details}</div>
</div>
<button
type="button"
className={buttonClass(false)}
onClick={onExpand}
aria-label={`Show diff for ${file.path}`}
>
Show file
</button>
</div>
</div>
);
}
export function ChangesTab({ context }: PluginDetailTabProps) {
const toast = usePluginToast();
const [mode, setMode] = useState<DiffRenderMode>("split");
const [lineWrap, setLineWrap] = useState(false);
const [view, setView] = useState<DiffViewMode>(() => readInitialView());
const [baseRef, setBaseRef] = useState(() => readInitialBaseRef());
const baseRefTouchedRef = useRef(Boolean(baseRef.trim()));
const viewTouchedRef = useRef(hasInitialViewParam());
const [includeUntracked, setIncludeUntracked] = useState(false);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(() => new Set());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [fileSidebarWidth, setFileSidebarWidth] = useState(() => readStoredFileSidebarWidth());
const [fileSidebarResizing, setFileSidebarResizing] = useState(false);
const fileSidebarWidthRef = useRef(fileSidebarWidth);
const fileSidebarDragRef = useRef<{ startX: number; startWidth: number } | null>(null);
const fileSectionRefs = useRef(new Map<string, HTMLElement>());
const diffScrollRef = useRef<HTMLElement | null>(null);
const scrollSyncFrameRef = useRef<number | null>(null);
const usesDesktopDiffLayout = useIsDesktopDiffLayout();
const requestedBaseRef = baseRef.trim();
const effectiveView = view === "head" && !requestedBaseRef ? "working-tree" : view;
const fileSidebarStyle = useMemo(
() => usesDesktopDiffLayout ? { width: `${fileSidebarWidth}px` } : undefined,
[fileSidebarWidth, usesDesktopDiffLayout],
);
const params = useMemo(() => ({
workspaceId: context.entityId,
companyId: context.companyId ?? "",
projectId: context.projectId ?? "",
entityType: context.entityType,
view: effectiveView,
baseRef: requestedBaseRef || null,
includeUntracked,
}), [context.companyId, context.entityId, context.entityType, context.projectId, effectiveView, includeUntracked, requestedBaseRef]);
const { data, loading, error, refresh } = usePluginData<WorkspaceDiffData>("workspace-diff", params);
const files = useMemo(() => toFileViewModels(data), [data]);
const summary = useMemo(() => diffSummary(data), [data]);
const selectedFile = files.find((file) => file.path === selectedPath) ?? files[0] ?? null;
const compareLabel = `${data?.baseRef ? `base ${data.baseRef}` : "working tree"}${data?.headSha ? ` · ${data.headSha.slice(0, 12)}` : ""}`;
const setFileSectionRef = useCallback((filePath: string) => (node: HTMLElement | null) => {
if (node) fileSectionRefs.current.set(filePath, node);
else fileSectionRefs.current.delete(filePath);
}, []);
const selectFile = useCallback((filePath: string) => {
setSelectedPath(filePath);
window.requestAnimationFrame(() => {
fileSectionRefs.current.get(filePath)?.scrollIntoView({
block: "start",
behavior: "smooth",
});
});
}, []);
const syncSelectedPathFromScroll = useCallback(() => {
const container = diffScrollRef.current;
if (!container || files.length === 0) return;
const containerTop = container.getBoundingClientRect().top;
let nextPath = files[0]?.path ?? null;
for (const file of files) {
const section = fileSectionRefs.current.get(file.path);
if (!section) continue;
const offsetFromScrollTop = section.getBoundingClientRect().top - containerTop;
if (offsetFromScrollTop <= 48) {
nextPath = file.path;
} else {
break;
}
}
if (nextPath) {
setSelectedPath((current) => current === nextPath ? current : nextPath);
}
}, [files]);
const handleDiffScroll = useCallback(() => {
if (scrollSyncFrameRef.current !== null) return;
scrollSyncFrameRef.current = window.requestAnimationFrame(() => {
scrollSyncFrameRef.current = null;
syncSelectedPathFromScroll();
});
}, [syncSelectedPathFromScroll]);
const commitFileSidebarWidth = useCallback((nextWidth: number) => {
const clamped = clampFileSidebarWidth(nextWidth);
fileSidebarWidthRef.current = clamped;
setFileSidebarWidth(clamped);
writeStoredFileSidebarWidth(clamped);
}, []);
const handleFileSidebarPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
if (!usesDesktopDiffLayout) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
fileSidebarDragRef.current = {
startX: event.clientX,
startWidth: fileSidebarWidthRef.current,
};
setFileSidebarResizing(true);
}, [usesDesktopDiffLayout]);
const handleFileSidebarPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
const drag = fileSidebarDragRef.current;
if (!drag) return;
const nextWidth = clampFileSidebarWidth(drag.startWidth + event.clientX - drag.startX);
fileSidebarWidthRef.current = nextWidth;
setFileSidebarWidth(nextWidth);
}, []);
const endFileSidebarResize = useCallback(() => {
if (!fileSidebarDragRef.current) return;
fileSidebarDragRef.current = null;
setFileSidebarResizing(false);
writeStoredFileSidebarWidth(fileSidebarWidthRef.current);
}, []);
const handleFileSidebarKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (!usesDesktopDiffLayout) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
commitFileSidebarWidth(fileSidebarWidth - FILE_SIDEBAR_WIDTH_STEP);
} else if (event.key === "ArrowRight") {
event.preventDefault();
commitFileSidebarWidth(fileSidebarWidth + FILE_SIDEBAR_WIDTH_STEP);
} else if (event.key === "Home") {
event.preventDefault();
commitFileSidebarWidth(MIN_FILE_SIDEBAR_WIDTH);
} else if (event.key === "End") {
event.preventDefault();
commitFileSidebarWidth(MAX_FILE_SIDEBAR_WIDTH);
}
}, [commitFileSidebarWidth, fileSidebarWidth, usesDesktopDiffLayout]);
useEffect(() => {
const defaultBaseRef = data?.defaultBaseRef?.trim();
if (!defaultBaseRef) return;
if (!baseRef.trim() && !baseRefTouchedRef.current) {
setBaseRef(defaultBaseRef);
}
if (view === "working-tree" && !viewTouchedRef.current) {
setView("head");
}
}, [baseRef, data?.defaultBaseRef, view]);
useEffect(() => {
if (files.length === 0) {
setExpandedFiles(new Set());
setSelectedPath(null);
return;
}
setExpandedFiles(initialExpandedFileSet(files));
setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null);
}, [files]);
useEffect(() => {
return () => {
if (scrollSyncFrameRef.current !== null) {
window.cancelAnimationFrame(scrollSyncFrameRef.current);
}
};
}, []);
useEffect(() => {
if (!fileSidebarResizing || typeof document === "undefined") return;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
return () => {
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
};
}, [fileSidebarResizing]);
const copyPath = async (filePath: string) => {
try {
await navigator.clipboard.writeText(filePath);
toast({ title: "Path copied", body: filePath });
} catch {
toast({ title: "Copy failed", body: filePath, tone: "error" });
}
};
return (
<div className="space-y-3">
<div key="toolbar" className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-center lg:justify-between">
<div key="summary" className="min-w-0">
<div key="summary-line" className="flex flex-wrap items-center gap-2 text-sm">
<span key="changed" className="font-medium text-foreground">{summary.changedLabel}</span>
<span key="lines" className="font-mono text-xs text-muted-foreground">{summary.lineLabel}</span>
{summary.truncated ? (
<span key="truncated" className="text-xs text-amber-700 dark:text-amber-300">Truncated</span>
) : null}
{summary.warningCount > 0 ? (
<span key="warnings" className="text-xs text-muted-foreground">{summary.warningCount} warnings</span>
) : null}
</div>
<div key="compare" className="mt-1 truncate font-mono text-xs text-muted-foreground">
{compareLabel}
</div>
</div>
<div key="actions" className="flex flex-wrap items-center gap-2">
<div key="layout" className="inline-flex gap-1" aria-label="Diff layout">
<button key="split" type="button" className={buttonClass(mode === "split")} onClick={() => setMode("split")}>
Split
</button>
<button key="unified" type="button" className={buttonClass(mode === "unified")} onClick={() => setMode("unified")}>
Unified
</button>
</div>
<button
key="line-wrap"
type="button"
className={buttonClass(lineWrap)}
onClick={() => setLineWrap((value) => !value)}
title={lineWrap ? "Disable line wrapping" : "Enable line wrapping"}
aria-pressed={lineWrap}
>
{lineWrap ? "Wrap on" : "Wrap lines"}
</button>
<div key="view" className="inline-flex gap-1" aria-label="Diff comparison">
<button
key="working-tree"
type="button"
className={buttonClass(effectiveView === "working-tree")}
onClick={() => {
viewTouchedRef.current = true;
setView("working-tree");
}}
>
Working tree
</button>
<button
key="head"
type="button"
className={buttonClass(effectiveView === "head")}
onClick={() => {
viewTouchedRef.current = true;
setView("head");
}}
>
Against ref
</button>
</div>
{view === "head" ? (
<input
key="base-ref"
className="h-8 w-40 rounded-md border border-border bg-background px-2.5 font-mono text-xs outline-none transition-colors placeholder:text-muted-foreground focus:border-foreground/40"
value={baseRef}
onChange={(event) => {
baseRefTouchedRef.current = true;
setBaseRef(event.target.value);
}}
placeholder="origin/master"
aria-label="Base ref"
/>
) : null}
{view === "working-tree" ? (
<button
key="untracked"
type="button"
className={buttonClass(includeUntracked)}
onClick={() => setIncludeUntracked((value) => !value)}
>
{includeUntracked ? "Untracked shown" : "Show untracked"}
</button>
) : null}
<button
key="refresh"
type="button"
className={iconButtonClass(false)}
onClick={() => refresh()}
title="Refresh changes"
aria-label="Refresh changes"
>
<RefreshCwIcon />
</button>
</div>
</div>
{loading ? (
<LoadingState />
) : error ? (
<ErrorState message={error.message} onRetry={refresh} />
) : files.length === 0 ? (
<EmptyState />
) : (
<div key="content" className="flex flex-col gap-3 lg:h-[70vh] lg:min-h-[560px] lg:max-h-[820px] lg:flex-row">
<aside
key="files"
className="relative flex min-w-0 flex-col border border-border bg-background lg:h-full lg:shrink-0 lg:overflow-hidden"
style={fileSidebarStyle}
>
<div key="heading" className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">
Files
</div>
<div key="list" className="max-h-[70vh] overflow-auto lg:max-h-none lg:flex-1">
{files.map((file, index) => (
<FileRow
key={`${file.path}:${index}`}
file={file}
active={file.path === selectedFile?.path}
expanded={expandedFiles.has(file.path)}
onSelect={() => selectFile(file.path)}
onToggle={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
onCopy={() => void copyPath(file.path)}
/>
))}
</div>
<div
role="separator"
aria-label="Resize file list"
aria-orientation="vertical"
aria-valuemin={MIN_FILE_SIDEBAR_WIDTH}
aria-valuemax={MAX_FILE_SIDEBAR_WIDTH}
aria-valuenow={fileSidebarWidth}
tabIndex={0}
className={[
"absolute inset-y-0 right-0 z-20 hidden w-3 cursor-col-resize touch-none outline-none lg:block",
"before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors",
"hover:before:bg-border focus-visible:before:bg-ring",
fileSidebarResizing ? "before:bg-ring" : "",
].join(" ")}
onPointerDown={handleFileSidebarPointerDown}
onPointerMove={handleFileSidebarPointerMove}
onPointerUp={endFileSidebarResize}
onPointerCancel={endFileSidebarResize}
onLostPointerCapture={endFileSidebarResize}
onKeyDown={handleFileSidebarKeyDown}
/>
</aside>
<main
key="diffs"
ref={diffScrollRef}
className="max-h-[70vh] min-w-0 flex-1 space-y-3 overflow-auto lg:h-full lg:max-h-none lg:pr-1"
onScroll={handleDiffScroll}
>
{files
.map((file, index) => (
<section
key={`${file.path}:${index}`}
ref={setFileSectionRef(file.path)}
className={file.path === selectedFile?.path ? "scroll-mt-2" : undefined}
>
<div
key="header"
className="sticky top-0 z-30 flex min-w-0 items-center justify-between gap-3 border border-b-0 border-border bg-background px-3 py-2 shadow-sm"
>
<div key="left" className="flex min-w-0 items-start gap-2">
<button
key="collapse"
type="button"
className="mt-0.5 text-muted-foreground hover:text-foreground"
title={expandedFiles.has(file.path) ? "Collapse file" : "Expand file"}
aria-label={expandedFiles.has(file.path) ? `Collapse ${file.path}` : `Expand ${file.path}`}
onClick={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
>
{expandedFiles.has(file.path) ? "" : "+"}
</button>
<button
key="select"
type="button"
className="min-w-0 text-left"
onClick={() => selectFile(file.path)}
>
<div key="path" className="truncate text-sm font-medium">{file.path}</div>
{file.oldPath ? (
<div key="old-path" className="truncate font-mono text-[11px] text-muted-foreground">
from {file.oldPath}
</div>
) : null}
</button>
</div>
<div key="actions" className="flex shrink-0 items-center gap-1">
<button
key="copy"
type="button"
className={iconButtonClass(false)}
title="Copy path"
aria-label={`Copy ${file.path}`}
onClick={() => void copyPath(file.path)}
>
</button>
</div>
</div>
{expandedFiles.has(file.path) ? (
<FileDiffPanel key="diff" file={file} mode={mode} lineWrap={lineWrap} />
) : (
<CollapsedFilePanel
key="collapsed"
file={file}
onExpand={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
/>
)}
</section>
))}
</main>
</div>
)}
</div>
);
}
@@ -0,0 +1,108 @@
import { definePlugin, runWorker, type PluginContext } from "@paperclipai/plugin-sdk";
import { workspaceDiffQuerySchema } from "./contracts.js";
import { workspaceDiffService } from "./workspace-diff.js";
const PLUGIN_NAME = "workspace-diff";
function readString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function readOptionalString(value: unknown): string | null {
const trimmed = readString(value);
return trimmed || null;
}
export function resolveDefaultBaseRef(input: {
workspaceBaseRef?: unknown;
projectWorkspaceDefaultRef?: unknown;
projectWorkspaceRepoRef?: unknown;
}): string | null {
return readOptionalString(input.workspaceBaseRef)
?? readOptionalString(input.projectWorkspaceDefaultRef)
?? readOptionalString(input.projectWorkspaceRepoRef);
}
async function resolveProjectWorkspaceDefaultBaseRef(input: {
ctx: PluginContext;
projectId: string;
companyId: string;
projectWorkspaceId?: string | null;
}): Promise<string | null> {
if (!input.projectId) return null;
const workspaces = await input.ctx.projects.listWorkspaces(input.projectId, input.companyId);
const projectWorkspace = input.projectWorkspaceId
? workspaces.find((candidate) => candidate.id === input.projectWorkspaceId)
: workspaces.find((candidate) => candidate.isPrimary) ?? workspaces[0] ?? null;
return projectWorkspace
? resolveDefaultBaseRef({
projectWorkspaceDefaultRef: projectWorkspace.defaultRef,
projectWorkspaceRepoRef: projectWorkspace.repoRef,
})
: null;
}
const plugin = definePlugin({
async setup(ctx) {
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
const workspaceDiff = workspaceDiffService();
ctx.data.register("workspace-diff", async (params: Record<string, unknown>) => {
const workspaceId = readString(params.workspaceId);
const companyId = readString(params.companyId);
if (!workspaceId || !companyId) {
throw new Error("workspaceId and companyId are required");
}
if (params.entityType === "project_workspace") {
const projectId = readString(params.projectId);
if (!projectId) {
throw new Error("projectId is required for project workspace diffs");
}
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
const workspace = workspaces.find((candidate) => candidate.id === workspaceId);
if (!workspace) {
throw new Error("Workspace not found");
}
return workspaceDiff.getDiff({
id: workspace.id,
companyId,
cwd: workspace.path,
baseRef: resolveDefaultBaseRef({
projectWorkspaceDefaultRef: workspace.defaultRef,
projectWorkspaceRepoRef: workspace.repoRef,
}),
}, workspaceDiffQuerySchema.parse(params));
}
const workspace = await ctx.executionWorkspaces.get(workspaceId, companyId);
if (!workspace) {
throw new Error("Workspace not found");
}
let projectWorkspaceDefaultBaseRef: string | null = null;
if (!readOptionalString(workspace.baseRef)) {
projectWorkspaceDefaultBaseRef = await resolveProjectWorkspaceDefaultBaseRef({
ctx,
projectId: workspace.projectId || readString(params.projectId),
companyId,
projectWorkspaceId: workspace.projectWorkspaceId,
});
}
return workspaceDiff.getDiff({
...workspace,
baseRef: resolveDefaultBaseRef({
workspaceBaseRef: workspace.baseRef,
projectWorkspaceDefaultRef: projectWorkspaceDefaultBaseRef,
}),
}, workspaceDiffQuerySchema.parse(params));
});
},
async onHealth() {
return { status: "ok", message: `${PLUGIN_NAME} ready` };
},
});
export default plugin;
runWorker(plugin, import.meta.url);
@@ -0,0 +1,845 @@
import { execFile } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk";
import type {
WorkspaceDiffCaps,
WorkspaceDiffFile,
WorkspaceDiffFilePatch,
WorkspaceDiffFileStatus,
WorkspaceDiffPatchKind,
WorkspaceDiffQueryOptions,
WorkspaceDiffResponse,
WorkspaceDiffWarning,
WorkspaceDiffWarningCode,
} from "./contracts.js";
const execFileAsync = promisify(execFile);
export const WORKSPACE_DIFF_CAPS: WorkspaceDiffCaps = {
maxFiles: 200,
maxFileBytes: 512 * 1024,
maxPatchBytes: 256 * 1024,
maxTotalPatchBytes: 1024 * 1024,
};
const GIT_TIMEOUT_MS = 10_000;
const GIT_LIST_MAX_BUFFER = 2 * 1024 * 1024;
const OPEN_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0;
interface GitStatusEntry {
status: WorkspaceDiffFileStatus;
path: string;
oldPath: string | null;
}
type DiffScope = "staged" | "unstaged" | "head";
interface MutableWorkspaceDiffFile extends WorkspaceDiffFile {
patchScopes: DiffScope[];
}
interface PatchBudget {
totalPatchBytes: number;
}
type WorkspaceDiffTarget = Pick<PluginExecutionWorkspaceMetadata, "id" | "companyId" | "cwd" | "baseRef">;
function warning(code: WorkspaceDiffWarningCode, message: string, filePath: string | null = null): WorkspaceDiffWarning {
return { code, message, path: filePath };
}
function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record<string, unknown> = {}) {
const error = new Error(message);
Object.assign(error, { code, status: 422, details: { code, ...details } });
return error;
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return String(error);
}
async function runGit(cwd: string, args: string[], maxBuffer = GIT_LIST_MAX_BUFFER) {
try {
return await execFileAsync("git", ["-C", cwd, ...args], {
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer,
});
} catch (error) {
const stderr = typeof (error as { stderr?: unknown }).stderr === "string"
? String((error as { stderr?: unknown }).stderr).trim()
: "";
const message = stderr || toErrorMessage(error);
throw workspaceDiffError("git_command_failed", message, { args });
}
}
async function realDirectory(value: string, code: WorkspaceDiffWarningCode) {
if (!path.isAbsolute(value)) {
throw workspaceDiffError(code, "Execution workspace path must be absolute", { cwd: value });
}
let stat: Awaited<ReturnType<typeof fs.stat>>;
try {
stat = await fs.stat(value);
} catch {
throw workspaceDiffError(code, "Execution workspace path does not exist", { cwd: value });
}
if (!stat.isDirectory()) {
throw workspaceDiffError(code, "Execution workspace path is not a directory", { cwd: value });
}
return await fs.realpath(value);
}
function isWithinDirectory(childPath: string, parentPath: string) {
const relative = path.relative(parentPath, childPath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
async function resolveWorkspacePaths(workspace: WorkspaceDiffTarget) {
if (!workspace.cwd?.trim()) {
throw workspaceDiffError(
"missing_cwd",
"Execution workspace needs a local path before Paperclip can inspect diffs",
{ workspaceId: workspace.id },
);
}
const cwd = await realDirectory(workspace.cwd.trim(), "workspace_path_invalid");
let repoRoot: string;
try {
repoRoot = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim();
} catch {
throw workspaceDiffError(
"non_git_workspace",
"Execution workspace path is not inside a git repository",
{ workspaceId: workspace.id, cwd },
);
}
const repoRootReal = await realDirectory(repoRoot, "non_git_workspace");
if (!isWithinDirectory(cwd, repoRootReal)) {
throw workspaceDiffError(
"workspace_path_invalid",
"Execution workspace path resolved outside its git repository",
{ workspaceId: workspace.id, cwd, repoRoot: repoRootReal },
);
}
return { cwd, repoRoot: repoRootReal };
}
function normalizePathFilter(rawPath: string) {
const value = rawPath.trim().replaceAll("\\", "/");
if (!value || value === ".") return null;
if (value.includes("\0") || value.startsWith("/")) {
throw workspaceDiffError("path_filter_invalid", "Path filters must be relative workspace paths", { path: rawPath });
}
const normalized = path.posix.normalize(value);
if (
normalized === "." ||
normalized === ".." ||
normalized.startsWith("../") ||
normalized.includes("/../")
) {
throw workspaceDiffError(
"path_filter_invalid",
"Path filters must not contain traversal segments",
{ path: rawPath },
);
}
return normalized;
}
function normalizePathFilters(paths: string[]) {
return Array.from(new Set(paths.map(normalizePathFilter).filter((value): value is string => Boolean(value))));
}
function statusFromGitStatus(status: string): WorkspaceDiffFileStatus {
if (status.startsWith("R")) return "renamed";
if (status.startsWith("C")) return "copied";
switch (status[0]) {
case "A":
return "added";
case "D":
return "deleted";
case "M":
return "modified";
case "T":
return "type_changed";
default:
return "unknown";
}
}
function parseNameStatus(output: string): GitStatusEntry[] {
const tokens = output.split("\0").filter(Boolean);
const entries: GitStatusEntry[] = [];
let index = 0;
while (index < tokens.length) {
const statusCode = tokens[index++] ?? "";
if (!statusCode) continue;
if (statusCode.startsWith("R") || statusCode.startsWith("C")) {
const oldPath = tokens[index++] ?? "";
const newPath = tokens[index++] ?? "";
if (newPath) {
entries.push({
status: statusFromGitStatus(statusCode),
path: newPath,
oldPath: oldPath || null,
});
}
continue;
}
const filePath = tokens[index++] ?? "";
if (filePath) {
entries.push({
status: statusFromGitStatus(statusCode),
path: filePath,
oldPath: null,
});
}
}
return entries;
}
async function readDiffNameStatus(cwd: string, scopeArgs: string[], paths: string[]) {
const result = await runGit(cwd, [
"diff",
"--name-status",
"-z",
"--no-ext-diff",
"--find-renames",
...scopeArgs,
"--",
...paths,
]);
return parseNameStatus(result.stdout);
}
async function readUntrackedPaths(cwd: string, paths: string[]) {
const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...paths]);
return result.stdout.split("\0").filter(Boolean);
}
function ensureFile(
files: Map<string, MutableWorkspaceDiffFile>,
filePath: string,
status: WorkspaceDiffFileStatus,
oldPath: string | null,
) {
const existing = files.get(filePath);
if (existing) {
if (existing.status === "unknown" || status === "renamed" || status === "copied") {
existing.status = status;
}
if (!existing.oldPath && oldPath) existing.oldPath = oldPath;
return existing;
}
const file: MutableWorkspaceDiffFile = {
path: filePath,
oldPath,
status,
staged: false,
unstaged: false,
untracked: false,
binary: false,
oversized: false,
truncated: false,
additions: 0,
deletions: 0,
sizeBytes: null,
patches: [],
warnings: [],
patchScopes: [],
};
files.set(filePath, file);
return file;
}
function addStatusEntries(
files: Map<string, MutableWorkspaceDiffFile>,
entries: GitStatusEntry[],
scope: DiffScope,
) {
for (const entry of entries) {
const file = ensureFile(files, entry.path, entry.status, entry.oldPath);
if (scope === "staged") file.staged = true;
else if (scope === "unstaged") file.unstaged = true;
if (!file.patchScopes.includes(scope)) file.patchScopes.push(scope);
}
}
function parseNumstat(output: string) {
const line = output.split(/\r?\n/).find(Boolean);
if (!line) return { additions: 0, deletions: 0, binary: false };
const [additionsRaw, deletionsRaw] = line.split(/\t/);
if (additionsRaw === "-" || deletionsRaw === "-") {
return { additions: 0, deletions: 0, binary: true };
}
return {
additions: Number.parseInt(additionsRaw ?? "0", 10) || 0,
deletions: Number.parseInt(deletionsRaw ?? "0", 10) || 0,
binary: false,
};
}
async function readNumstat(cwd: string, scopeArgs: string[], filePath: string) {
const result = await runGit(cwd, [
"diff",
"--numstat",
"--no-ext-diff",
"--find-renames",
...scopeArgs,
"--",
filePath,
], 128 * 1024);
return parseNumstat(result.stdout);
}
async function statWorkspaceFile(repoRoot: string, filePath: string) {
const resolved = await resolveWorkspaceFilePath(repoRoot, filePath);
if (resolved.status !== "ok") return null;
let handle: Awaited<ReturnType<typeof fs.open>>;
try {
handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW);
} catch {
return null;
}
try {
const stat = await handle.stat();
return stat.isFile() ? stat.size : null;
} catch {
return null;
} finally {
await handle.close();
}
}
async function resolveWorkspaceFilePath(repoRoot: string, filePath: string): Promise<
| { status: "ok"; realPath: string }
| { status: "missing" }
| { status: "outside_workspace" }
> {
const target = path.resolve(repoRoot, filePath);
if (!isWithinDirectory(target, repoRoot)) return { status: "outside_workspace" };
try {
const realPath = await fs.realpath(target);
if (!isWithinDirectory(realPath, repoRoot)) return { status: "outside_workspace" };
return { status: "ok", realPath };
} catch {
return { status: "missing" };
}
}
function isMaxBufferError(error: unknown) {
return typeof error === "object"
&& error !== null
&& "code" in error
&& (error as { code?: unknown }).code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
}
async function readPatchOutput(cwd: string, args: string[]) {
try {
return await execFileAsync("git", ["-C", cwd, ...args], {
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: WORKSPACE_DIFF_CAPS.maxPatchBytes + 64 * 1024,
});
} catch (error) {
if (isMaxBufferError(error)) {
return null;
}
const stderr = typeof (error as { stderr?: unknown }).stderr === "string"
? String((error as { stderr?: unknown }).stderr).trim()
: "";
throw workspaceDiffError("git_command_failed", stderr || toErrorMessage(error), { args });
}
}
function reservePatchBytes(
patch: string,
budget: PatchBudget,
filePath: string,
warnings: WorkspaceDiffWarning[],
) {
const patchBytes = Buffer.byteLength(patch, "utf8");
if (patchBytes > WORKSPACE_DIFF_CAPS.maxPatchBytes) {
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", filePath));
return null;
}
if (budget.totalPatchBytes + patchBytes > WORKSPACE_DIFF_CAPS.maxTotalPatchBytes) {
warnings.push(warning("patch_truncated", "Workspace diff exceeded the total patch cap.", filePath));
return null;
}
budget.totalPatchBytes += patchBytes;
return patch;
}
async function buildTrackedPatch(input: {
cwd: string;
repoRoot: string;
filePath: string;
kind: WorkspaceDiffPatchKind;
scopeArgs: string[];
budget: PatchBudget;
}): Promise<WorkspaceDiffFilePatch> {
const warnings: WorkspaceDiffWarning[] = [];
const numstat = await readNumstat(input.cwd, input.scopeArgs, input.filePath);
const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath);
if (numstat.binary) {
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
return {
kind: input.kind,
patch: null,
additions: 0,
deletions: 0,
binary: true,
oversized: false,
truncated: false,
warnings,
};
}
if (sizeBytes !== null && sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
warnings.push(warning("file_oversized", "File is too large to include a text patch.", input.filePath));
return {
kind: input.kind,
patch: null,
additions: numstat.additions,
deletions: numstat.deletions,
binary: false,
oversized: true,
truncated: false,
warnings,
};
}
const patchOutput = await readPatchOutput(input.cwd, [
"diff",
"--no-ext-diff",
"--find-renames",
"--unified=3",
...input.scopeArgs,
"--",
input.filePath,
]);
if (!patchOutput) {
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", input.filePath));
return {
kind: input.kind,
patch: null,
additions: numstat.additions,
deletions: numstat.deletions,
binary: false,
oversized: false,
truncated: true,
warnings,
};
}
const patch = reservePatchBytes(patchOutput.stdout, input.budget, input.filePath, warnings);
return {
kind: input.kind,
patch,
additions: numstat.additions,
deletions: numstat.deletions,
binary: false,
oversized: false,
truncated: patch === null,
warnings,
};
}
function isProbablyBinary(buffer: Buffer) {
return buffer.subarray(0, Math.min(buffer.length, 8_000)).includes(0);
}
function countAddedLines(content: string) {
if (content.length === 0) return 0;
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
}
function buildUntrackedPatch(filePath: string, content: string) {
const lines = content.length === 0 ? [] : content.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
const lineCount = countAddedLines(content);
const header = [
`diff --git a/${filePath} b/${filePath}`,
"new file mode 100644",
"--- /dev/null",
`+++ b/${filePath}`,
];
if (lineCount === 0) return `${header.join("\n")}\n`;
const hunkLines = lines.map((line) => `+${line}`).join("\n");
return [...header, `@@ -0,0 +1,${lineCount} @@`, hunkLines, ""].join("\n");
}
async function buildUntrackedFilePatch(input: {
repoRoot: string;
filePath: string;
budget: PatchBudget;
}): Promise<WorkspaceDiffFilePatch> {
const warnings: WorkspaceDiffWarning[] = [];
const resolved = await resolveWorkspaceFilePath(input.repoRoot, input.filePath);
if (resolved.status === "outside_workspace") {
warnings.push(warning(
"symlink_target_outside_workspace",
"Untracked file resolves outside the workspace and is summarized without reading target bytes.",
input.filePath,
));
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
if (resolved.status === "missing") {
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
let handle: Awaited<ReturnType<typeof fs.open>>;
try {
handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW);
} catch {
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
let sizeBytes: number;
let buffer: Buffer | null = null;
try {
const stat = await handle.stat();
if (!stat.isFile()) {
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
sizeBytes = stat.size;
if (sizeBytes <= WORKSPACE_DIFF_CAPS.maxFileBytes) {
buffer = await handle.readFile();
}
} finally {
await handle.close();
}
if (sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
warnings.push(warning("file_oversized", "Untracked file is too large to include a text patch.", input.filePath));
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: true,
truncated: false,
warnings,
};
}
if (!buffer) {
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
if (isProbablyBinary(buffer)) {
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: true,
oversized: false,
truncated: false,
warnings,
};
}
const content = buffer.toString("utf8");
const patch = reservePatchBytes(buildUntrackedPatch(input.filePath, content), input.budget, input.filePath, warnings);
return {
kind: "untracked",
patch,
additions: countAddedLines(content),
deletions: 0,
binary: false,
oversized: false,
truncated: patch === null,
warnings,
};
}
function applyPatchToFile(file: MutableWorkspaceDiffFile, patch: WorkspaceDiffFilePatch, sizeBytes: number | null) {
file.patches.push(patch);
file.additions += patch.additions;
file.deletions += patch.deletions;
file.binary = file.binary || patch.binary;
file.oversized = file.oversized || patch.oversized;
file.truncated = file.truncated || patch.truncated;
file.warnings.push(...patch.warnings);
if (file.sizeBytes === null && sizeBytes !== null) file.sizeBytes = sizeBytes;
}
function finalizeStats(files: WorkspaceDiffFile[]) {
return {
fileCount: files.length,
stagedFileCount: files.filter((file) => file.staged).length,
unstagedFileCount: files.filter((file) => file.unstaged).length,
untrackedFileCount: files.filter((file) => file.untracked).length,
binaryFileCount: files.filter((file) => file.binary).length,
oversizedFileCount: files.filter((file) => file.oversized).length,
truncatedFileCount: files.filter((file) => file.truncated).length,
additions: files.reduce((sum, file) => sum + file.additions, 0),
deletions: files.reduce((sum, file) => sum + file.deletions, 0),
};
}
async function resolveHeadSha(cwd: string) {
try {
return (await runGit(cwd, ["rev-parse", "HEAD"], 128 * 1024)).stdout.trim() || null;
} catch {
return null;
}
}
async function resolveVerifiedGitRef(cwd: string, refName: string) {
const trimmed = refName.trim();
if (!trimmed) return null;
try {
await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${trimmed}^{commit}`], {
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: 128 * 1024,
});
return trimmed;
} catch {
return null;
}
}
async function resolveGitUpstreamRef(cwd: string) {
try {
const upstream = (await execFileAsync(
"git",
["-C", cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
{
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: 128 * 1024,
},
)).stdout.trim();
return upstream ? await resolveVerifiedGitRef(cwd, upstream) : null;
} catch {
return null;
}
}
async function resolveInferredDefaultBaseRef(cwd: string) {
const upstream = await resolveGitUpstreamRef(cwd);
if (upstream) return upstream;
const candidates = ["origin/master", "origin/main", "master", "main"];
const resolvedCandidates = await Promise.all(
candidates.map((candidate) => resolveVerifiedGitRef(cwd, candidate)),
);
for (const resolved of resolvedCandidates) {
if (resolved) return resolved;
}
return null;
}
async function resolveDefaultDiffBaseRef(cwd: string, workspace: WorkspaceDiffTarget) {
return workspace.baseRef?.trim() || await resolveInferredDefaultBaseRef(cwd);
}
async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) {
const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null;
if (!resolvedBaseRef) {
throw workspaceDiffError(
"base_ref_missing",
"A baseRef query parameter or execution workspace baseRef is required for head diffs",
{ workspaceId: workspace.id },
);
}
try {
await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${resolvedBaseRef}^{commit}`], {
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: 128 * 1024,
});
} catch {
throw workspaceDiffError(
"base_ref_invalid",
`Could not resolve baseRef "${resolvedBaseRef}" in this workspace`,
{ workspaceId: workspace.id, baseRef: resolvedBaseRef },
);
}
return resolvedBaseRef;
}
async function collectFiles(input: {
cwd: string;
workspace: WorkspaceDiffTarget;
query: WorkspaceDiffQueryOptions;
paths: string[];
}) {
const files = new Map<string, MutableWorkspaceDiffFile>();
let baseRef: string | null = null;
if (input.query.view === "head") {
baseRef = await resolveBaseRef(input.cwd, input.query.baseRef, input.workspace);
addStatusEntries(
files,
await readDiffNameStatus(input.cwd, [`${baseRef}...HEAD`], input.paths),
"head",
);
} else {
addStatusEntries(files, await readDiffNameStatus(input.cwd, ["--cached"], input.paths), "staged");
addStatusEntries(files, await readDiffNameStatus(input.cwd, [], input.paths), "unstaged");
if (input.query.includeUntracked) {
for (const untrackedPath of await readUntrackedPaths(input.cwd, input.paths)) {
const file = ensureFile(files, untrackedPath, "untracked", null);
file.untracked = true;
if (!file.patchScopes.includes("unstaged")) file.patchScopes.push("unstaged");
}
}
}
return { files, baseRef };
}
export function workspaceDiffService() {
return {
async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise<WorkspaceDiffResponse> {
const { cwd, repoRoot } = await resolveWorkspacePaths(workspace);
const defaultBaseRef = await resolveDefaultDiffBaseRef(cwd, workspace);
const workspaceWithDefaultBaseRef = { ...workspace, baseRef: defaultBaseRef };
const paths = normalizePathFilters(query.paths);
const warnings: WorkspaceDiffWarning[] = [];
const { files: filesByPath, baseRef } = await collectFiles({
cwd,
workspace: workspaceWithDefaultBaseRef,
query,
paths,
});
const allFiles = Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path));
const cappedFiles = allFiles.slice(0, WORKSPACE_DIFF_CAPS.maxFiles);
if (allFiles.length > cappedFiles.length) {
warnings.push(warning(
"file_count_truncated",
`Workspace diff includes ${allFiles.length} files, so only the first ${WORKSPACE_DIFF_CAPS.maxFiles} are returned.`,
));
}
const patchBudget: PatchBudget = { totalPatchBytes: 0 };
for (const file of cappedFiles) {
if (query.view === "head") {
const patch = await buildTrackedPatch({
cwd,
repoRoot,
filePath: file.path,
kind: "head",
scopeArgs: [`${baseRef}...HEAD`],
budget: patchBudget,
});
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
continue;
}
if (file.staged) {
const patch = await buildTrackedPatch({
cwd,
repoRoot,
filePath: file.path,
kind: "staged",
scopeArgs: ["--cached"],
budget: patchBudget,
});
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
}
if (file.unstaged) {
const patch = await buildTrackedPatch({
cwd,
repoRoot,
filePath: file.path,
kind: "unstaged",
scopeArgs: [],
budget: patchBudget,
});
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
}
if (file.untracked) {
const patch = await buildUntrackedFilePatch({
repoRoot,
filePath: file.path,
budget: patchBudget,
});
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
}
}
const files = cappedFiles.map(({ patchScopes: _patchScopes, ...file }) => file);
const patchWarnings = files.flatMap((file) => file.warnings);
return {
workspaceId: workspace.id,
companyId: workspace.companyId,
view: query.view,
baseRef,
defaultBaseRef,
headSha: await resolveHeadSha(cwd),
includeUntracked: query.includeUntracked,
paths,
files,
stats: finalizeStats(files),
warnings: [...warnings, ...patchWarnings],
caps: WORKSPACE_DIFF_CAPS,
truncated: warnings.some((item) => item.code === "file_count_truncated")
|| files.some((file) => file.truncated),
};
},
};
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { workspaceDiffQuerySchema, workspaceDiffResponseSchema } from "../src/contracts.js";
import { diffResponse } from "./fixtures.js";
describe("workspace diff plugin contracts", () => {
it("normalizes query options from plugin data parameters", () => {
expect(workspaceDiffQuerySchema.parse({
view: "head",
baseRef: " main ",
includeUntracked: "false",
path: ["src/app.ts, README.md", "packages/shared/src/index.ts"],
})).toEqual({
view: "head",
baseRef: "main",
includeUntracked: false,
paths: ["src/app.ts", "README.md", "packages/shared/src/index.ts"],
});
});
it("validates the plugin-owned response shape", () => {
expect(workspaceDiffResponseSchema.parse(diffResponse())).toMatchObject({
workspaceId: "11111111-1111-4111-8111-111111111111",
stats: { fileCount: 1 },
});
});
});
@@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import {
buildFilePatch,
buildFilePatches,
diffSummary,
initialExpandedFileSet,
LONG_DIFF_LINE_THRESHOLD,
nextExpandedFileSet,
statusLabel,
toFileViewModels,
} from "../src/diff-model.js";
import { changedFile, diffResponse } from "./fixtures.js";
describe("workspace diff UI model", () => {
it("summarizes changed files and line counts", () => {
const diff = diffResponse();
expect(diffSummary(diff)).toMatchObject({
changedLabel: "1 file",
lineLabel: "+1 / -1",
warningCount: 0,
truncated: false,
});
expect(toFileViewModels(diff)[0]).toMatchObject({
path: "src/app.ts",
status: "modified",
patchKinds: ["unstaged"],
lineCount: 7,
longDiff: false,
});
});
it("represents empty workspace diffs", () => {
const diff = diffResponse({ files: [] });
expect(toFileViewModels(diff)).toEqual([]);
expect(diffSummary(diff).changedLabel).toBe("0 files");
});
it("surfaces truncation and file warnings", () => {
const warning = { code: "patch_truncated" as const, message: "Patch was truncated.", path: "src/app.ts" };
const file = changedFile({
truncated: true,
warnings: [warning],
patches: [],
});
const diff = diffResponse({ files: [file], truncated: true, warnings: [warning] });
expect(buildFilePatch(file)).toBeNull();
expect(toFileViewModels(diff)[0]?.warnings).toEqual([warning]);
expect(diffSummary(diff)).toMatchObject({
warningCount: 1,
truncated: true,
});
});
it("does not duplicate aggregated patch warnings", () => {
const warning = { code: "patch_truncated" as const, message: "Patch was truncated.", path: "src/app.ts" };
const file = changedFile({
warnings: [warning],
patches: [
{
kind: "unstaged",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: true,
warnings: [warning],
},
],
});
const diff = diffResponse({ files: [file], warnings: [warning] });
expect(toFileViewModels(diff)[0]?.warnings).toEqual([warning]);
expect(diffSummary(diff).warningCount).toBe(1);
});
it("keeps staged and unstaged patches renderable as separate single-file diffs", () => {
const stagedPatch = [
"diff --git a/src/app.ts b/src/app.ts",
"index 1111111..2222222 100644",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -1 +1 @@",
"-export const value = 1;",
"+export const value = 2;",
"",
].join("\n");
const unstagedPatch = [
"diff --git a/src/app.ts b/src/app.ts",
"index 2222222..3333333 100644",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -3 +3 @@",
"-export const label = 'old';",
"+export const label = 'new';",
"",
].join("\n");
const file = changedFile({
staged: true,
unstaged: true,
patches: [
{
kind: "staged",
patch: stagedPatch,
additions: 1,
deletions: 1,
binary: false,
oversized: false,
truncated: false,
warnings: [],
},
{
kind: "unstaged",
patch: unstagedPatch,
additions: 1,
deletions: 1,
binary: false,
oversized: false,
truncated: false,
warnings: [],
},
],
});
const patches = buildFilePatches(file);
const viewModel = toFileViewModels(diffResponse({ files: [file] }))[0];
expect(buildFilePatch(file)).toBe(stagedPatch.trimEnd());
expect(patches.map((patch) => patch.kind)).toEqual(["staged", "unstaged"]);
expect(patches.map((patch) => patch.patch?.match(/^diff --git/gm)?.length ?? 0)).toEqual([1, 1]);
expect(viewModel?.patches).toHaveLength(2);
expect(viewModel?.patchKinds).toEqual(["staged", "unstaged"]);
});
it("marks long text diffs so the UI can fold them by default", () => {
const longPatch = [
"diff --git a/src/large.ts b/src/large.ts",
"index 1111111..2222222 100644",
"--- a/src/large.ts",
"+++ b/src/large.ts",
"@@ -1,1 +1,1 @@",
...Array.from({ length: LONG_DIFF_LINE_THRESHOLD }, (_, index) => `+export const value${index} = ${index};`),
"",
].join("\n");
const files = toFileViewModels(diffResponse({
files: [
changedFile({ path: "src/small.ts" }),
changedFile({
path: "src/large.ts",
additions: LONG_DIFF_LINE_THRESHOLD,
deletions: 0,
patches: [
{
kind: "unstaged",
patch: longPatch,
additions: LONG_DIFF_LINE_THRESHOLD,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings: [],
},
],
}),
],
}));
const longFile = files.find((file) => file.path === "src/large.ts");
const defaultExpanded = initialExpandedFileSet(files);
expect(longFile?.lineCount).toBeGreaterThan(LONG_DIFF_LINE_THRESHOLD);
expect(longFile?.longDiff).toBe(true);
expect(defaultExpanded.has("src/small.ts")).toBe(true);
expect(defaultExpanded.has("src/large.ts")).toBe(false);
});
it("toggles expanded file state without mutating the current set", () => {
const current = new Set(["a.ts"]);
const collapsed = nextExpandedFileSet(current, "a.ts");
const expanded = nextExpandedFileSet(current, "b.ts");
expect(current.has("a.ts")).toBe(true);
expect(collapsed.has("a.ts")).toBe(false);
expect(expanded.has("b.ts")).toBe(true);
});
it("labels file statuses for the sidebar", () => {
expect(statusLabel("untracked")).toBe("Untracked");
expect(statusLabel("type_changed")).toBe("Type changed");
});
});
@@ -0,0 +1,78 @@
import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "../src/contracts.js";
export function changedFile(overrides: Partial<WorkspaceDiffFile> = {}): WorkspaceDiffFile {
return {
path: "src/app.ts",
oldPath: null,
status: "modified",
staged: false,
unstaged: true,
untracked: false,
binary: false,
oversized: false,
truncated: false,
additions: 1,
deletions: 1,
sizeBytes: 120,
patches: [
{
kind: "unstaged",
patch: [
"diff --git a/src/app.ts b/src/app.ts",
"index 1111111..2222222 100644",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -1 +1 @@",
"-export const value = 1;",
"+export const value = 2;",
"",
].join("\n"),
additions: 1,
deletions: 1,
binary: false,
oversized: false,
truncated: false,
warnings: [],
},
],
warnings: [],
...overrides,
};
}
export function diffResponse(overrides: Partial<WorkspaceDiffResponse> = {}): WorkspaceDiffResponse {
const files = overrides.files ?? [changedFile()];
const additions = files.reduce((sum, file) => sum + file.additions, 0);
const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
return {
workspaceId: "11111111-1111-4111-8111-111111111111",
companyId: "22222222-2222-4222-8222-222222222222",
view: "working-tree",
baseRef: null,
defaultBaseRef: null,
headSha: null,
includeUntracked: true,
paths: [],
files,
stats: {
fileCount: files.length,
stagedFileCount: files.filter((file) => file.staged).length,
unstagedFileCount: files.filter((file) => file.unstaged).length,
untrackedFileCount: files.filter((file) => file.untracked).length,
binaryFileCount: files.filter((file) => file.binary).length,
oversizedFileCount: files.filter((file) => file.oversized).length,
truncatedFileCount: files.filter((file) => file.truncated).length,
additions,
deletions,
},
warnings: [],
caps: {
maxFiles: 200,
maxFileBytes: 524288,
maxPatchBytes: 131072,
maxTotalPatchBytes: 1048576,
},
truncated: false,
...overrides,
};
}
@@ -0,0 +1,347 @@
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import manifest from "../src/manifest.js";
import plugin, { resolveDefaultBaseRef } from "../src/worker.js";
const execFileAsync = promisify(execFile);
const tempRoots: string[] = [];
async function git(cwd: string, args: string[]) {
return execFileAsync("git", ["-C", cwd, ...args], { cwd });
}
async function createGitWorkspace() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-diff-plugin-"));
tempRoots.push(root);
await fs.mkdir(path.join(root, "src"), { recursive: true });
await git(root, ["init"]);
await git(root, ["config", "user.email", "paperclip@example.com"]);
await git(root, ["config", "user.name", "Paperclip Test"]);
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 1;\n");
await git(root, ["add", "src/app.ts"]);
await git(root, ["commit", "-m", "initial"]);
await git(root, ["branch", "-M", "main"]);
return root;
}
describe("workspace diff plugin", () => {
afterEach(async () => {
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
tempRoots.length = 0;
});
it("declares workspace Changes tabs and workspace read capabilities", () => {
expect(manifest.capabilities).toContain("ui.detailTab.register");
expect(manifest.capabilities).toContain("execution.workspaces.read");
expect(manifest.capabilities).toContain("project.workspaces.read");
expect(manifest.ui?.slots).toContainEqual(expect.objectContaining({
type: "detailTab",
displayName: "Changes",
entityTypes: ["execution_workspace", "project_workspace"],
}));
});
it("fetches changed execution workspace diffs from host metadata", async () => {
const root = await createGitWorkspace();
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 2;\n");
const harness = createTestHarness({ manifest });
harness.seed({
executionWorkspaces: [{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
path: root,
cwd: root,
repoUrl: null,
baseRef: "HEAD",
branchName: "main",
providerType: "git_worktree",
providerMetadata: null,
}],
});
await plugin.definition.setup(harness.ctx);
const result = await harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
view: "working-tree",
includeUntracked: false,
paths: ["src/app.ts"],
});
expect(result).toMatchObject({
stats: { fileCount: 1 },
files: [expect.objectContaining({ path: "src/app.ts" })],
});
});
it("returns an empty diff when the workspace has no changes", async () => {
const root = await createGitWorkspace();
const harness = createTestHarness({ manifest });
harness.seed({
executionWorkspaces: [{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
path: root,
cwd: root,
repoUrl: null,
baseRef: "HEAD",
branchName: "main",
providerType: "git_worktree",
providerMetadata: null,
}],
});
await plugin.definition.setup(harness.ctx);
await expect(harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
})).resolves.toMatchObject({ files: [], truncated: false });
});
it("fetches project workspace diffs from generic project workspace metadata", async () => {
const root = await createGitWorkspace();
await git(root, ["checkout", "-b", "feature"]);
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 3;\n");
await git(root, ["add", "src/app.ts"]);
await git(root, ["commit", "-m", "project workspace change"]);
const harness = createTestHarness({ manifest });
harness.ctx.projects.listWorkspaces = async (projectId, companyId) => {
expect(projectId).toBe("project-1");
expect(companyId).toBe("company-1");
return [{
id: "workspace-1",
projectId: "project-1",
name: "Primary",
path: root,
repoUrl: null,
repoRef: "feature",
defaultRef: "main",
isPrimary: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}];
};
await plugin.definition.setup(harness.ctx);
const result = await harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
projectId: "project-1",
entityType: "project_workspace",
view: "head",
includeUntracked: false,
});
expect(result).toMatchObject({
baseRef: "main",
defaultBaseRef: "main",
stats: { fileCount: 1 },
files: [expect.objectContaining({ path: "src/app.ts" })],
});
});
it("resolves the default base ref from workspace and project workspace metadata", () => {
expect(resolveDefaultBaseRef({
workspaceBaseRef: " release/main ",
projectWorkspaceDefaultRef: "origin/main",
projectWorkspaceRepoRef: "feature",
})).toBe("release/main");
expect(resolveDefaultBaseRef({
workspaceBaseRef: null,
projectWorkspaceDefaultRef: " origin/main ",
projectWorkspaceRepoRef: "feature",
})).toBe("origin/main");
expect(resolveDefaultBaseRef({
workspaceBaseRef: "",
projectWorkspaceDefaultRef: null,
projectWorkspaceRepoRef: " feature ",
})).toBe("feature");
expect(resolveDefaultBaseRef({
workspaceBaseRef: "",
projectWorkspaceDefaultRef: null,
projectWorkspaceRepoRef: "",
})).toBeNull();
});
it("uses project workspace default refs for execution workspace head diffs", async () => {
const root = await createGitWorkspace();
await git(root, ["checkout", "-b", "feature"]);
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 4;\n");
await git(root, ["add", "src/app.ts"]);
await git(root, ["commit", "-m", "feature change"]);
const harness = createTestHarness({ manifest });
harness.seed({
executionWorkspaces: [{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "project-workspace-1",
path: root,
cwd: root,
repoUrl: null,
baseRef: null,
branchName: "feature",
providerType: "git_worktree",
providerMetadata: null,
}],
});
harness.ctx.projects.listWorkspaces = async (projectId, companyId) => {
expect(projectId).toBe("project-1");
expect(companyId).toBe("company-1");
return [{
id: "project-workspace-1",
projectId: "project-1",
name: "Primary",
path: root,
repoUrl: null,
repoRef: "feature",
defaultRef: "main",
isPrimary: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}];
};
await plugin.definition.setup(harness.ctx);
const result = await harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
view: "head",
includeUntracked: false,
});
expect(result).toMatchObject({
baseRef: "main",
defaultBaseRef: "main",
stats: { fileCount: 1 },
files: [expect.objectContaining({ path: "src/app.ts" })],
});
});
it("uses the primary project workspace default ref when execution workspace has no workspace link", async () => {
const root = await createGitWorkspace();
await git(root, ["checkout", "-b", "feature"]);
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 5;\n");
await git(root, ["add", "src/app.ts"]);
await git(root, ["commit", "-m", "feature change"]);
const harness = createTestHarness({ manifest });
harness.seed({
executionWorkspaces: [{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
path: root,
cwd: root,
repoUrl: null,
baseRef: null,
branchName: "feature",
providerType: "git_worktree",
providerMetadata: null,
}],
});
harness.ctx.projects.listWorkspaces = async (projectId, companyId) => {
expect(projectId).toBe("project-1");
expect(companyId).toBe("company-1");
return [{
id: "project-workspace-1",
projectId: "project-1",
name: "Primary",
path: root,
repoUrl: null,
repoRef: "feature",
defaultRef: "main",
isPrimary: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}];
};
await plugin.definition.setup(harness.ctx);
const result = await harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
projectId: "project-1",
view: "head",
baseRef: null,
includeUntracked: false,
});
expect(result).toMatchObject({
baseRef: "main",
defaultBaseRef: "main",
stats: { fileCount: 1 },
files: [expect.objectContaining({ path: "src/app.ts" })],
});
});
it("infers the default base ref from the execution workspace branch upstream", async () => {
const root = await createGitWorkspace();
await git(root, ["update-ref", "refs/remotes/origin/master", "HEAD"]);
await git(root, ["checkout", "-b", "feature"]);
await git(root, ["config", "branch.feature.remote", "origin"]);
await git(root, ["config", "branch.feature.merge", "refs/heads/master"]);
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 6;\n");
await git(root, ["add", "src/app.ts"]);
await git(root, ["commit", "-m", "feature change"]);
const harness = createTestHarness({ manifest });
harness.seed({
executionWorkspaces: [{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
path: root,
cwd: root,
repoUrl: null,
baseRef: null,
branchName: "feature",
providerType: "git_worktree",
providerMetadata: null,
}],
});
await plugin.definition.setup(harness.ctx);
await expect(harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
view: "working-tree",
includeUntracked: false,
})).resolves.toMatchObject({
baseRef: null,
defaultBaseRef: "origin/master",
stats: { fileCount: 0 },
});
await expect(harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
view: "head",
baseRef: null,
includeUntracked: false,
})).resolves.toMatchObject({
baseRef: "origin/master",
defaultBaseRef: "origin/master",
stats: { fileCount: 1 },
files: [expect.objectContaining({ path: "src/app.ts" })],
});
});
it("returns a clear bridge error when required context is missing", async () => {
const harness = createTestHarness({ manifest });
await plugin.definition.setup(harness.ctx);
await expect(harness.getData("workspace-diff", {
workspaceId: "workspace-1",
})).rejects.toThrow("workspaceId and companyId are required");
});
});
@@ -0,0 +1,20 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ErrorState } from "../src/ui/index.js";
describe("workspace diff error state", () => {
it("keeps bridge error details out of the primary headline", () => {
const rawError = "Execution workspace not found";
const html = renderToStaticMarkup(createElement(ErrorState, {
message: rawError,
onRetry: () => undefined,
}));
expect(html).toContain("Unable to load workspace changes.");
expect(html).toContain("Retry");
expect(html).toContain("Troubleshooting details");
expect(html).not.toContain(`font-medium text-foreground">${rawError}`);
expect(html.indexOf(rawError)).toBeGreaterThan(html.indexOf("Troubleshooting details"));
});
});
@@ -0,0 +1,200 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk";
import type { WorkspaceDiffQueryOptions } from "../src/contracts.js";
import { WORKSPACE_DIFF_CAPS, workspaceDiffService } from "../src/workspace-diff.js";
const execFileAsync = promisify(execFile);
const tempDirs = new Set<string>();
async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", ["-C", cwd, ...args], { cwd });
}
async function createTempRepo() {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-"));
tempDirs.add(repoRoot);
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]);
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\n", "utf8");
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\n", "utf8");
await fs.writeFile(path.join(repoRoot, "delete-me.txt"), "charlie\n", "utf8");
await fs.writeFile(path.join(repoRoot, "rename-me.txt"), "delta\n", "utf8");
await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3]));
await runGit(repoRoot, ["add", "."]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
await runGit(repoRoot, ["branch", "-M", "main"]);
return repoRoot;
}
function createWorkspace(cwd: string | null, overrides: Partial<PluginExecutionWorkspaceMetadata> = {}): PluginExecutionWorkspaceMetadata {
return {
id: randomUUID(),
companyId: randomUUID(),
projectId: randomUUID(),
projectWorkspaceId: null,
path: cwd,
cwd,
repoUrl: null,
baseRef: null,
branchName: "feature",
providerType: "git_worktree",
providerMetadata: null,
...overrides,
};
}
function workingTreeQuery(overrides: Partial<WorkspaceDiffQueryOptions> = {}): WorkspaceDiffQueryOptions {
return {
view: "working-tree",
baseRef: null,
includeUntracked: true,
paths: [],
...overrides,
};
}
afterEach(async () => {
for (const dir of tempDirs) {
await fs.rm(dir, { recursive: true, force: true });
}
tempDirs.clear();
});
describe("plugin workspace diff service", () => {
it("returns staged, unstaged, renamed, deleted, untracked, binary, and oversized working-tree changes", async () => {
const repoRoot = await createTempRepo();
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\nstaged\n", "utf8");
await runGit(repoRoot, ["add", "tracked-staged.txt"]);
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\nunstaged\n", "utf8");
await runGit(repoRoot, ["mv", "rename-me.txt", "renamed.txt"]);
await fs.rm(path.join(repoRoot, "delete-me.txt"));
await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3, 4, 5]));
await fs.writeFile(path.join(repoRoot, "untracked.txt"), "brand new\n", "utf8");
await fs.writeFile(path.join(repoRoot, "empty-untracked.txt"), "", "utf8");
await fs.writeFile(path.join(repoRoot, "oversized.txt"), "x".repeat(WORKSPACE_DIFF_CAPS.maxFileBytes + 1), "utf8");
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
const byPath = new Map(diff.files.map((file) => [file.path, file]));
expect(diff.view).toBe("working-tree");
expect(byPath.get("tracked-staged.txt")).toMatchObject({ staged: true, unstaged: false, status: "modified", additions: 1 });
expect(byPath.get("tracked-staged.txt")?.patches.map((patch) => patch.kind)).toEqual(["staged"]);
expect(byPath.get("tracked-unstaged.txt")).toMatchObject({ staged: false, unstaged: true, status: "modified", additions: 1 });
expect(byPath.get("renamed.txt")).toMatchObject({ oldPath: "rename-me.txt", staged: true, status: "renamed" });
expect(byPath.get("delete-me.txt")).toMatchObject({ unstaged: true, status: "deleted", deletions: 1 });
expect(byPath.get("untracked.txt")).toMatchObject({ untracked: true, status: "untracked", additions: 1 });
expect(byPath.get("untracked.txt")?.patches[0]?.patch).toContain("+brand new");
expect(byPath.get("empty-untracked.txt")?.patches[0]?.patch).toBe([
"diff --git a/empty-untracked.txt b/empty-untracked.txt",
"new file mode 100644",
"--- /dev/null",
"+++ b/empty-untracked.txt",
"",
].join("\n"));
expect(byPath.get("binary.bin")).toMatchObject({ binary: true, unstaged: true });
expect(byPath.get("oversized.txt")).toMatchObject({ oversized: true, untracked: true });
expect(diff.warnings.map((item) => item.code)).toEqual(expect.arrayContaining(["binary_file", "file_oversized"]));
}, 20_000);
it("returns head diffs against the requested base ref", async () => {
const repoRoot = await createTempRepo();
await runGit(repoRoot, ["checkout", "-b", "feature"]);
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\ncommitted\n", "utf8");
await runGit(repoRoot, ["add", "tracked-staged.txt"]);
await runGit(repoRoot, ["commit", "-m", "Feature change"]);
const diff = await workspaceDiffService().getDiff(
createWorkspace(repoRoot, { baseRef: "main" }),
workingTreeQuery({ view: "head", includeUntracked: false }),
);
expect(diff.baseRef).toBe("main");
expect(diff.files).toHaveLength(1);
expect(diff.files[0]).toMatchObject({
path: "tracked-staged.txt",
staged: false,
unstaged: false,
untracked: false,
additions: 1,
deletions: 0,
});
expect(diff.files[0]?.patches.map((patch) => patch.kind)).toEqual(["head"]);
}, 20_000);
it("filters changed files by relative workspace paths", async () => {
const repoRoot = await createTempRepo();
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\none\n", "utf8");
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\ntwo\n", "utf8");
const diff = await workspaceDiffService().getDiff(
createWorkspace(repoRoot),
workingTreeQuery({ paths: ["tracked-staged.txt"] }),
);
expect(diff.paths).toEqual(["tracked-staged.txt"]);
expect(diff.files.map((file) => file.path)).toEqual(["tracked-staged.txt"]);
}, 20_000);
it("applies output caps to large workspace responses", async () => {
const repoRoot = await createTempRepo();
for (let index = 0; index < WORKSPACE_DIFF_CAPS.maxFiles + 1; index += 1) {
await fs.writeFile(path.join(repoRoot, `untracked-${String(index).padStart(3, "0")}.txt`), "", "utf8");
}
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
expect(diff.files).toHaveLength(WORKSPACE_DIFF_CAPS.maxFiles);
expect(diff.truncated).toBe(true);
expect(diff.warnings).toContainEqual(expect.objectContaining({ code: "file_count_truncated" }));
}, 20_000);
it("does not follow untracked symlinks outside the repo", async () => {
const repoRoot = await createTempRepo();
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-secret-"));
tempDirs.add(outsideDir);
const secretContent = "external secret should not appear\n";
const secretPath = path.join(outsideDir, "secret.txt");
await fs.writeFile(secretPath, secretContent, "utf8");
await fs.symlink(secretPath, path.join(repoRoot, "leak.txt"));
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
const leak = diff.files.find((file) => file.path === "leak.txt");
const serialized = JSON.stringify(diff);
expect(leak).toMatchObject({ untracked: true, status: "untracked", additions: 0, sizeBytes: null });
expect(leak?.patches[0]).toMatchObject({
kind: "untracked",
patch: null,
warnings: [expect.objectContaining({ code: "symlink_target_outside_workspace" })],
});
expect(diff.warnings).toContainEqual(expect.objectContaining({
code: "symlink_target_outside_workspace",
path: "leak.txt",
}));
expect(serialized).not.toContain(secretContent.trim());
}, 20_000);
it("surfaces missing cwd, non-git, invalid base refs, and unsafe path filters as plugin errors", async () => {
const svc = workspaceDiffService();
await expect(svc.getDiff(createWorkspace(null), workingTreeQuery()))
.rejects.toMatchObject({ status: 422, details: { code: "missing_cwd" } });
const nonGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-non-git-"));
tempDirs.add(nonGitDir);
await expect(svc.getDiff(createWorkspace(nonGitDir), workingTreeQuery()))
.rejects.toMatchObject({ status: 422, details: { code: "non_git_workspace" } });
const repoRoot = await createTempRepo();
await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ paths: ["../secret"] })))
.rejects.toMatchObject({ status: 422, details: { code: "path_filter_invalid" } });
await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ view: "head", baseRef: "missing-ref" })))
.rejects.toMatchObject({ status: 422, details: { code: "base_ref_invalid" } });
}, 20_000);
});
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["dist", "node_modules"]
}
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["src", "tests"]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.spec.ts"],
environment: "node",
},
});
@@ -423,6 +423,17 @@ export async function handleBridgeRequest(request: Request, env: BridgeEnv): Pro
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
// Heartbeat keeps the SSE response alive during silent stretches
// (e.g. npm install downloading silently). SSE comment lines (`:`)
// are ignored by the client parser but keep the underlying HTTP
// connection from idling out at the Cloudflare edge.
const heartbeat = setInterval(() => {
try {
controller.enqueue(encoder.encode(": keepalive\n\n"));
} catch {
// Controller may already be closed; ignore.
}
}, 15_000);
try {
const result = await executeInSandbox({
sandbox,
@@ -444,6 +455,7 @@ export async function handleBridgeRequest(request: Request, env: BridgeEnv): Pro
error: error instanceof Error ? error.message : String(error),
})));
} finally {
clearInterval(heartbeat);
controller.close();
}
},
@@ -7,7 +7,7 @@
{
"class_name": "Sandbox",
"image": "./Dockerfile",
"instance_type": "lite",
"instance_type": "standard-2",
"max_instances": 10
}
],
@@ -1,9 +1,9 @@
import type { CloudflareDriverConfig } from "./types.js";
const DEFAULT_REQUESTED_CWD = "/workspace/paperclip";
const DEFAULT_SLEEP_AFTER = "10m";
const DEFAULT_SLEEP_AFTER = "1h";
const DEFAULT_TIMEOUT_MS = 300_000;
const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 30_000;
const DEFAULT_BRIDGE_REQUEST_TIMEOUT_MS = 300_000;
const LOCALHOST_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
function readTrimmedString(value: unknown): string | null {
@@ -49,8 +49,9 @@ const manifest: PaperclipPluginManifestV1 = {
},
sleepAfter: {
type: "string",
default: "10m",
description: "Idle timeout passed to getSandbox(). Ignored when keepAlive is true.",
default: "1h",
description:
"Idle timeout passed to getSandbox() on lease creation. Defaults to 1 hour so a fresh sandbox survives normal Claude/Codex heartbeats. Ignored when keepAlive is true.",
},
normalizeId: {
type: "boolean",
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import plugin from "./plugin.js";
const fetchMock = vi.fn();
let plugin: typeof import("./plugin.js").default;
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
@@ -23,9 +23,11 @@ function requestBodyAt(index = 0): Record<string, unknown> {
}
describe("Cloudflare sandbox provider plugin", () => {
beforeEach(() => {
beforeEach(async () => {
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
vi.resetModules();
plugin = (await import("./plugin.js")).default;
});
it("declares the Cloudflare environment lifecycle handlers", async () => {
@@ -60,7 +62,7 @@ describe("Cloudflare sandbox provider plugin", () => {
bridgeAuthToken: "secret-ref://bridge-token",
reuseLease: true,
keepAlive: true,
sleepAfter: "10m",
sleepAfter: "1h",
normalizeId: false,
requestedCwd: "/workspace/custom",
sessionStrategy: "default",
@@ -143,6 +145,29 @@ describe("Cloudflare sandbox provider plugin", () => {
});
});
it("defaults the sleepAfter passed to the bridge to 1h so long runs don't idle out", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({
providerLeaseId: "pc-run-1-abcd1234",
metadata: { provider: "cloudflare", remoteCwd: "/workspace/paperclip", resumedLease: false },
}),
);
await plugin.definition.onEnvironmentAcquireLease?.({
driverKey: "cloudflare",
companyId: "company-1",
environmentId: "env-1",
runId: "run-1",
requestedCwd: "/workspace/paperclip",
config: {
bridgeBaseUrl: "https://bridge.example.workers.dev",
bridgeAuthToken: "resolved-token",
},
});
expect(requestBodyAt()).toMatchObject({ sleepAfter: "1h" });
});
it("returns expired lease semantics when resume reports lost state", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse(
@@ -210,6 +235,12 @@ describe("Cloudflare sandbox provider plugin", () => {
});
it("routes bridge-channel execute calls through a dedicated session", async () => {
// pluginLogger must be set for the streaming branch to be reachable, so
// we can assert that bridge-channel calls take the non-streaming path
// even when adapter sessions would otherwise stream.
await plugin.definition.setup?.({
logger: { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined },
} as never);
fetchMock.mockResolvedValueOnce(
jsonResponse({
exitCode: 0,
@@ -248,6 +279,49 @@ describe("Cloudflare sandbox provider plugin", () => {
},
});
expect(requestBodyAt().env).not.toHaveProperty("PAPERCLIP_SANDBOX_EXEC_CHANNEL");
// Bridge-channel commands must use the non-streaming exec path. The
// @cloudflare/sandbox SDK's streaming mode can drop the final stdout
// chunk when a short shell exits the same tick it writes — bridge ops
// carry machine-consumed stdout (readiness JSON, base64 file payloads,
// queue response bodies) where that data loss surfaces as opaque
// "invalid readiness JSON" / "Invalid bridge request payload" errors.
expect(requestBodyAt().streamOutput).toBe(false);
});
it("uses streaming exec for non-bridge adapter commands so live logs flow", async () => {
// Streaming is gated on `pluginLogger` being set, which normally happens
// in `setup()`. Wire a minimal logger so the streaming branch is reachable.
await plugin.definition.setup?.({
logger: { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined },
} as never);
fetchMock.mockResolvedValueOnce(
new Response(
"event: stdout\ndata: {\"data\":\"hello\\n\"}\n\nevent: complete\ndata: {\"exitCode\":0,\"signal\":null,\"timedOut\":false,\"stdout\":\"hello\\n\",\"stderr\":\"\"}\n\n",
{
status: 200,
headers: { "Content-Type": "text/event-stream" },
},
),
);
await plugin.definition.onEnvironmentExecute?.({
driverKey: "cloudflare",
companyId: "company-1",
environmentId: "env-1",
lease: { providerLeaseId: "pc-run-1-abcd1234", metadata: {} },
command: "echo",
args: ["hello"],
cwd: "/workspace/paperclip",
env: { KEEP_ME: "visible" },
config: {
bridgeBaseUrl: "https://bridge.example.workers.dev",
bridgeAuthToken: "resolved-token",
sessionStrategy: "named",
sessionId: "paperclip",
},
});
expect(requestBodyAt().streamOutput).toBe(true);
});
it("maps lost-lease execute errors into a deterministic command failure", async () => {
@@ -317,7 +317,13 @@ const plugin = definePlugin({
const { config, client } = bridgeClientFor(params.config);
const session = resolveExecuteSession(config, params.env);
try {
const streamingOptions = pluginLogger
// Bridge-channel commands carry machine-consumed stdout (JSON, base64,
// file contents). The @cloudflare/sandbox SDK's streaming mode can drop
// the final stdout chunk when the inner shell exits the same tick as it
// writes (e.g. `cat ready.json && exit 0`), so we never stream for
// bridge control traffic — only adapter sessions get live log forwarding.
const isBridgeChannel = params.env?.[SANDBOX_EXEC_CHANNEL_ENV] === SANDBOX_EXEC_CHANNEL_BRIDGE;
const streamingOptions = pluginLogger && !isBridgeChannel
? {
onOutput: async (stream: "stdout" | "stderr", chunk: string) => {
logCloudflareExecChunk(pluginLogger, stream, chunk);
@@ -39,8 +39,9 @@ const manifest: PaperclipPluginManifestV1 = {
},
timeoutMs: {
type: "number",
description: "Sandbox timeout in milliseconds.",
default: 300000,
description:
"Sandbox lifetime in milliseconds, refreshed on each command. Defaults to 1 hour. Raise this if your runs commonly idle longer than the default between commands.",
default: 3600000,
},
reuseLease: {
type: "boolean",
@@ -379,6 +379,59 @@ describe("E2B sandbox provider plugin", () => {
});
});
it("refreshes the sandbox lifetime on every execute so long runs don't die mid-command", async () => {
const sandbox = createMockSandbox();
mockConnect.mockResolvedValue(sandbox);
await plugin.definition.onEnvironmentExecute?.({
driverKey: "e2b",
companyId: "company-1",
environmentId: "env-1",
config: {
template: "base",
apiKey: "resolved-key",
timeoutMs: 1_800_000,
reuseLease: false,
},
lease: { providerLeaseId: "sandbox-123", metadata: {} },
command: "printf",
args: ["hello"],
cwd: "/workspace",
env: {},
timeoutMs: 1000,
});
expect(sandbox.setTimeout).toHaveBeenCalledWith(1_800_000);
});
it("still runs the command when the setTimeout refresh fails transiently", async () => {
const sandbox = createMockSandbox();
sandbox.setTimeout.mockRejectedValueOnce(new Error("transient e2b api error"));
mockConnect.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "e2b",
companyId: "company-1",
environmentId: "env-1",
config: {
template: "base",
apiKey: "resolved-key",
timeoutMs: 1_800_000,
reuseLease: false,
},
lease: { providerLeaseId: "sandbox-123", metadata: {} },
command: "printf",
args: ["hello"],
cwd: "/workspace",
env: {},
timeoutMs: 1000,
});
expect(sandbox.setTimeout).toHaveBeenCalledWith(1_800_000);
expect(sandbox.commands.run).toHaveBeenCalled();
expect(result?.exitCode).toBe(0);
});
it("cleans up staged stdin even when writing it fails", async () => {
const sandbox = createMockSandbox();
const failure = new Error("write failed");
@@ -34,11 +34,11 @@ function parseDriverConfig(raw: Record<string, unknown>): E2bDriverConfig {
const template = typeof raw.template === "string" && raw.template.trim().length > 0
? raw.template.trim()
: "base";
const timeoutMs = Number(raw.timeoutMs ?? 300_000);
const timeoutMs = Number(raw.timeoutMs ?? 3_600_000);
return {
template,
apiKey: typeof raw.apiKey === "string" && raw.apiKey.trim().length > 0 ? raw.apiKey.trim() : null,
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 300_000,
timeoutMs: Number.isFinite(timeoutMs) ? Math.trunc(timeoutMs) : 3_600_000,
reuseLease: raw.reuseLease === true,
};
}
@@ -391,6 +391,18 @@ const plugin = definePlugin({
const config = parseDriverConfig(params.config);
const sandbox = await connectSandbox(config, params.lease.providerLeaseId);
// Refresh the sandbox death clock on every command. E2B's `timeoutMs` is
// the absolute sandbox lifetime from create/connect; without this, a run
// longer than `config.timeoutMs` will have its sandbox killed mid-command
// and the next call throws "Sandbox is probably not running anymore".
// The refresh is best-effort: the sandbox is already healthy at this
// point, so a transient API error on setTimeout should not block the
// command from running. Worst case the existing lifetime stands.
try {
await sandbox.setTimeout(config.timeoutMs);
} catch {
// ignore — keep going with the existing sandbox lifetime
}
const baseCommand = buildLoginShellScript({
command: params.command,
args: params.args ?? [],
@@ -0,0 +1,77 @@
# `@paperclipai/plugin-modal`
First-party Modal sandbox provider plugin for Paperclip.
Like the other sandbox-provider packages in this repo, it lives inside the Paperclip monorepo but is intentionally excluded from the root `pnpm` workspace and shaped to publish and install like a standalone npm package. That lets operators install it from the Plugins page by package name without introducing root lockfile churn for Modal's SDK dependencies.
## Install
From a Paperclip instance, install:
```text
@paperclipai/plugin-modal
```
The host plugin installer runs `npm install` into the managed plugin directory, so the `modal` SDK dependency is pulled in during installation.
## Runtime support note
Modal's official JS SDK README pins support to **Node 22 or later**. Paperclip's repo baseline is currently `node >= 20`; empirically `modal@0.7.4` imports and operates against the Modal API under Node 20, so the plugin runs there today, but the vendor support contract is Node 22+. The plugin logs a startup warning when it detects Node `< 22`. Operators who can pin their Paperclip runtime to Node 22+ should do so; treat Node-20 usage as best-effort until the host bumps its baseline.
The empirical Node 20 compatibility check is recorded in [PAPA-352](/PAPA/issues/PAPA-352).
## Configuration
Configure Modal from `Company Settings -> Environments`, not from the plugin's instance settings page.
| Field | Required | Description |
| --- | --- | --- |
| `appName` | yes | Modal App name. The plugin calls `modal.apps.fromName(appName, { createIfMissing: true })`, so the App is created on first acquire if it does not already exist. |
| `image` | yes | Container image passed to `modal.images.fromRegistry()`, e.g. `python:3.13` or `node:20`. |
| `tokenId` / `tokenSecret` | yes | Modal auth tokens. Both must be provided together. Paperclip stores pasted values as company secrets. The plugin worker runs in a child process that does not inherit host env vars, so `MODAL_TOKEN_ID` / `MODAL_TOKEN_SECRET` set on the Paperclip server are **not** read by the plugin — provide the tokens in this form. |
| `environment` | no | Optional Modal environment name. Falls back to the SDK profile default. |
| `workdir` | no | Remote working directory inside the sandbox. Defaults to `/workspace/paperclip`. |
| `sandboxTimeoutMs` | no | Maximum sandbox lifetime in milliseconds. Must be a positive multiple of `1000` between `1000` and `86_400_000` (24 hours). Defaults to `3_600_000` (1 hour). |
| `idleTimeoutMs` | no | Optional idle timeout in milliseconds. Modal terminates the sandbox if no exec is active for this duration. Must be a positive multiple of `1000`. |
| `execTimeoutMs` | no | Default per-exec timeout in milliseconds when the caller does not pass one. Must be a positive multiple of `1000`. Defaults to `300_000` (5 minutes). |
| `blockNetwork` | no | Block all egress network access. |
| `cidrAllowlist` | no | List of CIDRs the sandbox may reach. Cannot be combined with `blockNetwork`. |
| `reuseLease` | no | When `true`, the sandbox is detached (not terminated) on release and reattached by id later. Defaults to `false`. |
### Reuse semantics
Modal does **not** expose a separate pause/resume primitive for sandboxes — there is no equivalent to e2b's `pause()`. The plugin implements `reuseLease` as follows:
- **`reuseLease: false` (default)**: On release the sandbox is `terminate()`d. Subsequent runs create a new sandbox.
- **`reuseLease: true`**: On release the plugin calls `sandbox.detach()`. The sandbox keeps running on Modal until its configured `sandboxTimeoutMs` or `idleTimeoutMs` elapses. The next acquire/resume reconnects via `modal.sandboxes.fromId(providerLeaseId)`. If the sandbox has expired, `fromId` raises `NotFoundError` and the plugin reports the lease as expired so Paperclip reacquires.
Because there is no real pause, **`reuseLease: true` keeps billing running** until the sandbox or idle timeout cuts it off. Tune `idleTimeoutMs` to a value that matches your reuse window.
## Local development
```bash
cd packages/plugins/sandbox-providers/modal
pnpm install --ignore-workspace --no-lockfile
pnpm build
pnpm test
pnpm typecheck
```
These commands assume the repo root has already been installed once so the local `@paperclipai/plugin-sdk` workspace package is available to the compiler during development.
## Operator verification
1. Provision Modal credentials in your Modal account (`modal token new`) or use a service account.
2. Install the plugin from the Paperclip Plugins page.
3. In `Company Settings -> Environments`, add a new Modal sandbox environment with at least `appName`, `image`, `tokenId`, and `tokenSecret`.
4. Run the environment **Probe** action. A success result confirms auth, app creation, image pull, and `exec` round-trip.
5. Run at least one Paperclip task with a remote-managed adapter (for example `claude_local`) bound to that environment. The adapter should provision the sandbox, run commands in it, and clean it up.
Full end-to-end manual QA is tracked separately in [PAPA-354](/PAPA/issues/PAPA-354).
## Package layout
- `src/manifest.ts` declares the sandbox-provider driver metadata
- `src/plugin.ts` implements the environment lifecycle hooks
- `src/worker.ts` boots the plugin under the host worker runtime
- `paperclipPlugin.manifest` and `paperclipPlugin.worker` point the host at the built plugin entrypoints in `dist/`
@@ -0,0 +1,61 @@
{
"name": "@paperclipai/plugin-modal",
"version": "0.1.0",
"description": "Modal sandbox provider plugin for Paperclip environments",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/plugins/sandbox-providers/modal"
},
"type": "module",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js"
},
"keywords": [
"paperclip",
"plugin",
"sandbox",
"modal"
],
"scripts": {
"postinstall": "node ../../../../scripts/link-plugin-dev-sdk.mjs",
"prebuild": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps",
"build": "rm -rf dist && tsc",
"clean": "rm -rf dist",
"typecheck": "pnpm -C ../../../.. --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit",
"test": "vitest run --config vitest.config.ts",
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../../scripts/generate-plugin-package-json.mjs",
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
},
"dependencies": {
"modal": "^0.7.4"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as plugin } from "./plugin.js";
@@ -0,0 +1,101 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const PLUGIN_ID = "paperclip.modal-sandbox-provider";
const PLUGIN_VERSION = "0.1.0";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: PLUGIN_VERSION,
displayName: "Modal Sandbox Provider",
description:
"First-party sandbox provider plugin that provisions Modal sandboxes as Paperclip execution environments.",
author: "Paperclip",
categories: ["automation"],
capabilities: ["environment.drivers.register"],
entrypoints: {
worker: "./dist/worker.js",
},
environmentDrivers: [
{
driverKey: "modal",
kind: "sandbox_provider",
displayName: "Modal Sandbox",
description:
"Provisions Modal sandboxes with configurable image, app, auth, timeouts, and network controls.",
configSchema: {
type: "object",
required: ["appName", "image"],
properties: {
appName: {
type: "string",
description:
"Modal App name used as the parent for sandboxes. The plugin calls `modal.apps.fromName(appName, { createIfMissing: true })`, so the App is created on first acquire if it does not already exist.",
},
image: {
type: "string",
description:
"Container image reference passed to `modal.images.fromRegistry()`, e.g. `python:3.13` or `node:20`.",
},
tokenId: {
type: "string",
format: "secret-ref",
description:
"Modal token ID. Paste a token or an existing Paperclip secret reference; saved environments store pasted values as company secrets. Required.",
},
tokenSecret: {
type: "string",
format: "secret-ref",
description: "Modal token secret paired with tokenId. Required.",
},
environment: {
type: "string",
description:
"Optional Modal environment name. Falls back to the SDK profile default.",
},
workdir: {
type: "string",
description: "Remote working directory inside the sandbox.",
default: "/workspace/paperclip",
},
sandboxTimeoutMs: {
type: "number",
description:
"Maximum sandbox lifetime in milliseconds. Must be a positive multiple of 1000 between 1000 and 86400000 (24 hours).",
default: 3_600_000,
},
idleTimeoutMs: {
type: "number",
description:
"Optional idle timeout in milliseconds. When set, Modal terminates the sandbox if no exec is active for this duration. Must be a positive multiple of 1000.",
},
execTimeoutMs: {
type: "number",
description:
"Default per-exec timeout in milliseconds when the caller does not provide one. Must be a positive multiple of 1000.",
default: 300_000,
},
blockNetwork: {
type: "boolean",
description: "Whether to block all egress network access from the sandbox.",
default: false,
},
cidrAllowlist: {
type: "array",
items: { type: "string" },
description:
"Optional list of CIDRs the sandbox is allowed to reach. Cannot be combined with blockNetwork.",
},
reuseLease: {
type: "boolean",
description:
"When true, the sandbox is detached (not terminated) on release and resumed by id later. Reuse relies on Modal's sandbox lifetime and idle timeout because Modal has no separate pause primitive.",
default: false,
},
},
},
},
],
};
export default manifest;
@@ -0,0 +1,703 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { MockNotFoundError, MockTimeoutError, MockSandboxTimeoutError } = vi.hoisted(() => {
class MockNotFoundError extends Error {}
class MockTimeoutError extends Error {}
class MockSandboxTimeoutError extends Error {}
return { MockNotFoundError, MockTimeoutError, MockSandboxTimeoutError };
});
const mockAppFromName = vi.hoisted(() => vi.fn());
const mockImageFromRegistry = vi.hoisted(() => vi.fn(() => ({ kind: "image" })));
const mockSandboxesCreate = vi.hoisted(() => vi.fn());
const mockSandboxesFromId = vi.hoisted(() => vi.fn());
const mockClientClose = vi.hoisted(() => vi.fn());
vi.mock("modal", () => ({
ModalClient: class MockModalClient {
apps = { fromName: mockAppFromName };
images = { fromRegistry: mockImageFromRegistry };
sandboxes = { create: mockSandboxesCreate, fromId: mockSandboxesFromId };
close = mockClientClose;
constructor(_params?: unknown) {}
},
NotFoundError: MockNotFoundError,
TimeoutError: MockTimeoutError,
SandboxTimeoutError: MockSandboxTimeoutError,
}));
import plugin from "./plugin.js";
interface FakeSandboxOverrides {
id?: string;
execImpl?: (argv: string[], params?: unknown) => Promise<FakeProcess>;
}
interface FakeProcess {
stdout: { readText: () => Promise<string> };
stderr: { readText: () => Promise<string> };
wait: () => Promise<number>;
}
function makeFakeProcess(input: {
exitCode?: number;
stdout?: string;
stderr?: string;
throwOnWait?: unknown;
}): FakeProcess {
return {
stdout: { readText: vi.fn().mockResolvedValue(input.stdout ?? "") },
stderr: { readText: vi.fn().mockResolvedValue(input.stderr ?? "") },
wait: vi.fn().mockImplementation(async () => {
if (input.throwOnWait) throw input.throwOnWait;
return input.exitCode ?? 0;
}),
};
}
function createFakeSandbox(overrides: FakeSandboxOverrides = {}) {
const execCalls: Array<{ argv: string[]; params?: unknown }> = [];
const defaultExec = async (_argv: string[], _params?: unknown): Promise<FakeProcess> =>
makeFakeProcess({ exitCode: 0, stdout: "paperclip-probe" });
const exec = vi.fn().mockImplementation(async (argv: string[], params?: unknown) => {
execCalls.push({ argv, params });
return overrides.execImpl ? overrides.execImpl(argv, params) : defaultExec(argv, params);
});
const openedFiles: Array<{ path: string; mode: string; written: Uint8Array | null }> = [];
const sandbox = {
sandboxId: overrides.id ?? "sb-123",
exec,
execCalls,
openedFiles,
setTags: vi.fn().mockResolvedValue(undefined),
terminate: vi.fn().mockResolvedValue(undefined),
detach: vi.fn(),
poll: vi.fn().mockResolvedValue(null),
open: vi.fn().mockImplementation(async (path: string, mode: string) => {
const entry: { path: string; mode: string; written: Uint8Array | null } = {
path,
mode,
written: null,
};
openedFiles.push(entry);
return {
write: vi.fn().mockImplementation(async (data: Uint8Array) => {
entry.written = data;
}),
flush: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
};
}),
};
return sandbox;
}
type FakeSandbox = ReturnType<typeof createFakeSandbox>;
const baseAcquireParams = {
driverKey: "modal",
companyId: "company-1",
environmentId: "env-1",
runId: "run-1",
};
const baseConfig = {
appName: "paperclip-app",
image: "node:20",
sandboxTimeoutMs: 3_600_000,
execTimeoutMs: 300_000,
reuseLease: false,
};
const baseConfigWithTokens = {
...baseConfig,
tokenId: "config-id",
tokenSecret: "config-secret",
};
beforeEach(() => {
mockAppFromName.mockReset();
mockImageFromRegistry.mockReset();
mockImageFromRegistry.mockReturnValue({ kind: "image" });
mockSandboxesCreate.mockReset();
mockSandboxesFromId.mockReset();
mockClientClose.mockReset();
vi.restoreAllMocks();
delete process.env.MODAL_TOKEN_ID;
delete process.env.MODAL_TOKEN_SECRET;
});
describe("Modal sandbox provider plugin", () => {
it("declares environment lifecycle handlers", async () => {
expect(await plugin.definition.onHealth?.()).toEqual({
status: "ok",
message: "Modal sandbox provider plugin healthy",
});
expect(plugin.definition.onEnvironmentAcquireLease).toBeTypeOf("function");
expect(plugin.definition.onEnvironmentExecute).toBeTypeOf("function");
expect(plugin.definition.onEnvironmentReleaseLease).toBeTypeOf("function");
expect(plugin.definition.onEnvironmentResumeLease).toBeTypeOf("function");
});
it("normalizes config when both tokens are provided", async () => {
const result = await plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "modal",
config: {
appName: " app-1 ",
image: " node:20 ",
tokenId: " token-id ",
tokenSecret: " token-secret ",
environment: " main ",
workdir: " /srv/work ",
sandboxTimeoutMs: "1800000",
idleTimeoutMs: "60000",
execTimeoutMs: "120000",
reuseLease: true,
blockNetwork: false,
cidrAllowlist: ["10.0.0.0/8"],
},
});
expect(result).toEqual({
ok: true,
normalizedConfig: {
appName: "app-1",
image: "node:20",
tokenId: "token-id",
tokenSecret: "token-secret",
environment: "main",
workdir: "/srv/work",
sandboxTimeoutMs: 1_800_000,
idleTimeoutMs: 60_000,
execTimeoutMs: 120_000,
blockNetwork: false,
cidrAllowlist: ["10.0.0.0/8"],
reuseLease: true,
},
});
});
it("ignores host MODAL_TOKEN_* env vars (plugin worker does not inherit them)", async () => {
process.env.MODAL_TOKEN_ID = "host-id";
process.env.MODAL_TOKEN_SECRET = "host-secret";
const result = await plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "modal",
config: { ...baseConfig },
});
expect(result).toEqual({
ok: false,
errors: ["Modal sandbox environments require tokenId and tokenSecret."],
});
});
it("rejects invalid config", async () => {
const result = await plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "modal",
config: {
appName: "",
image: "",
sandboxTimeoutMs: 1500,
idleTimeoutMs: 1500,
execTimeoutMs: 0,
blockNetwork: true,
cidrAllowlist: ["1.2.3.4/32"],
tokenId: "only-id",
},
});
expect(result).toEqual({
ok: false,
errors: [
"Modal sandbox environments require an appName.",
"Modal sandbox environments require an image reference.",
"sandboxTimeoutMs must be a positive multiple of 1000 between 1000 and 86400000.",
"idleTimeoutMs must be a positive multiple of 1000 when provided.",
"execTimeoutMs must be a positive multiple of 1000.",
"cidrAllowlist cannot be combined with blockNetwork.",
"tokenId and tokenSecret must both be provided when either is set.",
],
});
});
it("requires both tokens in config", async () => {
const result = await plugin.definition.onEnvironmentValidateConfig?.({
driverKey: "modal",
config: { ...baseConfig },
});
expect(result).toEqual({
ok: false,
errors: ["Modal sandbox environments require tokenId and tokenSecret."],
});
});
it("probes by creating, executing, and terminating a sandbox", async () => {
const sandbox = createFakeSandbox();
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
mockSandboxesCreate.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentProbe?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: { ...baseConfig, workdir: "/srv/work" },
});
expect(mockAppFromName).toHaveBeenCalledWith("paperclip-app", {
createIfMissing: true,
environment: undefined,
});
expect(mockImageFromRegistry).toHaveBeenCalledWith("node:20");
expect(sandbox.setTags).toHaveBeenCalledWith(expect.objectContaining({
"paperclip-provider": "modal",
"paperclip-company-id": "c-1",
}));
// First exec is the mkdir for the workspace, second is the probe command.
expect(sandbox.execCalls[0]?.argv).toEqual([
"sh",
"-lc",
"mkdir -p '/srv/work'",
]);
expect(sandbox.execCalls[1]?.argv).toEqual([
"sh",
"-lc",
"printf paperclip-probe",
]);
expect(sandbox.terminate).toHaveBeenCalled();
expect(mockClientClose).toHaveBeenCalled();
expect(result).toMatchObject({
ok: true,
metadata: {
provider: "modal",
sandboxId: "sb-123",
remoteCwd: "/srv/work",
reuseLease: false,
},
});
});
it("returns a failure probe result when the probe command exits non-zero", async () => {
const sandbox = createFakeSandbox({
execImpl: async (argv: string[]) => {
if (argv[2] === "printf paperclip-probe") {
return makeFakeProcess({ exitCode: 7, stdout: "boom" });
}
return makeFakeProcess({ exitCode: 0 });
},
});
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
mockSandboxesCreate.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentProbe?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
});
expect(result?.ok).toBe(false);
expect(sandbox.terminate).toHaveBeenCalled();
});
it("closes the Modal client when probe fails before sandbox creation", async () => {
mockAppFromName.mockRejectedValue(new Error("app lookup failed"));
const result = await plugin.definition.onEnvironmentProbe?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
});
expect(result).toMatchObject({
ok: false,
summary: "Modal sandbox probe failed.",
metadata: expect.objectContaining({
error: "app lookup failed",
}),
});
expect(mockClientClose).toHaveBeenCalledTimes(1);
});
it("acquires a lease, applies tags, and ensures the workspace directory", async () => {
const sandbox = createFakeSandbox({ id: "sb-acquire" });
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
mockSandboxesCreate.mockResolvedValue(sandbox);
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
...baseAcquireParams,
config: { ...baseConfig, reuseLease: true, workdir: "/srv/work" },
});
expect(lease).toEqual({
providerLeaseId: "sb-acquire",
metadata: expect.objectContaining({
provider: "modal",
sandboxId: "sb-acquire",
remoteCwd: "/srv/work",
reuseLease: true,
resumedLease: false,
}),
});
expect(sandbox.setTags).toHaveBeenCalledWith(expect.objectContaining({
"paperclip-run-id": "run-1",
"paperclip-reuse-lease": "true",
}));
expect(sandbox.execCalls[0]?.argv).toEqual(["sh", "-lc", "mkdir -p '/srv/work'"]);
});
it("terminates the sandbox if acquire workspace setup throws", async () => {
const sandbox = createFakeSandbox({
execImpl: async (argv: string[]) => {
if (argv[2]?.startsWith("mkdir -p")) {
return makeFakeProcess({ throwOnWait: new Error("mkdir failed") });
}
return makeFakeProcess({ exitCode: 0 });
},
});
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
mockSandboxesCreate.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentAcquireLease?.({
...baseAcquireParams,
config: baseConfig,
}),
).rejects.toThrow("mkdir failed");
expect(sandbox.terminate).toHaveBeenCalledTimes(1);
});
it("fails acquire when workspace creation exits non-zero", async () => {
const sandbox = createFakeSandbox({
execImpl: async (argv: string[]) => {
if (argv[2]?.startsWith("mkdir -p")) {
return makeFakeProcess({ exitCode: 17 });
}
return makeFakeProcess({ exitCode: 0 });
},
});
mockAppFromName.mockResolvedValue({ appId: "ap-1" });
mockSandboxesCreate.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentAcquireLease?.({
...baseAcquireParams,
config: baseConfig,
}),
).rejects.toThrow(
"Failed to create remote workspace directory '/workspace/paperclip': mkdir exited with code 17",
);
expect(sandbox.terminate).toHaveBeenCalledTimes(1);
});
it("closes the Modal client when acquire fails before sandbox creation", async () => {
mockAppFromName.mockRejectedValue(new Error("app lookup failed"));
await expect(
plugin.definition.onEnvironmentAcquireLease?.({
...baseAcquireParams,
config: baseConfig,
}),
).rejects.toThrow("app lookup failed");
expect(mockClientClose).toHaveBeenCalledTimes(1);
});
it("treats missing leases as expired on resume", async () => {
mockSandboxesFromId.mockRejectedValue(new MockNotFoundError("gone"));
const lease = await plugin.definition.onEnvironmentResumeLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-missing",
config: { ...baseConfig, reuseLease: true },
});
expect(lease).toEqual({ providerLeaseId: null, metadata: { expired: true } });
});
it("resumes a reusable lease by reconnecting via fromId", async () => {
const sandbox = createFakeSandbox({ id: "sb-resume" });
mockSandboxesFromId.mockResolvedValue(sandbox);
const lease = await plugin.definition.onEnvironmentResumeLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-resume",
config: { ...baseConfig, reuseLease: true },
});
expect(lease).toEqual({
providerLeaseId: "sb-resume",
metadata: expect.objectContaining({
provider: "modal",
sandboxId: "sb-resume",
resumedLease: true,
reuseLease: true,
}),
});
});
it("detaches the sandbox if resumed workspace setup fails", async () => {
const sandbox = createFakeSandbox({
id: "sb-resume",
execImpl: async (argv: string[]) => {
if (argv[2]?.startsWith("mkdir -p")) {
return makeFakeProcess({ throwOnWait: new Error("mkdir failed") });
}
return makeFakeProcess({ exitCode: 0 });
},
});
mockSandboxesFromId.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentResumeLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-resume",
config: { ...baseConfig, reuseLease: true },
}),
).rejects.toThrow("mkdir failed");
expect(sandbox.detach).toHaveBeenCalledTimes(1);
});
it("detaches reusable leases and terminates ephemeral leases on release", async () => {
const reusable = createFakeSandbox({ id: "sb-reuse" });
const ephemeral = createFakeSandbox({ id: "sb-ephem" });
mockSandboxesFromId.mockResolvedValueOnce(reusable).mockResolvedValueOnce(ephemeral);
await plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-reuse",
config: { ...baseConfig, reuseLease: true },
});
await plugin.definition.onEnvironmentReleaseLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-ephem",
config: { ...baseConfig, reuseLease: false },
});
expect(reusable.detach).toHaveBeenCalled();
expect(reusable.terminate).not.toHaveBeenCalled();
expect(ephemeral.terminate).toHaveBeenCalled();
expect(ephemeral.detach).not.toHaveBeenCalled();
});
it("destroys leases by terminating, ignoring missing sandboxes", async () => {
const sandbox = createFakeSandbox({ id: "sb-destroy" });
mockSandboxesFromId.mockResolvedValueOnce(sandbox);
await plugin.definition.onEnvironmentDestroyLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-destroy",
config: baseConfig,
});
expect(sandbox.terminate).toHaveBeenCalled();
mockSandboxesFromId.mockRejectedValueOnce(new MockNotFoundError("missing"));
await expect(
plugin.definition.onEnvironmentDestroyLease?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
providerLeaseId: "sb-missing",
config: baseConfig,
}),
).resolves.toBeUndefined();
});
it("realizes the workspace using the lease metadata cwd when available", async () => {
const sandbox = createFakeSandbox({ id: "sb-real" });
mockSandboxesFromId.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentRealizeWorkspace?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: {
providerLeaseId: "sb-real",
metadata: { remoteCwd: "/srv/from-metadata" },
},
workspace: { localPath: "/local", remotePath: "/remote" },
});
expect(sandbox.execCalls[0]?.argv).toEqual([
"sh",
"-lc",
"mkdir -p '/srv/from-metadata'",
]);
expect(result).toEqual({
cwd: "/srv/from-metadata",
metadata: { provider: "modal", remoteCwd: "/srv/from-metadata" },
});
});
it("executes commands with a login-shell wrapper that injects env after profile sourcing", async () => {
const sandbox = createFakeSandbox({
execImpl: async (argv: string[]) =>
makeFakeProcess({
exitCode: 5,
stdout: "stdout-output",
stderr: "stderr-output",
}),
});
mockSandboxesFromId.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: { providerLeaseId: "sb-exec", metadata: {} },
command: "printf",
args: ["hello"],
cwd: "/srv/work",
env: { FOO: "bar" },
timeoutMs: 12_000,
});
expect(sandbox.execCalls).toHaveLength(1);
const call = sandbox.execCalls[0]!;
expect(call.argv[0]).toBe("sh");
expect(call.argv[1]).toBe("-lc");
const script = call.argv[2]!;
expect(script).toMatch(/\/etc\/profile/);
expect(script).toMatch(/cd '\/srv\/work'/);
expect(script).toMatch(/&& exec env FOO='bar' 'printf' 'hello'$/);
expect(call.params).toMatchObject({
timeoutMs: 12_000,
stdout: "pipe",
stderr: "pipe",
});
expect(result).toEqual({
exitCode: 5,
timedOut: false,
stdout: "stdout-output",
stderr: "stderr-output",
});
});
it("stages stdin in the sandbox filesystem when execution needs redirected input", async () => {
const sandbox = createFakeSandbox();
mockSandboxesFromId.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: { providerLeaseId: "sb-exec", metadata: {} },
command: "cat",
args: [],
stdin: "input payload",
cwd: "/srv/work",
});
expect(sandbox.openedFiles).toHaveLength(1);
expect(sandbox.openedFiles[0]?.path).toMatch(/^\/tmp\/paperclip-stdin-/);
expect(sandbox.openedFiles[0]?.mode).toBe("w");
expect(sandbox.openedFiles[0]?.written).not.toBeNull();
expect(new TextDecoder().decode(sandbox.openedFiles[0]!.written!)).toBe("input payload");
// First exec is the user command; second is the rm cleanup.
const userCall = sandbox.execCalls[0]!;
expect(userCall.argv[2]).toMatch(/&& exec 'cat' < '\/tmp\/paperclip-stdin-/);
const cleanupCall = sandbox.execCalls[1]!;
expect(cleanupCall.argv[2]).toMatch(/^rm -f '\/tmp\/paperclip-stdin-/);
expect(result?.exitCode).toBe(0);
});
it("rejects invalid shell env keys before execution", async () => {
const sandbox = createFakeSandbox();
mockSandboxesFromId.mockResolvedValue(sandbox);
await expect(
plugin.definition.onEnvironmentExecute?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: { providerLeaseId: "sb-exec", metadata: {} },
command: "printf",
args: ["hello"],
env: { "BAD-KEY": "v" },
}),
).rejects.toThrow("Invalid sandbox environment variable key: BAD-KEY");
expect(sandbox.execCalls).toHaveLength(0);
});
it("returns an error result when execute is called for an expired sandbox lease", async () => {
mockSandboxesFromId.mockRejectedValue(new MockNotFoundError("gone"));
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: { providerLeaseId: "sb-expired", metadata: {} },
command: "printf",
args: ["hello"],
});
expect(result).toEqual({
exitCode: 1,
timedOut: false,
stdout: "",
stderr: "Modal sandbox lease is no longer available.\n",
});
});
it("returns a timedOut result when Modal raises a TimeoutError during exec", async () => {
const sandbox = createFakeSandbox({
execImpl: async () =>
makeFakeProcess({ throwOnWait: new MockTimeoutError("exec timed out") }),
});
mockSandboxesFromId.mockResolvedValue(sandbox);
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: { providerLeaseId: "sb-exec", metadata: {} },
command: "sleep",
args: ["60"],
cwd: "/srv/work",
timeoutMs: 5_000,
});
expect(result).toEqual({
exitCode: null,
timedOut: true,
stdout: "",
stderr: "exec timed out\n",
});
});
it("returns an error result when execute is called without a provider lease id", async () => {
const result = await plugin.definition.onEnvironmentExecute?.({
driverKey: "modal",
companyId: "c-1",
environmentId: "e-1",
config: baseConfig,
lease: { providerLeaseId: null, metadata: {} },
command: "printf",
args: ["hello"],
});
expect(result).toEqual({
exitCode: 1,
timedOut: false,
stdout: "",
stderr: "No provider lease ID available for execution.",
});
});
});
@@ -0,0 +1,660 @@
import {
ModalClient,
NotFoundError,
SandboxTimeoutError,
TimeoutError,
type App,
type ContainerProcess,
type Sandbox,
type SandboxCreateParams,
} from "modal";
import { definePlugin } from "@paperclipai/plugin-sdk";
import type {
PluginEnvironmentAcquireLeaseParams,
PluginEnvironmentDestroyLeaseParams,
PluginEnvironmentExecuteParams,
PluginEnvironmentExecuteResult,
PluginEnvironmentLease,
PluginEnvironmentProbeParams,
PluginEnvironmentProbeResult,
PluginEnvironmentRealizeWorkspaceParams,
PluginEnvironmentRealizeWorkspaceResult,
PluginEnvironmentReleaseLeaseParams,
PluginEnvironmentResumeLeaseParams,
PluginEnvironmentValidateConfigParams,
PluginEnvironmentValidationResult,
} from "@paperclipai/plugin-sdk";
const DEFAULT_WORKDIR = "/workspace/paperclip";
const DEFAULT_SANDBOX_TIMEOUT_MS = 3_600_000;
const DEFAULT_EXEC_TIMEOUT_MS = 300_000;
const MAX_SANDBOX_TIMEOUT_MS = 86_400_000;
interface ModalDriverConfig {
appName: string;
image: string;
tokenId: string | null;
tokenSecret: string | null;
environment: string | null;
workdir: string;
sandboxTimeoutMs: number;
idleTimeoutMs: number | null;
execTimeoutMs: number;
blockNetwork: boolean;
cidrAllowlist: string[] | null;
reuseLease: boolean;
}
function parseOptionalString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function parseOptionalNumber(value: unknown): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseStringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const trimmed = value
.filter((entry): entry is string => typeof entry === "string")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
return trimmed.length > 0 ? trimmed : null;
}
export function parseDriverConfig(raw: Record<string, unknown>): ModalDriverConfig {
const sandboxTimeoutMsRaw = parseOptionalNumber(raw.sandboxTimeoutMs);
const execTimeoutMsRaw = parseOptionalNumber(raw.execTimeoutMs);
const idleTimeoutMsRaw = parseOptionalNumber(raw.idleTimeoutMs);
return {
appName: parseOptionalString(raw.appName) ?? "",
image: parseOptionalString(raw.image) ?? "",
tokenId: parseOptionalString(raw.tokenId),
tokenSecret: parseOptionalString(raw.tokenSecret),
environment: parseOptionalString(raw.environment),
workdir: parseOptionalString(raw.workdir) ?? DEFAULT_WORKDIR,
sandboxTimeoutMs:
sandboxTimeoutMsRaw != null ? Math.trunc(sandboxTimeoutMsRaw) : DEFAULT_SANDBOX_TIMEOUT_MS,
idleTimeoutMs: idleTimeoutMsRaw != null ? Math.trunc(idleTimeoutMsRaw) : null,
execTimeoutMs:
execTimeoutMsRaw != null ? Math.trunc(execTimeoutMsRaw) : DEFAULT_EXEC_TIMEOUT_MS,
blockNetwork: raw.blockNetwork === true,
cidrAllowlist: parseStringArray(raw.cidrAllowlist),
reuseLease: raw.reuseLease === true,
};
}
function isMultipleOf1000(value: number): boolean {
return value > 0 && value % 1000 === 0;
}
function resolveAuth(config: ModalDriverConfig): { tokenId: string; tokenSecret: string } | null {
// The plugin worker runs in a child process that does not inherit host env
// vars (see PluginWorkerManager.spawnProcess), so MODAL_TOKEN_ID /
// MODAL_TOKEN_SECRET cannot be read here. Credentials must come from the
// environment config, which Paperclip stores as company secrets.
const tokenId = config.tokenId ?? "";
const tokenSecret = config.tokenSecret ?? "";
if (!tokenId && !tokenSecret) return null;
if (!tokenId || !tokenSecret) {
throw new Error("Modal sandbox environments require both tokenId and tokenSecret to be configured.");
}
return { tokenId, tokenSecret };
}
function createModalClient(config: ModalDriverConfig): ModalClient {
const auth = resolveAuth(config);
const params: ConstructorParameters<typeof ModalClient>[0] = {};
if (auth) {
params.tokenId = auth.tokenId;
params.tokenSecret = auth.tokenSecret;
}
if (config.environment) {
params.environment = config.environment;
}
return new ModalClient(params);
}
async function resolveApp(client: ModalClient, config: ModalDriverConfig): Promise<App> {
return await client.apps.fromName(config.appName, {
createIfMissing: true,
environment: config.environment ?? undefined,
});
}
function buildSandboxCreateParams(input: {
config: ModalDriverConfig;
tags: Record<string, string>;
}): SandboxCreateParams {
const params: SandboxCreateParams = {
workdir: input.config.workdir,
timeoutMs: input.config.sandboxTimeoutMs,
blockNetwork: input.config.blockNetwork,
};
if (input.config.idleTimeoutMs != null) {
params.idleTimeoutMs = input.config.idleTimeoutMs;
}
if (input.config.cidrAllowlist && input.config.cidrAllowlist.length > 0) {
params.cidrAllowlist = input.config.cidrAllowlist;
}
// Modal sandboxes accept tag metadata via setTags after creation; the create
// RPC does not take tags directly. We pass them through input so the caller
// can apply them after `create` resolves.
void input.tags;
return params;
}
function buildSandboxTags(input: {
companyId: string;
environmentId: string;
runId?: string;
reuseLease: boolean;
}): Record<string, string> {
return {
"paperclip-provider": "modal",
"paperclip-company-id": input.companyId,
"paperclip-environment-id": input.environmentId,
"paperclip-reuse-lease": input.reuseLease ? "true" : "false",
...(input.runId ? { "paperclip-run-id": input.runId } : {}),
};
}
async function createSandboxFor(
client: ModalClient,
app: App,
config: ModalDriverConfig,
tags: Record<string, string>,
): Promise<Sandbox> {
const image = client.images.fromRegistry(config.image);
const params = buildSandboxCreateParams({ config, tags });
const sandbox = await client.sandboxes.create(app, image, params);
try {
await sandbox.setTags(tags);
} catch (error) {
// setTags is best-effort metadata; surface but do not block lease creation.
console.warn(`Failed to set tags on Modal sandbox ${sandbox.sandboxId}: ${formatErrorMessage(error)}`);
}
return sandbox;
}
function leaseMetadata(input: {
config: ModalDriverConfig;
sandbox: Sandbox;
remoteCwd: string;
resumedLease: boolean;
}) {
return {
provider: "modal",
shellCommand: "sh",
sandboxId: input.sandbox.sandboxId,
appName: input.config.appName,
image: input.config.image,
sandboxTimeoutMs: input.config.sandboxTimeoutMs,
idleTimeoutMs: input.config.idleTimeoutMs,
reuseLease: input.config.reuseLease,
remoteCwd: input.remoteCwd,
resumedLease: input.resumedLease,
};
}
function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function isValidShellEnvKey(value: string): boolean {
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
// Modal's `sandbox.exec` takes an argv array and bypasses the shell entirely,
// so adapter probes that rely on PATH mutations from /etc/profile or ~/.bashrc
// do not work without an explicit login shell. Mirroring the Daytona / E2B
// providers, wrap the user command in a `sh -lc` script that sources common
// login profiles plus nvm before invoking it. Env is set after profile sourcing
// so caller env wins; stdin is staged to a temp file and shell-redirected so
// fast-failing commands do not race a streaming stdin writer.
function buildLoginShellScript(input: {
command: string;
args: string[];
cwd?: string;
env?: Record<string, string>;
stdinPath?: string;
}): string {
const env = input.env ?? {};
for (const key of Object.keys(env)) {
if (!isValidShellEnvKey(key)) {
throw new Error(`Invalid sandbox environment variable key: ${key}`);
}
}
const envArgs = Object.entries(env)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.map(([key, value]) => `${key}=${shellQuote(value)}`);
const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" ");
const redirected = input.stdinPath
? `${commandParts} < ${shellQuote(input.stdinPath)}`
: commandParts;
const finalLine = envArgs.length > 0 ? `exec env ${envArgs.join(" ")} ${redirected}` : `exec ${redirected}`;
const lines = [
'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
'[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true',
];
if (input.cwd) {
lines.push(`cd ${shellQuote(input.cwd)}`);
}
lines.push(finalLine);
return lines.join(" && ");
}
async function ensureRemoteWorkspace(sandbox: Sandbox, remoteCwd: string): Promise<void> {
// Use a one-shot exec to mkdir -p; Modal does not expose a direct
// filesystem mkdir helper and creating a file via `open()` does not create
// intermediate directories.
const proc = await sandbox.exec(["sh", "-lc", `mkdir -p ${shellQuote(remoteCwd)}`]);
const exitCode = await proc.wait();
if (exitCode !== 0) {
throw new Error(
`Failed to create remote workspace directory '${remoteCwd}': mkdir exited with code ${exitCode}`,
);
}
}
async function stageStdin(sandbox: Sandbox, stdin: string, remotePath: string): Promise<void> {
const file = await sandbox.open(remotePath, "w");
try {
await file.write(new TextEncoder().encode(stdin));
await file.flush();
} finally {
await file.close().catch(() => undefined);
}
}
async function deleteStdinPath(sandbox: Sandbox, remotePath: string): Promise<void> {
// Best-effort cleanup of the staged stdin file. We swallow errors because
// it is fine for the file to outlive the sandbox if it is going to be
// terminated, and a missing rm tool would otherwise mask the real result.
try {
const proc = await sandbox.exec(["sh", "-lc", `rm -f ${shellQuote(remotePath)}`]);
await proc.wait();
} catch {
// ignore
}
}
async function readProcessStreams(
proc: ContainerProcess<string>,
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
const [stdout, stderr, exitCode] = await Promise.all([
proc.stdout.readText(),
proc.stderr.readText(),
proc.wait(),
]);
return { stdout, stderr, exitCode };
}
function isModalNotFound(error: unknown): boolean {
return error instanceof NotFoundError;
}
async function getSandboxOrNull(
client: ModalClient,
providerLeaseId: string,
): Promise<Sandbox | null> {
try {
return await client.sandboxes.fromId(providerLeaseId);
} catch (error) {
if (isModalNotFound(error)) return null;
throw error;
}
}
function warnIfUnsupportedNode(logger: { warn: (msg: string) => void } | undefined): void {
const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
if (Number.isFinite(major) && major < 22) {
const message = `Modal sandbox provider is running on Node ${process.versions.node}; Modal officially supports Node 22+. The plugin will attempt to operate but vendor support is not guaranteed below Node 22.`;
logger?.warn(message);
}
}
function leaseRemoteCwd(metadata: Record<string, unknown> | undefined, fallback: string): string {
if (metadata && typeof metadata.remoteCwd === "string" && metadata.remoteCwd.trim().length > 0) {
return metadata.remoteCwd.trim();
}
return fallback;
}
const plugin = definePlugin({
async setup(ctx) {
warnIfUnsupportedNode(ctx.logger);
ctx.logger.info("Modal sandbox provider plugin ready");
},
async onHealth() {
return { status: "ok", message: "Modal sandbox provider plugin healthy" };
},
async onEnvironmentValidateConfig(
params: PluginEnvironmentValidateConfigParams,
): Promise<PluginEnvironmentValidationResult> {
const config = parseDriverConfig(params.config);
const errors: string[] = [];
if (!config.appName) {
errors.push("Modal sandbox environments require an appName.");
}
if (!config.image) {
errors.push("Modal sandbox environments require an image reference.");
}
if (
config.sandboxTimeoutMs < 1000 ||
config.sandboxTimeoutMs > MAX_SANDBOX_TIMEOUT_MS ||
!isMultipleOf1000(config.sandboxTimeoutMs)
) {
errors.push(
"sandboxTimeoutMs must be a positive multiple of 1000 between 1000 and 86400000.",
);
}
if (
config.idleTimeoutMs != null &&
(config.idleTimeoutMs < 1000 || !isMultipleOf1000(config.idleTimeoutMs))
) {
errors.push("idleTimeoutMs must be a positive multiple of 1000 when provided.");
}
if (config.execTimeoutMs < 1000 || !isMultipleOf1000(config.execTimeoutMs)) {
errors.push("execTimeoutMs must be a positive multiple of 1000.");
}
if (config.blockNetwork && config.cidrAllowlist && config.cidrAllowlist.length > 0) {
errors.push("cidrAllowlist cannot be combined with blockNetwork.");
}
const hasTokenId = Boolean(config.tokenId);
const hasTokenSecret = Boolean(config.tokenSecret);
if (hasTokenId !== hasTokenSecret) {
errors.push("tokenId and tokenSecret must both be provided when either is set.");
} else if (!hasTokenId) {
errors.push("Modal sandbox environments require tokenId and tokenSecret.");
}
if (errors.length > 0) {
return { ok: false, errors };
}
return { ok: true, normalizedConfig: { ...config } };
},
async onEnvironmentProbe(
params: PluginEnvironmentProbeParams,
): Promise<PluginEnvironmentProbeResult> {
const config = parseDriverConfig(params.config);
const tags = buildSandboxTags({
companyId: params.companyId,
environmentId: params.environmentId,
reuseLease: false,
});
const client = createModalClient(config);
try {
const app = await resolveApp(client, config);
const sandbox = await createSandboxFor(client, app, config, tags);
try {
await ensureRemoteWorkspace(sandbox, config.workdir);
const proc = await sandbox.exec(["sh", "-lc", "printf paperclip-probe"]);
const { stdout, exitCode } = await readProcessStreams(proc);
if (exitCode !== 0 || stdout.trim() !== "paperclip-probe") {
return {
ok: false,
summary: `Modal sandbox probe failed: exit ${exitCode}, stdout=${JSON.stringify(stdout)}`,
metadata: {
provider: "modal",
sandboxId: sandbox.sandboxId,
appName: config.appName,
image: config.image,
},
};
}
return {
ok: true,
summary: `Connected to Modal sandbox in app ${config.appName}.`,
metadata: {
provider: "modal",
sandboxId: sandbox.sandboxId,
appName: config.appName,
image: config.image,
workdir: config.workdir,
sandboxTimeoutMs: config.sandboxTimeoutMs,
idleTimeoutMs: config.idleTimeoutMs,
reuseLease: config.reuseLease,
remoteCwd: config.workdir,
},
};
} finally {
await sandbox.terminate().catch(() => undefined);
}
} catch (error) {
return {
ok: false,
summary: "Modal sandbox probe failed.",
metadata: {
provider: "modal",
appName: config.appName,
image: config.image,
reuseLease: config.reuseLease,
error: formatErrorMessage(error),
},
};
} finally {
client.close();
}
},
async onEnvironmentAcquireLease(
params: PluginEnvironmentAcquireLeaseParams,
): Promise<PluginEnvironmentLease> {
const config = parseDriverConfig(params.config);
const client = createModalClient(config);
try {
const app = await resolveApp(client, config);
const tags = buildSandboxTags({
companyId: params.companyId,
environmentId: params.environmentId,
runId: params.runId,
reuseLease: config.reuseLease,
});
const sandbox = await createSandboxFor(client, app, config, tags);
try {
await ensureRemoteWorkspace(sandbox, config.workdir);
return {
providerLeaseId: sandbox.sandboxId,
metadata: leaseMetadata({
config,
sandbox,
remoteCwd: config.workdir,
resumedLease: false,
}),
};
} catch (error) {
await sandbox.terminate().catch(() => undefined);
throw error;
}
} finally {
// Keep the client open for the lease lifetime is unnecessary; subsequent
// calls construct their own client. Close the local handle to free
// grpc resources.
client.close();
}
},
async onEnvironmentResumeLease(
params: PluginEnvironmentResumeLeaseParams,
): Promise<PluginEnvironmentLease> {
const config = parseDriverConfig(params.config);
const client = createModalClient(config);
try {
const sandbox = await getSandboxOrNull(client, params.providerLeaseId);
if (!sandbox) {
return { providerLeaseId: null, metadata: { expired: true } };
}
try {
await ensureRemoteWorkspace(sandbox, config.workdir);
return {
providerLeaseId: sandbox.sandboxId,
metadata: leaseMetadata({ config, sandbox, remoteCwd: config.workdir, resumedLease: true }),
};
} catch (error) {
// If we just resumed and workspace setup blew up, treat as a lease
// failure rather than silently terminating the user's reusable
// sandbox. Detach so the sandbox is not killed for a transient setup
// error.
void sandbox.detach();
throw error;
}
} finally {
client.close();
}
},
async onEnvironmentReleaseLease(
params: PluginEnvironmentReleaseLeaseParams,
): Promise<void> {
if (!params.providerLeaseId) return;
const config = parseDriverConfig(params.config);
const client = createModalClient(config);
try {
const sandbox = await getSandboxOrNull(client, params.providerLeaseId);
if (!sandbox) return;
if (config.reuseLease) {
// Modal has no separate pause primitive. Detaching releases the local
// grpc connection but leaves the sandbox running on Modal until its
// configured sandboxTimeoutMs or idleTimeoutMs expires. The next
// acquire/resume reconnects via sandboxes.fromId(providerLeaseId).
void sandbox.detach();
return;
}
await sandbox.terminate();
} finally {
client.close();
}
},
async onEnvironmentDestroyLease(
params: PluginEnvironmentDestroyLeaseParams,
): Promise<void> {
if (!params.providerLeaseId) return;
const config = parseDriverConfig(params.config);
const client = createModalClient(config);
try {
const sandbox = await getSandboxOrNull(client, params.providerLeaseId);
if (!sandbox) return;
await sandbox.terminate();
} finally {
client.close();
}
},
async onEnvironmentRealizeWorkspace(
params: PluginEnvironmentRealizeWorkspaceParams,
): Promise<PluginEnvironmentRealizeWorkspaceResult> {
const config = parseDriverConfig(params.config);
const fallback =
params.workspace.remotePath ??
params.workspace.localPath ??
config.workdir;
const remoteCwd = leaseRemoteCwd(params.lease.metadata, fallback);
if (params.lease.providerLeaseId) {
const client = createModalClient(config);
try {
const sandbox = await getSandboxOrNull(client, params.lease.providerLeaseId);
if (sandbox) {
await ensureRemoteWorkspace(sandbox, remoteCwd);
}
} finally {
client.close();
}
}
return {
cwd: remoteCwd,
metadata: { provider: "modal", remoteCwd },
};
},
async onEnvironmentExecute(
params: PluginEnvironmentExecuteParams,
): Promise<PluginEnvironmentExecuteResult> {
if (!params.lease.providerLeaseId) {
return {
exitCode: 1,
timedOut: false,
stdout: "",
stderr: "No provider lease ID available for execution.",
};
}
const config = parseDriverConfig(params.config);
const client = createModalClient(config);
const callerTimeoutMs =
params.timeoutMs != null && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0
? Math.max(1000, Math.trunc(params.timeoutMs / 1000) * 1000)
: config.execTimeoutMs;
try {
const sandbox = await getSandboxOrNull(client, params.lease.providerLeaseId);
if (!sandbox) {
return {
exitCode: 1,
timedOut: false,
stdout: "",
stderr: "Modal sandbox lease is no longer available.\n",
};
}
const stdinPath = params.stdin != null
? `/tmp/paperclip-stdin-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
: null;
try {
if (stdinPath && params.stdin != null) {
await stageStdin(sandbox, params.stdin, stdinPath);
}
const script = buildLoginShellScript({
command: params.command,
args: params.args ?? [],
cwd: params.cwd ?? config.workdir,
env: params.env,
stdinPath: stdinPath ?? undefined,
});
const proc = await sandbox.exec(["sh", "-lc", script], {
timeoutMs: callerTimeoutMs,
stdout: "pipe",
stderr: "pipe",
});
const { stdout, stderr, exitCode } = await readProcessStreams(proc);
return {
exitCode,
timedOut: false,
stdout,
stderr,
};
} catch (error) {
if (error instanceof TimeoutError || error instanceof SandboxTimeoutError) {
return {
exitCode: null,
timedOut: true,
stdout: "",
stderr: `${formatErrorMessage(error)}\n`,
};
}
throw error;
} finally {
if (stdinPath) {
await deleteStdinPath(sandbox, stdinPath);
}
}
} finally {
client.close();
}
},
});
export default plugin;
@@ -0,0 +1,5 @@
import { runWorker } from "@paperclipai/plugin-sdk";
import plugin from "./plugin.js";
export default plugin;
runWorker(plugin, import.meta.url);
@@ -0,0 +1,11 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023"],
"types": ["node"]
},
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
environment: "node",
},
});
+11 -2
View File
@@ -100,7 +100,7 @@ runWorker(plugin, import.meta.url);
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
**Context (`ctx`) in setup:** `config`, `localFolders`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `access`, `authorization`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
@@ -134,7 +134,7 @@ Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name,
**Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events.
**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime.
**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime. Access and authorization host services require an active company-scoped invocation such as an event, API route, tool run, environment call, or UI bridge call; the requested `companyId` must match that active scope.
## Scheduled (recurring) jobs
@@ -321,6 +321,11 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `activity.read` |
| | `costs.read` |
| | `issues.orchestration.read` |
| | `access.members.read` |
| | `access.invites.read` |
| | `authorization.grants.read` |
| | `authorization.policies.read` |
| | `authorization.audit.read` |
| | `database.namespace.read` |
| | `issues.create` |
| | `issues.update` |
@@ -348,6 +353,10 @@ Declare in `manifest.capabilities`. Grouped by scope:
| | `local.folders` |
| **Agent** | `agent.tools.register` |
| | `agents.invoke` |
| | `access.members.write` |
| | `access.invites.write` |
| | `authorization.grants.write` |
| | `authorization.policies.write` |
| | `agent.sessions.create` |
| | `agent.sessions.list` |
| | `agent.sessions.send` |

Some files were not shown because too many files have changed in this diff Show More