mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-02 02:07:25 +08:00
Use prompt delivery instead of duplicate wake environment JSON
Built-in adapters already render wake context in the prompt. Remove the second serialized copy from their environments so large continuations do not prevent process startup. Apply the prompt-only design from #13144 without its history caps or API changes. Reserve the retired key against config injection, preserve serializers used by gateway bodies and Hermes templates, and cover large local and sandbox stdin launches plus fresh and resumed ACP turns. Remove the file-transport implementation, which is no longer needed. Validation: 340 focused tests and 18 Hermes tests passed; recursive typecheck passed. Full build and full test run are in progress. The full local suite reproduces the existing macOS skill-cache permission failure. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
+19
-21
@@ -345,27 +345,6 @@ Allow additional private hostnames (for example custom Tailscale hostnames):
|
||||
npx paperclipai allowed-hostname dotta-macbook-pro
|
||||
```
|
||||
|
||||
## Wake payload process transport
|
||||
|
||||
Process adapters keep `PAPERCLIP_WAKE_PAYLOAD_JSON` inline up to 64 KiB of
|
||||
UTF-8 data (including shell quoting overhead for remote launches). Larger values are written without modification to a private file
|
||||
on the execution host. The child receives `PAPERCLIP_WAKE_PAYLOAD_PATH` instead
|
||||
of the JSON variable. Consumers must support both forms. Prompt rendering and
|
||||
gateway request serialization remain unchanged.
|
||||
|
||||
Local files use a unique temporary directory (0700) and file (0600). Confined
|
||||
local processes receive a read-only mount of that file. SSH and sandbox launches
|
||||
upload bounded chunks into an exclusively created directory, then verify the
|
||||
byte count before starting the child. Failed uploads prevent launch. The process
|
||||
or ACP turn owns cleanup, including startup failure and cancellation paths. ACP
|
||||
prompts name the current turn's file so a resumed process cannot select an old
|
||||
wake from its initial environment. Invocation logs record payload size only.
|
||||
|
||||
The files are temporary run data, not durable context. An abrupt host or worker
|
||||
crash can leave a private temporary directory for the host's normal temporary
|
||||
file cleanup. This transport does not bound other environment variables or CLI
|
||||
prompt arguments, and does not apply to hosted SDK or gateway request bodies.
|
||||
|
||||
## Test Commands
|
||||
|
||||
Use the cheap local default unless you are specifically working on browser flows:
|
||||
@@ -1010,6 +989,25 @@ In Vite middleware mode, Paperclip gives HMR a dedicated HTTP server bound to th
|
||||
|
||||
When a workspace service runs Paperclip for browser OAuth QA, configure its `expose.urlTemplate` with the canonical URL the browser can reach. Paperclip preserves explicit `PAPERCLIP_PUBLIC_URL` or `BETTER_AUTH_URL` settings; otherwise it uses a valid exposed HTTPS origin (or loopback HTTP) as the managed runtime fallback for Better Auth and `/api/tools/oauth/callback`. Internal service names such as `http://paperclip-dev:<port>` are rejected unless that hostname is genuinely the browser route. Use a unique origin per isolated worktree. See [Execution Workspaces And Runtime Services](../docs/guides/board-operator/execution-workspaces-and-runtime-services.md#browser-reachable-origins-for-oauth-qa) for configuration and verification.
|
||||
|
||||
## Wake Context Delivery
|
||||
|
||||
Built-in adapters deliver wake context through the run prompt, including structured
|
||||
execution-continuation data. They do not export `PAPERCLIP_WAKE_PAYLOAD_JSON`. A
|
||||
large JSON environment entry can prevent the agent process from starting with
|
||||
`E2BIG`, even when the same context fits in the prompt transport. Configured values
|
||||
for this retired variable are ignored. Scalar runtime variables such as
|
||||
`PAPERCLIP_TASK_ID` and `PAPERCLIP_WAKE_REASON` remain available.
|
||||
|
||||
Custom instructions that read the retired variable must use the wake payload in
|
||||
the prompt instead. This transport change adds no history limits or truncation;
|
||||
existing comment windows and resume-delta rendering still apply. Gateway request
|
||||
bodies and Hermes prompt-template JSON variables remain supported.
|
||||
|
||||
This removes the duplicate environment entry, not every possible `E2BIG` cause.
|
||||
Legacy CLI paths that put prompts in command-line arguments (Gemini, Grok, Kimi,
|
||||
Pi, and Hermes) still have argument-size limits. ACP turns, SDK requests, and
|
||||
CLI paths that use stdin avoid that separate limit for the wake prompt.
|
||||
|
||||
## Paperclip Runner Adapter Conversion
|
||||
|
||||
The experimental Paperclip Runner offers native Codex, OpenCode, and **ACPX
|
||||
|
||||
@@ -132,24 +132,28 @@ function createLocalSandboxRunner(
|
||||
|
||||
function buildRuntime(
|
||||
onSetConfigOption?: (input: { key: string; value: string }) => void,
|
||||
onEnsureSession?: (input: Record<string, unknown>) => unknown,
|
||||
onEnsureSession?: (input: Record<string, unknown>) => void,
|
||||
onStartTurn?: (input: Record<string, unknown>) => void,
|
||||
) {
|
||||
return {
|
||||
ensureSession: async (input: Record<string, unknown>) => {
|
||||
await onEnsureSession?.(input);
|
||||
onEnsureSession?.(input);
|
||||
return ({
|
||||
backendSessionId: "backend-session",
|
||||
agentSessionId: "agent-session",
|
||||
runtimeSessionName: "runtime-session",
|
||||
});
|
||||
},
|
||||
startTurn: () => ({
|
||||
events: (async function* () {
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
}),
|
||||
startTurn: (input: Record<string, unknown>) => {
|
||||
onStartTurn?.(input);
|
||||
return {
|
||||
events: (async function* () {
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
})(),
|
||||
result: Promise.resolve({ status: "completed", stopReason: "end_turn" }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
},
|
||||
setConfigOption: async (input: { key: string; value: string }) => {
|
||||
onSetConfigOption?.(input);
|
||||
},
|
||||
@@ -168,12 +172,12 @@ async function runExecutor(
|
||||
runtimeMcp?: AdapterRuntimeMcpAccess;
|
||||
prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"];
|
||||
startupTraceContext?: AdapterExecutionContext["startupTraceContext"];
|
||||
inspectSession?: (input: Record<string, unknown>) => Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
const runtimeOptions: Record<string, unknown>[] = [];
|
||||
const configOptions: Array<{ key: string; value: string }> = [];
|
||||
const sessionInputs: Record<string, unknown>[] = [];
|
||||
const turnInputs: Record<string, unknown>[] = [];
|
||||
const meta: Record<string, unknown>[] = [];
|
||||
const logs: Array<{ stream: string; text: string }> = [];
|
||||
const events: Array<{ eventType: string; payload?: Record<string, unknown> }> = [];
|
||||
@@ -181,11 +185,12 @@ async function runExecutor(
|
||||
...(options.prepareRemoteManagedHome
|
||||
? { prepareRemoteManagedHome: options.prepareRemoteManagedHome }
|
||||
: {}),
|
||||
createRuntime: (runtimeConfig) => {
|
||||
runtimeOptions.push(runtimeConfig as unknown as Record<string, unknown>);
|
||||
createRuntime: (options) => {
|
||||
runtimeOptions.push(options as unknown as Record<string, unknown>);
|
||||
return buildRuntime(
|
||||
({ key, value }) => configOptions.push({ key, value }),
|
||||
async (input) => { sessionInputs.push(input); await options.inspectSession?.(input); },
|
||||
(input) => sessionInputs.push(input),
|
||||
(input) => turnInputs.push(input),
|
||||
) as never;
|
||||
},
|
||||
});
|
||||
@@ -216,7 +221,7 @@ async function runExecutor(
|
||||
} as never);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
return { logs, meta, events, runtimeOptions, configOptions, sessionInputs, result };
|
||||
return { logs, meta, events, runtimeOptions, configOptions, sessionInputs, turnInputs, result };
|
||||
}
|
||||
|
||||
// Under `vi.useFakeTimers()`, setup before `ensureSession` still performs real
|
||||
@@ -370,64 +375,6 @@ const ALLOWED_TURN_SPAN_ATTRIBUTE_KEYS = new Set<string>([
|
||||
]);
|
||||
|
||||
describe("shared ACPX engine runtime behavior", () => {
|
||||
it("removes the oversized wake file when ACP initialization fails", async () => {
|
||||
const root = await makeTempRoot();
|
||||
let filePath = "";
|
||||
const execute = createAcpxEngineExecutor({ createRuntime: () => ({
|
||||
...buildRuntime(),
|
||||
ensureSession: async (input: { sessionOptions: { env: Record<string, string> } }) => {
|
||||
filePath = input.sessionOptions.env.PAPERCLIP_WAKE_PAYLOAD_PATH!;
|
||||
expect(await fs.readFile(filePath, "utf8")).toContain("complete description");
|
||||
throw new Error("synthetic initialization failure");
|
||||
},
|
||||
}) as never });
|
||||
const result = await execute({
|
||||
runId: "failed-wake-init", agent: { id: "agent-1", companyId: "company-1" }, runtime: {},
|
||||
config: { agent: "custom", agentCommand: "node ./fake-acp.js", cwd: root, stateDir: path.join(root, "state") },
|
||||
context: { paperclipWake: { issue: { id: "issue-1", description: "complete description ".repeat(40_000) } } },
|
||||
onLog: async () => {}, onMeta: async () => {},
|
||||
} as never);
|
||||
expect(result.errorCode).toBe("acpx_session_init_failed");
|
||||
expect(filePath).not.toBe("");
|
||||
await expect(fs.stat(path.dirname(filePath))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("delivers the full oversized wake during ACP initialization and cleans it up after each resumed turn", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const config = { agent: "custom", agentCommand: "node ./fake-acp.js", cwd: root, stateDir: path.join(root, "state") };
|
||||
let sessionParams: unknown;
|
||||
const paths: string[] = [];
|
||||
for (const marker of ["first", "resumed", "small"]) {
|
||||
const description = marker + "🙂 complete context ".repeat(marker === "small" ? 1 : 40_000);
|
||||
const run = await runExecutor(config, {
|
||||
runtime: sessionParams ? { sessionParams } : {},
|
||||
context: { taskId: "issue-1", paperclipWake: { issue: { id: "issue-1", identifier: "TEST-1", description } } },
|
||||
inspectSession: async (input) => {
|
||||
const env = (input.sessionOptions as { env: Record<string, string> }).env;
|
||||
if (marker === "small") {
|
||||
expect(JSON.parse(env.PAPERCLIP_WAKE_PAYLOAD_JSON!).issue.description).toBe(description);
|
||||
expect(env.PAPERCLIP_WAKE_PAYLOAD_PATH).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
expect(env.PAPERCLIP_WAKE_PAYLOAD_JSON).toBeUndefined();
|
||||
const filePath = env.PAPERCLIP_WAKE_PAYLOAD_PATH!;
|
||||
paths.push(filePath);
|
||||
expect(JSON.parse(await fs.readFile(filePath, "utf8")).issue.description).toBe(description);
|
||||
},
|
||||
});
|
||||
sessionParams = run.result.sessionParams;
|
||||
if (marker === "small") {
|
||||
expect(String(run.meta[0]?.prompt)).toContain("do not read a previous turn's wake payload file");
|
||||
for (const filePath of paths) expect(String(run.meta[0]?.prompt)).not.toContain(filePath);
|
||||
continue;
|
||||
}
|
||||
expect(String(run.meta[0]?.prompt)).toContain(JSON.stringify(paths.at(-1)));
|
||||
expect(String(run.meta[0]?.prompt)).toContain("takes precedence over any older wake environment");
|
||||
await expect(fs.stat(paths.at(-1)!)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
expect(new Set(paths).size).toBe(2);
|
||||
});
|
||||
|
||||
it.each(["claude", "codex", "gemini", "kimi", "custom"])("defaults the legacy %s engine to full auto on fresh and resumed runs", async (agent) => {
|
||||
const root = await makeTempRoot();
|
||||
const config = {
|
||||
@@ -662,7 +609,9 @@ describe("shared ACPX engine runtime behavior", () => {
|
||||
expect(prompt).toContain("Paperclip runtime note:");
|
||||
expect(prompt).toContain("PAPERCLIP_AGENT_ID");
|
||||
expect(prompt).toContain("PAPERCLIP_API_KEY");
|
||||
expect(prompt).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
expect(prompt).not.toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
expect(prompt).toContain("## Paperclip Wake Payload");
|
||||
expect(prompt).toContain("TEST-1");
|
||||
expect(prompt).toContain("Paperclip API access note:");
|
||||
expect(prompt).toContain('PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}"; PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}"');
|
||||
expect(prompt).toContain("$PAPERCLIP_API_BASE/api/agents/me");
|
||||
@@ -675,6 +624,57 @@ describe("shared ACPX engine runtime behavior", () => {
|
||||
expect(promptMetrics?.runtimeNoteChars).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("keeps large continuation history in ACP turns and preserves resume deltas", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const config = {
|
||||
agent: "claude", cwd: root, stateDir: path.join(root, "state"), mode: "persistent",
|
||||
env: { PAPERCLIP_WAKE_PAYLOAD_JSON: "stale configured wake" },
|
||||
};
|
||||
const messages = Array.from({ length: 50 }, (_, index) => ({
|
||||
id: `message-${index}`, authorType: "user", authorId: "user-1",
|
||||
body: `Message ${index}: ${"context ".repeat(500)} End ${index}.`,
|
||||
createdAt: "2020-01-01T00:00:00.000Z", updatedAt: "2020-01-01T00:00:00.000Z",
|
||||
deleted: false, sourceTrust: { kind: "authenticated_user" },
|
||||
}));
|
||||
const completedActions = Array.from({ length: 50 }, (_, index) => ({
|
||||
runId: "prior-run", receiptId: `receipt-${index}`, operationId: `operation-${index}`,
|
||||
result: { text: `Completed action ${index}` },
|
||||
}));
|
||||
const continuation = {
|
||||
version: 1, companyId: "company-1", issueId: "issue-1",
|
||||
trigger: { reason: "issue_commented", interactionId: null, sourceRunId: null },
|
||||
originCommentIds: [messages[49]!.id], objective: "Preserve all context", messages,
|
||||
interactionOutcomes: [], unresolvedInteractionIds: [], completedWork: null,
|
||||
completedActions, coverage: { kind: "full_task_history", throughCommentId: messages[49]!.id, summaryThroughCommentId: null },
|
||||
};
|
||||
expect(Buffer.byteLength(JSON.stringify(continuation))).toBeGreaterThan(128 * 1024);
|
||||
const context = { taskId: "issue-1", paperclipWake: {
|
||||
reason: "issue_commented", issue: { id: "issue-1" }, executionContinuation: continuation,
|
||||
} };
|
||||
const fresh = await runExecutor(config, { context });
|
||||
const changedMessage = { ...messages[49]!, body: "Updated direction: preserve approval gates." };
|
||||
const resumed = await runExecutor(config, {
|
||||
runtime: { sessionParams: fresh.result.sessionParams },
|
||||
context: { ...context, paperclipWake: { ...context.paperclipWake, executionContinuation: {
|
||||
...continuation, resumeDelta: { baseRunId: "run-1", messages: [changedMessage] },
|
||||
} } },
|
||||
});
|
||||
expect(resumed.sessionInputs[0]?.resumeSessionId).toBe(fresh.result.sessionId);
|
||||
for (const run of [fresh, resumed]) {
|
||||
const sessionOptions = run.sessionInputs[0]?.sessionOptions as Record<string, unknown>;
|
||||
expect(sessionOptions.env).not.toHaveProperty("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
const prompt = String(run.turnInputs[0]?.text);
|
||||
expect(prompt).not.toContain("stale configured wake");
|
||||
for (const action of completedActions) expect(prompt).toContain(JSON.stringify(action));
|
||||
}
|
||||
const freshPrompt = String(fresh.turnInputs[0]?.text);
|
||||
for (const message of messages) expect(freshPrompt).toContain(JSON.stringify(message));
|
||||
const resumedPrompt = String(resumed.turnInputs[0]?.text);
|
||||
expect(resumedPrompt).toContain(JSON.stringify(changedMessage));
|
||||
expect(resumedPrompt).not.toContain(messages[0]!.body);
|
||||
expect(resumedPrompt).toContain('"kind":"task_history_delta"');
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude", false], ["codex", false], ["claude", true], ["codex", true],
|
||||
] as const)("keeps %s ACP conversation policy on fresh, resumed, and reset turns (custom=%s)", async (agent, custom) => {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
formatAdapterExecutionTimeoutErrorMessage,
|
||||
formatAdapterExecutionTimeoutStartLogLine,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
prepareAdapterWakePayloadEnv,
|
||||
readAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetTimeout,
|
||||
resolveReferencedSourceIgnore,
|
||||
@@ -73,11 +72,9 @@ import {
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
rewriteWorkspaceCwdEnvVarsForExecution,
|
||||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
type PaperclipSkillEntry,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
|
||||
import { renderWakePayloadFileNote, type WakePayloadDelivery } from "../wake-payload-env.js";
|
||||
import {
|
||||
createAcpRuntime,
|
||||
createAgentRegistry,
|
||||
@@ -431,7 +428,6 @@ export interface AcpxEngineExecutorOptions {
|
||||
}
|
||||
|
||||
interface AcpxPreparedRuntime {
|
||||
wakePayloadDelivery: WakePayloadDelivery;
|
||||
acpxAgent: string;
|
||||
coalescePlaceholderToolUpdates: boolean;
|
||||
mode: "persistent" | "oneshot";
|
||||
@@ -1931,7 +1927,6 @@ async function buildRuntime(input: {
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): 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;
|
||||
@@ -1940,7 +1935,6 @@ async function buildRuntime(input: {
|
||||
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;
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
||||
workspaceSource,
|
||||
@@ -2436,13 +2430,8 @@ async function buildRuntime(input: {
|
||||
let paperclipBridge: AdapterExecutionTargetPaperclipBridgeHandle | null = null;
|
||||
let processSessionBridge: AdapterExecutionTargetProcessSessionBridgeHandle | null = null;
|
||||
let runtimeEnv: Record<string, string> = {};
|
||||
let wakePayloadDelivery: WakePayloadDelivery | null = null;
|
||||
const startTransportStart = nowMs();
|
||||
try {
|
||||
wakePayloadDelivery = await prepareAdapterWakePayloadEnv(useRemoteProcessSession ? executionTarget : null, env);
|
||||
delete env.PAPERCLIP_WAKE_PAYLOAD_JSON;
|
||||
delete env.PAPERCLIP_WAKE_PAYLOAD_PATH;
|
||||
Object.assign(env, wakePayloadDelivery.env);
|
||||
if (useRemoteProcessSession && sandboxSite) {
|
||||
// The sandbox run site brings up both host-side bridges concurrently, keeps
|
||||
// the one paperclip-env → process-session-launch dependency at a single
|
||||
@@ -2472,7 +2461,6 @@ async function buildRuntime(input: {
|
||||
const startedControl = sandboxSite?.controlBridge ?? paperclipBridge;
|
||||
const startedAgent = sandboxSite?.agentBridge ?? processSessionBridge;
|
||||
await Promise.allSettled([startedControl?.stop(), startedAgent?.stop()]);
|
||||
await wakePayloadDelivery?.cleanup().catch(() => {});
|
||||
// The staged home / copy-back teardown must run even if a bridge fails to
|
||||
// start after the workspace + managed home were already staged into the
|
||||
// sandbox, so a refreshed credential is copied back on this error path too.
|
||||
@@ -2515,7 +2503,6 @@ async function buildRuntime(input: {
|
||||
});
|
||||
|
||||
return {
|
||||
wakePayloadDelivery,
|
||||
acpxAgent,
|
||||
coalescePlaceholderToolUpdates,
|
||||
mode,
|
||||
@@ -3018,7 +3005,6 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean
|
||||
: renderTemplate(promptTemplate, templateData);
|
||||
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
||||
const paperclipEnvNote = externalChatTurn ? "" : renderPaperclipEnvNote(env);
|
||||
const wakePayloadFileNote = externalChatTurn ? "" : renderWakePayloadFileNote(env, resumedSession);
|
||||
const apiAccessNote = externalChatTurn ? "" : renderApiAccessNote(env);
|
||||
const prompt = joinPromptSections([
|
||||
promptInstructionsPrefix,
|
||||
@@ -3027,7 +3013,6 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean
|
||||
sessionHandoffNote,
|
||||
taskContextNote,
|
||||
paperclipEnvNote,
|
||||
wakePayloadFileNote,
|
||||
apiAccessNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
@@ -3042,7 +3027,7 @@ async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length + wakePayloadFileNote.length + apiAccessNote.length,
|
||||
runtimeNoteChars: paperclipEnvNote.length + apiAccessNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
},
|
||||
};
|
||||
@@ -3939,7 +3924,6 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
|
||||
// null on the host lane (no staging) and on a build failure (where
|
||||
// `buildRuntime` already released its own partial lease).
|
||||
let releaseStagingLease: (() => void) | null = null;
|
||||
let wakePayloadDelivery: WakePayloadDelivery | null = null;
|
||||
let stopTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let removeStopListener: (() => void) | undefined;
|
||||
// Unregisters the sandbox duplex bridge's loss listener (below, in
|
||||
@@ -4168,7 +4152,6 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
|
||||
// Capture acquired resources before the cancellation boundary so the
|
||||
// normal settlement path also releases a just-completed build.
|
||||
releaseStagingLease = prepared.sessionStagingLeaseRelease;
|
||||
wakePayloadDelivery = prepared.wakePayloadDelivery;
|
||||
} finally {
|
||||
await startupCancellation.finish();
|
||||
}
|
||||
@@ -5444,9 +5427,6 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
|
||||
return await runAttempt(plan);
|
||||
} finally {
|
||||
clearTimeout(stopTimer);
|
||||
await (wakePayloadDelivery as WakePayloadDelivery | null)?.cleanup().catch(async () => {
|
||||
await ctx.onLog("stderr", "[paperclip] Could not remove the wake payload file after the turn ended.\n").catch(() => {});
|
||||
});
|
||||
removeStopListener?.();
|
||||
removeLossListener?.();
|
||||
clearTimeout(lossDeadlineTimer);
|
||||
|
||||
@@ -574,43 +574,6 @@ describe("sandbox adapter execution targets", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])("delivers an oversized wake file through the real session wrapper (streamed=%s)", async (streamOutputViaSession) => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-wake-session-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const payload = JSON.stringify({ description: "full 🙂 wake history ".repeat(40_000) });
|
||||
const childPath = path.join(rootDir, "child.mjs");
|
||||
await writeFile(childPath, `import fs from 'node:fs';
|
||||
const p = process.env.PAPERCLIP_WAKE_PAYLOAD_PATH;
|
||||
process.stdout.write(JSON.stringify({ path: p, payload: fs.readFileSync(p, 'utf8'), inline: process.env.PAPERCLIP_WAKE_PAYLOAD_JSON ?? null }));`);
|
||||
const delegate = createLocalSandboxRunner();
|
||||
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
|
||||
runId: "large-wake-session", adapterKey: "acpx", runtimeRootDir: rootDir,
|
||||
target: { kind: "remote", transport: "sandbox", remoteCwd: rootDir, runner: {
|
||||
execute: async (input) => {
|
||||
for (const value of [...(input.args ?? []), ...Object.values(input.env ?? {})]) {
|
||||
expect(Buffer.byteLength(value)).toBeLessThan(128 * 1024);
|
||||
}
|
||||
return delegate.execute(input);
|
||||
},
|
||||
} },
|
||||
command: process.execPath, args: [childPath], cwd: rootDir,
|
||||
env: { PAPERCLIP_WAKE_PAYLOAD_JSON: payload }, timeoutSec: 10, streamOutputViaSession,
|
||||
});
|
||||
let filePath = "";
|
||||
try {
|
||||
const result = await runProxyWithInput(bridge!.agentCommand, "", true);
|
||||
expect(result.code).toBe(0);
|
||||
const received = JSON.parse(result.stdout);
|
||||
expect(received.payload).toBe(payload);
|
||||
expect(received.inline).toBeNull();
|
||||
filePath = received.path;
|
||||
expect(await readFile(filePath, "utf8")).toBe(payload);
|
||||
} finally {
|
||||
await bridge?.stop();
|
||||
}
|
||||
await expect(stat(path.dirname(filePath))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}, 30_000);
|
||||
|
||||
it("logs a streamed wrapper launch failure before forwarding its exit", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-wrapper-launch-error-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
@@ -80,7 +80,6 @@ import {
|
||||
type TerminalResultCleanupOptions,
|
||||
} from "./server-utils.js";
|
||||
import { sanitizeRemoteExecutionEnv } from "./remote-execution-env.js";
|
||||
import { prepareWakePayloadEnv, type WakePayloadDelivery } from "./wake-payload-env.js";
|
||||
import { preferredShellForSandbox, shellCommandArgs } from "./sandbox-shell.js";
|
||||
import {
|
||||
runWithRuntimeParent,
|
||||
@@ -707,11 +706,6 @@ export async function ensureAdapterExecutionTargetCommandResolvable(
|
||||
env: NodeJS.ProcessEnv,
|
||||
options: { installCommand?: string | null; timeoutSec?: number | null } = {},
|
||||
) {
|
||||
// A command lookup never consumes wake context. Do not forward a large
|
||||
// payload (or a previous turn's file) through the provider's probe launch.
|
||||
env = { ...env };
|
||||
delete env.PAPERCLIP_WAKE_PAYLOAD_JSON;
|
||||
delete env.PAPERCLIP_WAKE_PAYLOAD_PATH;
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
await ensureSandboxCommandResolvable(
|
||||
command,
|
||||
@@ -863,47 +857,6 @@ export async function runAdapterExecutionTargetProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: AdapterExecutionTargetProcessOptions,
|
||||
): Promise<RunProcessResult> {
|
||||
if (target?.kind !== "remote" || target.transport !== "sandbox") {
|
||||
return runAdapterExecutionTargetProcessWithPreparedEnv(runId, target, command, args, options);
|
||||
}
|
||||
const delivery = await prepareAdapterWakePayloadEnv(target, options.env);
|
||||
try {
|
||||
return await runAdapterExecutionTargetProcessWithPreparedEnv(runId, target, command, args, { ...options, env: delivery.env });
|
||||
} finally {
|
||||
await delivery.cleanup().catch(async () => {
|
||||
await options.onLog("stderr", "[paperclip] Could not remove the wake payload file after the process ended.\n").catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepare on the host where the provider process actually runs. */
|
||||
export async function prepareAdapterWakePayloadEnv(
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
env: Record<string, string>,
|
||||
): Promise<WakePayloadDelivery> {
|
||||
if (target?.kind !== "remote") return prepareWakePayloadEnv(env);
|
||||
if (target.transport === "ssh") {
|
||||
return prepareWakePayloadEnv(env, async (script) =>
|
||||
(await runSshCommand(target.spec, script, { timeoutMs: 30_000, maxBuffer: 64 * 1024 })).stdout);
|
||||
}
|
||||
const runner = requireSandboxRunner(target);
|
||||
return prepareWakePayloadEnv(env, async (script) => {
|
||||
const result = await runner.execute({
|
||||
command: "sh", args: ["-c", script], cwd: target.remoteCwd,
|
||||
timeoutMs: 30_000, bypassSession: true,
|
||||
});
|
||||
if (result.timedOut || result.exitCode !== 0) throw new Error("Wake payload file operation failed.");
|
||||
return result.stdout;
|
||||
});
|
||||
}
|
||||
|
||||
async function runAdapterExecutionTargetProcessWithPreparedEnv(
|
||||
runId: string,
|
||||
target: AdapterExecutionTarget | null | undefined,
|
||||
command: string,
|
||||
args: string[],
|
||||
options: AdapterExecutionTargetProcessOptions,
|
||||
): Promise<RunProcessResult> {
|
||||
if (target?.kind === "remote" && target.transport === "sandbox") {
|
||||
const runner = requireSandboxRunner(target);
|
||||
@@ -1979,35 +1932,7 @@ const AGENT_SESSION_SEND_INPUT_SPAN = "sandbox.agentSession.sendInput";
|
||||
* file found (`1 + 2n` execs). */
|
||||
const AGENT_SESSION_POLL_OUTPUT_SPAN = "sandbox.agentSession.pollOutput";
|
||||
|
||||
export async function startAdapterExecutionTargetProcessSessionBridge(
|
||||
input: Parameters<typeof startProcessSessionBridgeWithPreparedEnv>[0],
|
||||
): Promise<AdapterExecutionTargetProcessSessionBridgeHandle | null> {
|
||||
let delivery: WakePayloadDelivery | null = null;
|
||||
const cleanup = async () => {
|
||||
await delivery?.cleanup().catch(async () => {
|
||||
await input.onLog?.("stderr", "[paperclip] Could not remove the wake payload file after the session ended.\n").catch(() => {});
|
||||
});
|
||||
};
|
||||
try {
|
||||
const bridge = await startProcessSessionBridgeWithPreparedEnv({
|
||||
...input,
|
||||
env: async () => {
|
||||
const env = typeof input.env === "function" ? await input.env() : input.env;
|
||||
delivery = await prepareAdapterWakePayloadEnv(input.target, env);
|
||||
return delivery.env;
|
||||
},
|
||||
});
|
||||
if (!bridge) { await cleanup(); return null; }
|
||||
return { ...bridge, stop: async () => {
|
||||
try { await bridge.stop(); } finally { await cleanup(); }
|
||||
} };
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function startProcessSessionBridgeWithPreparedEnv(input: {
|
||||
export async function startAdapterExecutionTargetProcessSessionBridge(input: {
|
||||
runId: string;
|
||||
target: AdapterExecutionTarget | null | undefined;
|
||||
runtimeRootDir: string | null | undefined;
|
||||
|
||||
@@ -3717,6 +3717,16 @@ describe("refreshPaperclipWorkspaceEnvForExecution", () => {
|
||||
expect(env.PAPERCLIP_CLOUD_PROVIDER_TOKEN).toBe("cloud-token");
|
||||
});
|
||||
|
||||
it("does not restore the retired wake JSON variable from config", () => {
|
||||
const env: Record<string, string> = {};
|
||||
refreshPaperclipWorkspaceEnvForExecution({
|
||||
env,
|
||||
envConfig: { PAPERCLIP_WAKE_PAYLOAD_JSON: "stale wake" },
|
||||
workspaceCwd: null,
|
||||
});
|
||||
expect(env).not.toHaveProperty("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
});
|
||||
|
||||
it("never accepts PAPERCLIP_API_KEY from config env", () => {
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
buildLocalProcessSandboxSpawnTarget,
|
||||
type LocalProcessSandboxOptions,
|
||||
} from "./local-process-sandbox.js";
|
||||
import { buildSshSpawnTarget, runSshCommand, type SshRemoteExecutionSpec } from "./ssh.js";
|
||||
import { prepareWakePayloadEnv } from "./wake-payload-env.js";
|
||||
import { buildSshSpawnTarget, type SshRemoteExecutionSpec } from "./ssh.js";
|
||||
import { redactCommandText } from "./command-redaction.js";
|
||||
import { paperclipChatFilePreparationDelivery } from "./chat-file-delivery.js";
|
||||
import {
|
||||
@@ -163,10 +162,12 @@ export function isPaperclipRuntimeEnvKey(key: string): boolean {
|
||||
|
||||
// PAPERCLIP_API_KEY is never accepted from adapter/user config env: the
|
||||
// harness-minted run token is the only source of Paperclip API identity.
|
||||
// PAPERCLIP_WAKE_PAYLOAD_JSON is retired: wake context travels in the prompt,
|
||||
// and a configured copy can exceed OS process-launch limits.
|
||||
// Other PAPERCLIP_*-named config keys are allowed as long as Paperclip has
|
||||
// not assigned the same key for the run (runtime vars always win).
|
||||
export function isForbiddenConfigEnvKey(key: string): boolean {
|
||||
return key === "PAPERCLIP_API_KEY" || key === "PAPERCLIP_WAKE_PAYLOAD_PATH";
|
||||
return key === "PAPERCLIP_API_KEY" || key === "PAPERCLIP_WAKE_PAYLOAD_JSON";
|
||||
}
|
||||
const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [
|
||||
"../../skills",
|
||||
@@ -1920,7 +1921,7 @@ export function stringifyPaperclipWakePayload(
|
||||
value: unknown,
|
||||
options: {
|
||||
// For prompt-embedded copies of the payload on lanes where another prompt
|
||||
// section already carries the issue description; the env-var copy should
|
||||
// section already carries the issue description. Other serialized copies
|
||||
// stay complete.
|
||||
omitIssueDescription?: boolean;
|
||||
} = {},
|
||||
@@ -3070,10 +3071,6 @@ export function redactEnvForLogs(
|
||||
): Record<string, string> {
|
||||
const redacted: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (key === "PAPERCLIP_WAKE_PAYLOAD_JSON") {
|
||||
redacted[key] = `[wake payload: ${Buffer.byteLength(value, "utf8")} bytes]`;
|
||||
continue;
|
||||
}
|
||||
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? REDACTED_LOG_VALUE : value;
|
||||
}
|
||||
return redacted;
|
||||
@@ -4577,34 +4574,6 @@ export async function ensureCommandResolvable(
|
||||
}
|
||||
|
||||
export async function runChildProcess(
|
||||
runId: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: Parameters<typeof runChildProcessWithPreparedEnv>[3],
|
||||
): Promise<RunProcessResult> {
|
||||
const remote = opts.remoteExecution;
|
||||
const delivery = await prepareWakePayloadEnv(opts.env, remote
|
||||
? async (script) => (await runSshCommand(remote, script, { timeoutMs: 30_000, maxBuffer: 64 * 1024 })).stdout
|
||||
: undefined);
|
||||
try {
|
||||
return await runChildProcessWithPreparedEnv(runId, command, args, {
|
||||
...opts,
|
||||
env: delivery.env,
|
||||
localProcessSandbox: opts.localProcessSandbox && delivery.filePath && !remote
|
||||
? { ...opts.localProcessSandbox, managedPaths: [
|
||||
...(opts.localProcessSandbox.managedPaths ?? []),
|
||||
{ path: delivery.filePath, access: "ro" },
|
||||
] }
|
||||
: opts.localProcessSandbox,
|
||||
});
|
||||
} finally {
|
||||
await delivery.cleanup().catch(async () => {
|
||||
await opts.onLog("stderr", "[paperclip] Could not remove the wake payload file after the process ended.\n").catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function runChildProcessWithPreparedEnv(
|
||||
runId: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
type SshEnvLabFixtureState,
|
||||
} from "./ssh.js";
|
||||
import { prepareRemoteManagedRuntime } from "./remote-managed-runtime.js";
|
||||
import { runChildProcess } from "./server-utils.js";
|
||||
|
||||
const SSH_FIXTURE_TEST_TIMEOUT_MS = 30_000;
|
||||
let sshEnvLabUnsupportedReason: string | null = null;
|
||||
@@ -514,27 +512,6 @@ describe("ssh env-lab fixture", () => {
|
||||
).rejects.toThrow("Invalid SSH environment variable key: BAD KEY");
|
||||
});
|
||||
|
||||
it("delivers and removes an oversized wake on the SSH host", async (context) => {
|
||||
const rootDir = await createFixtureRootDir();
|
||||
const started = await startSshEnvLabFixtureOrSkip(path.join(rootDir, "state.json"), "SSH wake payload test");
|
||||
if (!started) { context.skip(); return; }
|
||||
const config = await buildSshEnvLabFixtureConfig(started);
|
||||
const payload = JSON.stringify({ description: "full 🙂 wake context ".repeat(10_000) });
|
||||
const result = await runChildProcess("ssh-wake", process.execPath, ["-e", `
|
||||
const fs = require('node:fs');
|
||||
const p = process.env.PAPERCLIP_WAKE_PAYLOAD_PATH;
|
||||
console.log(JSON.stringify({ path: p, digest: require('node:crypto').createHash('sha256').update(fs.readFileSync(p)).digest('hex'), inline: process.env.PAPERCLIP_WAKE_PAYLOAD_JSON ?? null }));
|
||||
`], {
|
||||
cwd: rootDir, env: { PAPERCLIP_WAKE_PAYLOAD_JSON: payload }, timeoutSec: 10, graceSec: 1, onLog: async () => {},
|
||||
remoteExecution: { ...config, remoteCwd: started.workspaceDir },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const received = JSON.parse(result.stdout);
|
||||
expect(received.digest).toBe(createHash("sha256").update(payload).digest("hex"));
|
||||
expect(received.inline).toBeNull();
|
||||
await expect(stat(path.dirname(received.path))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}, SSH_FIXTURE_TEST_TIMEOUT_MS);
|
||||
|
||||
it("syncs a local directory into the remote fixture workspace", async () => {
|
||||
const rootDir = await createFixtureRootDir();
|
||||
const statePath = path.join(rootDir, "state.json");
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { execFile, spawnSync } from "node:child_process";
|
||||
import { createHash } 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, vi } from "vitest";
|
||||
import { prepareWakePayloadEnv, renderWakePayloadFileNote, WAKE_PAYLOAD_INLINE_MAX_BYTES } from "./wake-payload-env.js";
|
||||
import { isForbiddenConfigEnvKey, redactEnvForLogs, runChildProcess } from "./server-utils.js";
|
||||
import { runAdapterExecutionTargetProcess } from "./execution-target.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const payload = JSON.stringify({ description: "🙂 café\n".repeat(60_000), messages: ["first", "last"] });
|
||||
const digest = (value: string) => createHash("sha256").update(value).digest("hex");
|
||||
const cleanup: Array<() => Promise<void>> = [];
|
||||
afterEach(async () => { vi.restoreAllMocks(); await Promise.all(cleanup.splice(0).map((fn) => fn())); });
|
||||
const childScript = `
|
||||
const fs = require('node:fs');
|
||||
const p = process.env.PAPERCLIP_WAKE_PAYLOAD_PATH;
|
||||
const body = fs.readFileSync(p, 'utf8');
|
||||
let stdin = '';
|
||||
process.stdin.on('data', c => stdin += c);
|
||||
process.stdin.on('end', () => console.log(JSON.stringify({
|
||||
digest: require('node:crypto').createHash('sha256').update(body).digest('hex'),
|
||||
path: p, inline: process.env.PAPERCLIP_WAKE_PAYLOAD_JSON ?? null, stdin,
|
||||
mode: fs.statSync(p).mode & 511, directoryMode: fs.statSync(require('node:path').dirname(p)).mode & 511
|
||||
})));
|
||||
`;
|
||||
|
||||
describe("lossless wake payload transport", () => {
|
||||
it("keeps small payload bytes unchanged and measures UTF-8 bytes at the boundary", async () => {
|
||||
const inline = "é".repeat(WAKE_PAYLOAD_INLINE_MAX_BYTES / 2);
|
||||
const small = await prepareWakePayloadEnv({ PAPERCLIP_WAKE_PAYLOAD_JSON: inline, PAPERCLIP_WAKE_PAYLOAD_PATH: "/stale" });
|
||||
expect(small.env).toEqual({ PAPERCLIP_WAKE_PAYLOAD_JSON: inline });
|
||||
expect(small.filePath).toBeNull();
|
||||
const large = await prepareWakePayloadEnv({ PAPERCLIP_WAKE_PAYLOAD_JSON: inline + "é" });
|
||||
cleanup.push(large.cleanup);
|
||||
expect(await fs.readFile(large.filePath!, "utf8")).toBe(inline + "é");
|
||||
expect(large.env.PAPERCLIP_WAKE_PAYLOAD_JSON).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses separate private files for overlapping runs and never mutates the input", async () => {
|
||||
const env = { PAPERCLIP_WAKE_PAYLOAD_JSON: payload, PAPERCLIP_RUN_ID: "same-run-retry" };
|
||||
const [first, second] = await Promise.all([prepareWakePayloadEnv(env), prepareWakePayloadEnv(env)]);
|
||||
cleanup.push(first.cleanup, second.cleanup);
|
||||
expect(first.filePath).not.toBe(second.filePath);
|
||||
expect(env).toEqual({ PAPERCLIP_WAKE_PAYLOAD_JSON: payload, PAPERCLIP_RUN_ID: "same-run-retry" });
|
||||
await first.cleanup();
|
||||
expect(await fs.readFile(second.filePath!, "utf8")).toBe(payload);
|
||||
expect(renderWakePayloadFileNote(second.env)).toContain(JSON.stringify(second.filePath));
|
||||
});
|
||||
|
||||
it("accounts for shell quoting expansion on remote launches", async () => {
|
||||
const quoted = JSON.stringify({ description: "'".repeat(8_000) });
|
||||
expect(Buffer.byteLength(quoted)).toBeLessThan(WAKE_PAYLOAD_INLINE_MAX_BYTES);
|
||||
const delivery = await prepareWakePayloadEnv({ PAPERCLIP_WAKE_PAYLOAD_JSON: quoted }, async (script) =>
|
||||
(await exec("sh", ["-c", script])).stdout);
|
||||
cleanup.push(delivery.cleanup);
|
||||
expect(await fs.readFile(delivery.filePath!, "utf8")).toBe(quoted);
|
||||
expect(delivery.env.PAPERCLIP_WAKE_PAYLOAD_JSON).toBeUndefined();
|
||||
});
|
||||
|
||||
it("launches a real child with the complete large payload, preserves stdin, and removes the file", async () => {
|
||||
const result = await runChildProcess("large-wake", process.execPath, ["-e", childScript], {
|
||||
cwd: os.tmpdir(), env: { PAPERCLIP_WAKE_PAYLOAD_JSON: payload }, stdin: "original prompt",
|
||||
timeoutSec: 10, graceSec: 1, onLog: async () => {},
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const received = JSON.parse(result.stdout);
|
||||
expect(received).toMatchObject({ digest: digest(payload), inline: null, stdin: "original prompt" });
|
||||
if (process.platform !== "win32") expect(received).toMatchObject({ mode: 0o600, directoryMode: 0o700 });
|
||||
await expect(fs.stat(path.dirname(received.path))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "linux")("reproduces Linux E2BIG when the original payload is used as an env entry", () => {
|
||||
// execFile's custom promisify wrapper can throw synchronously on E2BIG.
|
||||
// spawnSync exposes the native launch error without that Promise boundary.
|
||||
const result = spawnSync(process.execPath, ["-e", ""], { env: { PAPERCLIP_WAKE_PAYLOAD_JSON: payload } });
|
||||
expect(result.error).toMatchObject({ code: "E2BIG" });
|
||||
});
|
||||
|
||||
it("cleans up when the child cannot start", async () => {
|
||||
const mkdtemp = vi.spyOn(fs, "mkdtemp");
|
||||
await expect(runChildProcess("failed-wake", "/paperclip-test-no-such-command", [], {
|
||||
cwd: os.tmpdir(), env: { PAPERCLIP_WAKE_PAYLOAD_JSON: payload }, timeoutSec: 5, graceSec: 1, onLog: async () => {},
|
||||
})).rejects.toThrow("Failed to start command");
|
||||
const directory = await mkdtemp.mock.results[0]!.value;
|
||||
await expect(fs.stat(directory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "linux")("binds only the owned payload file into a confined process", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-wake-mount-test-"));
|
||||
cleanup.push(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const fakeBwrap = path.join(root, "fake-bwrap.cjs");
|
||||
await fs.writeFile(fakeBwrap, `#!${process.execPath}\nconsole.log(JSON.stringify({ args: process.argv.slice(2), path: process.env.PAPERCLIP_WAKE_PAYLOAD_PATH }));`, { mode: 0o700 });
|
||||
const result = await runChildProcess("confined-wake", process.execPath, ["-e", ""], {
|
||||
cwd: root, env: { PAPERCLIP_WAKE_PAYLOAD_JSON: payload }, timeoutSec: 5, graceSec: 1, onLog: async () => {},
|
||||
localProcessSandbox: { workspaceDir: root, filesystemScope: "workspace", command: fakeBwrap },
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const received = JSON.parse(result.stdout);
|
||||
const index = received.args.indexOf(received.path);
|
||||
expect(received.args.slice(index - 1, index + 2)).toEqual(["--ro-bind", received.path, received.path]);
|
||||
const mounts = received.args.flatMap((arg: string, index: number) =>
|
||||
arg === "--bind" || arg === "--ro-bind" ? [received.args[index + 1]] : []);
|
||||
expect(mounts).not.toContain(path.dirname(received.path));
|
||||
await expect(fs.stat(received.path)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("cleans up after a timeout", async () => {
|
||||
const mkdtemp = vi.spyOn(fs, "mkdtemp");
|
||||
const result = await runChildProcess("timeout-wake", process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
||||
cwd: os.tmpdir(), env: { PAPERCLIP_WAKE_PAYLOAD_JSON: payload }, timeoutSec: 0.2, graceSec: 1, onLog: async () => {},
|
||||
});
|
||||
expect(result.timedOut).toBe(true);
|
||||
const directory = await mkdtemp.mock.results[0]!.value;
|
||||
await expect(fs.stat(directory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("delivers exact bytes through bounded remote commands and cleans up after the remote child", async () => {
|
||||
const commands: string[] = [];
|
||||
const result = await runAdapterExecutionTargetProcess("remote-wake", {
|
||||
kind: "remote", transport: "sandbox", remoteCwd: os.tmpdir(),
|
||||
runner: { execute: async (input) => {
|
||||
for (const value of [...(input.args ?? []), ...Object.values(input.env ?? {})]) {
|
||||
expect(Buffer.byteLength(value)).toBeLessThan(32 * 1024);
|
||||
}
|
||||
commands.push((input.args ?? []).join(" "));
|
||||
return runChildProcess("remote-test-command", input.command, input.args ?? [], {
|
||||
cwd: os.tmpdir(), env: input.env ?? {}, stdin: input.stdin, timeoutSec: 10, graceSec: 1, onLog: async () => {},
|
||||
});
|
||||
} },
|
||||
}, process.execPath, ["-e", childScript], {
|
||||
cwd: os.tmpdir(), env: { PAPERCLIP_WAKE_PAYLOAD_JSON: payload }, stdin: "remote prompt",
|
||||
timeoutSec: 10, graceSec: 1, onLog: async () => {},
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const received = JSON.parse(result.stdout);
|
||||
expect(received).toMatchObject({ digest: digest(payload), inline: null, stdin: "remote prompt", mode: 0o600, directoryMode: 0o700 });
|
||||
await expect(fs.stat(path.dirname(received.path))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(commands.filter((s) => s.includes("base64 -d")).length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it.each(["upload", "verification"])("fails closed on remote %s failure without leaking payload chunks", async (failure) => {
|
||||
let directory = "";
|
||||
let appends = 0;
|
||||
const promise = prepareWakePayloadEnv({ PAPERCLIP_WAKE_PAYLOAD_JSON: payload }, async (script) => {
|
||||
if (script.startsWith("umask 077 && mkdir")) directory = script.match(/'(\/tmp\/paperclip-wake-[^']+)'/)![1]!;
|
||||
if (script.includes("base64 -d") && ++appends === 2 && failure === "upload") throw new Error(script);
|
||||
if (script.startsWith("test ") && failure === "verification") throw new Error(script);
|
||||
return (await exec("sh", ["-c", script])).stdout;
|
||||
});
|
||||
await expect(promise).rejects.toThrow(/^Could not deliver the complete wake payload file\.$/);
|
||||
expect(directory).not.toBe("");
|
||||
await expect(fs.stat(directory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it.each([false, true])("safely cleans up an ambiguous directory creation failure (occupied=%s)", async (occupied) => {
|
||||
let directory = "";
|
||||
await expect(prepareWakePayloadEnv({ PAPERCLIP_WAKE_PAYLOAD_JSON: payload }, async (script) => {
|
||||
if (script.startsWith("umask 077 && mkdir")) {
|
||||
directory = script.match(/'(\/tmp\/paperclip-wake-[^']+)'/)![1]!;
|
||||
cleanup.push(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
await fs.mkdir(directory, { mode: 0o700 });
|
||||
if (occupied) await fs.writeFile(path.join(directory, "other-owner"), "preserve this");
|
||||
throw new Error(occupied ? "directory exists" : "acknowledgement lost after mkdir");
|
||||
}
|
||||
return (await exec("sh", ["-c", script])).stdout;
|
||||
})).rejects.toThrow("Could not create the private wake payload directory.");
|
||||
if (occupied) expect(await fs.readFile(path.join(directory, "other-owner"), "utf8")).toBe("preserve this");
|
||||
else await expect(fs.stat(directory)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("rejects configured file paths and keeps wake contents out of invocation logs", () => {
|
||||
expect(isForbiddenConfigEnvKey("PAPERCLIP_WAKE_PAYLOAD_PATH")).toBe(true);
|
||||
const logged = redactEnvForLogs({ PAPERCLIP_WAKE_PAYLOAD_JSON: payload });
|
||||
expect(logged.PAPERCLIP_WAKE_PAYLOAD_JSON).toBe(`[wake payload: ${Buffer.byteLength(payload)} bytes]`);
|
||||
expect(JSON.stringify(logged)).not.toContain("café");
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// Leave headroom for other environment entries and shell/provider wrappers.
|
||||
// This is a transport threshold, never a limit on the wake's contents.
|
||||
export const WAKE_PAYLOAD_INLINE_MAX_BYTES = 64 * 1024;
|
||||
const PAYLOAD_KEY = "PAPERCLIP_WAKE_PAYLOAD_JSON";
|
||||
const PATH_KEY = "PAPERCLIP_WAKE_PAYLOAD_PATH";
|
||||
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
|
||||
export interface WakePayloadDelivery {
|
||||
env: Record<string, string>;
|
||||
filePath: string | null;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Execute a bounded shell command on the actual agent host, with no wake env. */
|
||||
export type WakePayloadRemoteCommand = (script: string) => Promise<string>;
|
||||
|
||||
/**
|
||||
* The caller owns this delivery until the process/turn settles. Never accept a
|
||||
* path supplied by a payload or config, overwrite a shared scratch file, or
|
||||
* change the caller's env (which may be reused for probes/retries).
|
||||
*/
|
||||
export async function prepareWakePayloadEnv(
|
||||
input: Record<string, string>,
|
||||
remoteCommand?: WakePayloadRemoteCommand,
|
||||
): Promise<WakePayloadDelivery> {
|
||||
const payload = input[PAYLOAD_KEY];
|
||||
const env = { ...input };
|
||||
// Inline contents take precedence over a stale file reference.
|
||||
if (payload !== undefined) delete env[PATH_KEY];
|
||||
// SSH/provider launchers can quote the environment inside another shell
|
||||
// command. Apostrophes expand at each layer, even for a small raw JSON value.
|
||||
const transportBytes = payload
|
||||
? Buffer.byteLength(remoteCommand ? quote(quote(payload)) : payload, "utf8")
|
||||
: 0;
|
||||
if (!payload || transportBytes <= WAKE_PAYLOAD_INLINE_MAX_BYTES) {
|
||||
return { env, filePath: null, cleanup: async () => {} };
|
||||
}
|
||||
|
||||
let directory: string;
|
||||
let filePath: string;
|
||||
let cleanup: () => Promise<void>;
|
||||
if (remoteCommand) {
|
||||
directory = `/tmp/paperclip-wake-${randomUUID()}`;
|
||||
filePath = `${directory}/payload.json`;
|
||||
// A provider can lose the acknowledgement after mkdir succeeds. On that
|
||||
// ambiguous path only remove an empty directory: never recursively remove
|
||||
// a pre-existing path whose exclusive creation was not acknowledged.
|
||||
try {
|
||||
await remoteCommand(`umask 077 && mkdir -m 700 ${quote(directory)}`);
|
||||
} catch {
|
||||
await remoteCommand(`rmdir -- ${quote(directory)}`).catch(() => {});
|
||||
throw new Error("Could not create the private wake payload directory.");
|
||||
}
|
||||
cleanup = async () => { await remoteCommand(`rm -rf -- ${quote(directory)}`); };
|
||||
try {
|
||||
await remoteCommand(`umask 077 && set -C && : > ${quote(filePath)}`);
|
||||
const encoded = Buffer.from(payload, "utf8").toString("base64");
|
||||
// Some providers turn stdin into a shell argument. Bound the transport
|
||||
// ourselves instead of trusting that stdin remains a stream end to end.
|
||||
for (let offset = 0; offset < encoded.length; offset += 16 * 1024) {
|
||||
await remoteCommand(
|
||||
`printf '%s' ${quote(encoded.slice(offset, offset + 16 * 1024))} | base64 -d >> ${quote(filePath)}`,
|
||||
);
|
||||
}
|
||||
await remoteCommand(`test "$(wc -c < ${quote(filePath)})" -eq ${Buffer.byteLength(payload, "utf8")}`);
|
||||
} catch {
|
||||
await cleanup().catch(() => {});
|
||||
// Provider errors can include the command (and thus a payload chunk).
|
||||
throw new Error("Could not deliver the complete wake payload file.");
|
||||
}
|
||||
} else {
|
||||
directory = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), "paperclip-wake-"));
|
||||
filePath = path.join(directory, "payload.json");
|
||||
cleanup = () => fs.rm(directory, { recursive: true, force: true });
|
||||
try {
|
||||
await fs.chmod(directory, 0o700);
|
||||
await fs.writeFile(filePath, payload, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
} catch {
|
||||
await cleanup().catch(() => {});
|
||||
throw new Error("Could not deliver the complete wake payload file.");
|
||||
}
|
||||
}
|
||||
delete env[PAYLOAD_KEY];
|
||||
env[PATH_KEY] = filePath;
|
||||
return { env, filePath, cleanup };
|
||||
}
|
||||
|
||||
export function renderWakePayloadFileNote(env: Record<string, string>, resumedSession = false): string {
|
||||
const filePath = env[PATH_KEY];
|
||||
if (!filePath) return resumedSession
|
||||
? "Use the current wake context in this prompt. A resumed process may retain wake environment variables from an earlier turn; do not read a previous turn's wake payload file."
|
||||
: "";
|
||||
return `The complete structured wake payload for this turn is in ${JSON.stringify(filePath)}. Read this JSON file if you need the structured wake context. This turn's path takes precedence over any older wake environment retained by a resumed session. The file is removed when this turn ends.`;
|
||||
}
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
selectPaperclipTaskMarkdown,
|
||||
rewriteWorkspaceCwdEnvVarsForExecution,
|
||||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -241,7 +240,6 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
||||
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
|
||||
|
||||
if (wakeTaskId) {
|
||||
@@ -265,9 +263,6 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (wakePayloadJson) {
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
applyPaperclipWorkspaceEnv(env, {
|
||||
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
||||
workspaceSource,
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
@@ -915,7 +914,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): 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;
|
||||
@@ -938,9 +936,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (wakePayloadJson) {
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
refreshPaperclipWorkspaceEnvForExecution({
|
||||
env,
|
||||
envConfig,
|
||||
|
||||
@@ -168,6 +168,22 @@ describe("cursor_cloud execute", () => {
|
||||
expect(prompt).not.toContain("Create child issues");
|
||||
});
|
||||
|
||||
it("delivers a large wake through the SDK prompt without a configured JSON env copy", async () => {
|
||||
const sdkAgent = createMockSdkAgent();
|
||||
createMock.mockResolvedValue(sdkAgent);
|
||||
const ctx = createContext();
|
||||
const description = "start " + "context ".repeat(25_000) + " end";
|
||||
ctx.config.env = { CURSOR_API_KEY: "cursor-secret", PAPERCLIP_WAKE_PAYLOAD_JSON: description };
|
||||
ctx.context.paperclipWake = {
|
||||
reason: "issue_assigned",
|
||||
issue: { id: "issue-1", description },
|
||||
};
|
||||
const result = await execute(ctx);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(createMock.mock.calls[0]?.[0]?.cloud?.envVars).not.toHaveProperty("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
expect(sdkAgent.send.mock.calls[0]?.[0]).toContain(description);
|
||||
});
|
||||
|
||||
it("creates a fresh Cursor agent and injects Paperclip env without CURSOR_API_KEY", async () => {
|
||||
const run = createMockRun({
|
||||
agentId: "agent-fresh",
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
selectInitialCommunicationGuidance,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
renderTemplate,
|
||||
stringifyPaperclipWakePayload,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
type CursorCloudSession = {
|
||||
@@ -116,6 +115,8 @@ function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record<string, st
|
||||
// PAPERCLIP_API_KEY is never accepted from config — the harness-minted run
|
||||
// token is the only source of Paperclip API identity.
|
||||
delete env.PAPERCLIP_API_KEY;
|
||||
// Wake context travels in the prompt; a configured copy can exceed spawn limits.
|
||||
delete env.PAPERCLIP_WAKE_PAYLOAD_JSON;
|
||||
|
||||
const wakeTaskId = trimNullable(context.taskId) ?? trimNullable(context.issueId);
|
||||
const wakeReason = trimNullable(context.wakeReason);
|
||||
@@ -125,7 +126,6 @@ function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record<string, st
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): 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;
|
||||
@@ -134,7 +134,6 @@ function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record<string, st
|
||||
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;
|
||||
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
|
||||
if (authToken) {
|
||||
env.PAPERCLIP_API_KEY = authToken;
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
selectPaperclipTaskMarkdown,
|
||||
selectInitialCommunicationGuidance,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
@@ -275,7 +274,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): 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;
|
||||
@@ -298,9 +296,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (wakePayloadJson) {
|
||||
env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
}
|
||||
refreshPaperclipWorkspaceEnvForExecution({
|
||||
env,
|
||||
envConfig,
|
||||
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
selectPaperclipTaskMarkdown,
|
||||
selectInitialCommunicationGuidance,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
@@ -299,7 +298,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): 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;
|
||||
@@ -308,7 +306,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
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,
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
selectInitialCommunicationGuidance,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
resolveLegacyPaperclipDesiredSkillNames,
|
||||
stringifyPaperclipWakePayload,
|
||||
refreshPaperclipWorkspaceEnvForExecution,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
@@ -286,7 +285,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
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;
|
||||
@@ -295,7 +293,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
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,
|
||||
|
||||
@@ -104,6 +104,15 @@ describe("hermes-local adapter onSpawn forwarding", () => {
|
||||
expect(opts.onSpawn).toBe(onSpawn);
|
||||
});
|
||||
|
||||
it("keeps wake data in the prompt and drops configured JSON env copies", async () => {
|
||||
const { ctx } = makeCtx({ env: { PAPERCLIP_WAKE_PAYLOAD_JSON: "stale configured wake" } });
|
||||
const wake = { reason: "issue_assigned", issue: { id: "issue-1", description: "Current task brief" } };
|
||||
await execute({ ...ctx, context: { ...ctx.context, paperclipWake: wake } } as any);
|
||||
const call = vi.mocked(serverUtils.runChildProcess).mock.calls.at(-1)!;
|
||||
expect(call[3].env).not.toHaveProperty("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
expect(call[2]).toContainEqual(expect.stringContaining("Current task brief"));
|
||||
});
|
||||
|
||||
it("runChildProcess opts type includes onSpawn", () => {
|
||||
// Type-level assertion: if onSpawn were removed from the type,
|
||||
// this file would fail to compile. The runtime test above catches
|
||||
|
||||
@@ -499,6 +499,8 @@ export async function execute(
|
||||
// PAPERCLIP_API_KEY is never accepted from config — the harness-minted run
|
||||
// token is the only source of Paperclip API identity.
|
||||
delete env.PAPERCLIP_API_KEY;
|
||||
// Wake context travels in the prompt; drop both inherited and configured copies.
|
||||
delete env.PAPERCLIP_WAKE_PAYLOAD_JSON;
|
||||
if ((ctx as any).authToken) env.PAPERCLIP_API_KEY = (ctx as any).authToken;
|
||||
|
||||
// BUG FIX: Read task context from ctx.context (wake context), not ctx.config (adapter config)
|
||||
@@ -509,8 +511,6 @@ export async function execute(
|
||||
if (envWakeReason) env.PAPERCLIP_WAKE_REASON = envWakeReason;
|
||||
const envCommentId = cfgString(ctxContext.commentId) || cfgString(ctxContext.wakeCommentId) || cfgString(ctx.config?.commentId);
|
||||
if (envCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = envCommentId;
|
||||
const wakePayloadJson = stringifyPaperclipWakePayload(ctxContext.paperclipWake);
|
||||
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
||||
|
||||
// ── Resolve working directory ──────────────────────────────────────────
|
||||
const cwd =
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
selectPaperclipTaskMarkdown,
|
||||
selectInitialCommunicationGuidance,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
@@ -272,7 +271,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): 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;
|
||||
@@ -281,7 +279,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
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,
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
selectPaperclipTaskMarkdown,
|
||||
selectInitialCommunicationGuidance,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
@@ -298,7 +297,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): 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;
|
||||
@@ -307,7 +305,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
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,
|
||||
|
||||
@@ -48,7 +48,6 @@ import {
|
||||
selectPaperclipTaskMarkdown,
|
||||
selectInitialCommunicationGuidance,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
runChildProcess,
|
||||
@@ -303,7 +302,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): 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;
|
||||
@@ -313,7 +311,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
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,
|
||||
|
||||
@@ -315,8 +315,8 @@ The runner, package driver, and model/harness never receive:
|
||||
- the local agent JWT, `PAPERCLIP_API_KEY`, a board session, or a board API key;
|
||||
- managed MCP gateway credentials, runner-lease/bootstrap credentials, or
|
||||
credential-broker secret material;
|
||||
- `PAPERCLIP_WAKE_PAYLOAD_JSON`, rendered Paperclip wake text, Paperclip skill
|
||||
instructions, the Paperclip API manual, or run-scoped skill material;
|
||||
- rendered Paperclip wake text, Paperclip skill instructions, the Paperclip API
|
||||
manual, or run-scoped skill material;
|
||||
- raw `process.env`, agent/project/routine env maps, `runtimeConfig.env`, or the
|
||||
legacy adapter's generic execution context;
|
||||
- authority to choose a company, issue, agent, policy, approval, or status;
|
||||
|
||||
@@ -538,7 +538,7 @@ describe("codex execute", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("injects structured Paperclip wake payloads into env and prompt", async () => {
|
||||
it.each([false, true])("delivers oversized wake context through stdin (sandbox=%s)", async (sandbox) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
@@ -546,6 +546,8 @@ describe("codex execute", () => {
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await writeFakeCodexCommand(commandPath);
|
||||
|
||||
const description = "begin " + "full wake context ".repeat(16_384) + " end";
|
||||
expect(Buffer.byteLength(description)).toBeGreaterThan(128 * 1024);
|
||||
const previousHome = process.env.HOME;
|
||||
process.env.HOME = root;
|
||||
await seedSharedCodexAuth(root);
|
||||
@@ -572,6 +574,7 @@ describe("codex execute", () => {
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
PAPERCLIP_WAKE_PAYLOAD_JSON: description,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
},
|
||||
@@ -585,7 +588,8 @@ describe("codex execute", () => {
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-874",
|
||||
title: "chat-speed issues",
|
||||
title: "Wake context test",
|
||||
description,
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
@@ -618,6 +622,16 @@ describe("codex execute", () => {
|
||||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
executionTarget: sandbox ? {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "test",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-1",
|
||||
remoteCwd: workspace,
|
||||
timeoutMs: 30_000,
|
||||
runner: createLocalSandboxRunner(),
|
||||
} : undefined,
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
@@ -626,13 +640,10 @@ describe("codex execute", () => {
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.paperclipEnvKeys).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
expect(capture.paperclipWakePayloadJson).not.toBeNull();
|
||||
expect(JSON.parse(capture.paperclipWakePayloadJson ?? "{}")).toMatchObject({
|
||||
reason: "issue_commented",
|
||||
latestCommentId: "comment-2",
|
||||
commentIds: ["comment-1", "comment-2"],
|
||||
});
|
||||
expect(capture.paperclipEnvKeys).not.toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
expect(capture.paperclipWakePayloadJson).toBeNull();
|
||||
expect(capture.prompt).toContain(description);
|
||||
expect(capture.prompt).toContain("- reason: issue_commented");
|
||||
expect(capture.prompt).toContain("## Paperclip Wake Payload");
|
||||
expect(capture.prompt).toContain("Use this wake to continue the task, applying new user direction and preserving its approval gates.");
|
||||
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
@@ -1216,19 +1227,8 @@ process.exit(1);
|
||||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.paperclipEnvKeys).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
expect(capture.paperclipWakePayloadJson).not.toBeNull();
|
||||
expect(JSON.parse(capture.paperclipWakePayloadJson ?? "{}")).toMatchObject({
|
||||
reason: "issue_assigned",
|
||||
issue: {
|
||||
identifier: "PAP-1201",
|
||||
title: "Fix gallery opening for inline images",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
checkedOutByHarness: true,
|
||||
commentIds: [],
|
||||
});
|
||||
expect(capture.paperclipEnvKeys).not.toContain("PAPERCLIP_WAKE_PAYLOAD_JSON");
|
||||
expect(capture.paperclipWakePayloadJson).toBeNull();
|
||||
expect(capture.prompt).toContain("## Paperclip Wake Payload");
|
||||
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(capture.prompt).toContain("- issue: PAP-1201 Fix gallery opening for inline images");
|
||||
|
||||
@@ -19,7 +19,7 @@ In Paperclip, **task** and **issue** refer to the same work item. The UI may use
|
||||
|
||||
Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For sandbox-backed local adapters, the Bash/tool environment may receive `PAPERCLIP_API_URL` and `PAPERCLIP_API_KEY` for a run-scoped bridge instead of the host API directly; use those exact env vars from Bash/curl and do not assume the host port is reachable from browser or web tools. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints are under `/api`. Use JSON except for multipart attachment uploads and binary content downloads. Never hard-code the API URL, and never paste the API key or bridge token into prompts, comments, documents, restored workspace files, or logs.
|
||||
|
||||
Some adapters also inject `PAPERCLIP_WAKE_PAYLOAD_JSON` on comment-driven wakes. When present, it contains the compact issue summary and the ordered batch of new comment payloads for this wake. Use it first. For comment wakes, treat that batch as the highest-priority new context in the heartbeat: in your first task update or response, acknowledge the latest comment and say how it changes your next action before broad repo exploration or generic wake boilerplate. Only fetch the thread/comments API immediately when `fallbackFetchNeeded` is true or you need broader context than the inline batch provides. Large wake payloads are delivered as a private UTF-8 JSON file instead. Read the file named by `PAPERCLIP_WAKE_PAYLOAD_PATH`; it contains the complete payload, with no transport truncation. A file path supplied in the current turn's prompt takes precedence over wake variables retained by a resumed process. These files exist only for the active run; do not retain their paths for later turns or copy their contents into logs.
|
||||
Adapters deliver the wake payload in the run prompt. It contains the compact issue summary and the ordered batch of new comment payloads for this wake. Read that prompt section first. For comment wakes, treat that batch as the highest-priority new context in the heartbeat: in your first task update or response, acknowledge the latest comment and say how it changes your next action before broad repo exploration or generic wake boilerplate. Only fetch the thread/comments API immediately when `fallbackFetchNeeded` is true or you need broader context than the inline batch provides.
|
||||
|
||||
Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity.
|
||||
|
||||
@@ -127,7 +127,7 @@ If already checked out by you, returns normally. If owned by another agent: `409
|
||||
|
||||
**Step 6 — Understand context.** Prefer `GET /api/issues/{issueId}/heartbeat-context` first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay.
|
||||
|
||||
If the current turn supplies a wake payload file path, or `PAPERCLIP_WAKE_PAYLOAD_PATH` or `PAPERCLIP_WAKE_PAYLOAD_JSON` is present, inspect that payload before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. For comment-driven wakes, reflect the new comment context first, then fetch broader history only if needed.
|
||||
If the run prompt includes a Paperclip wake payload, inspect that section before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. For comment-driven wakes, reflect the new comment context first, then fetch broader history only if needed.
|
||||
|
||||
Use comments incrementally:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user