fix: preserve chat sessions and stabilize paid lifecycle coverage

This commit is contained in:
Dotta 2026-09-11 13:51:27 -05:00
parent c41b293915
commit 93bf987780
20 changed files with 557 additions and 65 deletions

View File

@ -82,6 +82,7 @@ export const sessionCodec: AdapterSessionCodec = {
const promptBundleKey =
readNonEmptyString(record.promptBundleKey) ??
readNonEmptyString(record.prompt_bundle_key);
const mcpServerIdentity = readNonEmptyString(record.mcpServerIdentity);
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);
@ -89,6 +90,7 @@ export const sessionCodec: AdapterSessionCodec = {
sessionId,
...(cwd ? { cwd } : {}),
...(promptBundleKey ? { promptBundleKey } : {}),
...(mcpServerIdentity ? { mcpServerIdentity } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
@ -105,6 +107,7 @@ export const sessionCodec: AdapterSessionCodec = {
const promptBundleKey =
readNonEmptyString(params.promptBundleKey) ??
readNonEmptyString(params.prompt_bundle_key);
const mcpServerIdentity = readNonEmptyString(params.mcpServerIdentity);
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);
@ -112,6 +115,7 @@ export const sessionCodec: AdapterSessionCodec = {
sessionId,
...(cwd ? { cwd } : {}),
...(promptBundleKey ? { promptBundleKey } : {}),
...(mcpServerIdentity ? { mcpServerIdentity } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),

View File

@ -1527,6 +1527,13 @@ impl CommandExecutor for AcpxCommandExecutor {
return Ok(Vec::new());
}
self.poll_provider()?;
self.retained_events()
}
fn retained_events(&mut self) -> Result<Vec<PolledEvent>, DurableRunnerError> {
// Explicit drain runs while control traffic suppresses provider polling.
// Expose the already-retained suffix so runnerd can commit and ACK it
// before suspension, without restoring or advancing the provider.
Ok(self
.state
.as_ref()
@ -1805,6 +1812,57 @@ mod tests {
})
}
#[test]
fn retained_events_exposes_terminal_suffix_without_restoring_provider() {
let directory = temporary_directory("retained-terminal-suffix");
let config = test_config(&directory, None);
let mut executor = AcpxCommandExecutor::with_runner_config(&directory, &config);
// Invalid on-disk state would fail restoration. Retained-only reads
// must neither restore a provider nor inspect a different state owner.
fs::write(executor.state_path(), b"not provider state").unwrap();
assert!(executor.retained_events().unwrap().is_empty());
let operations = Vec::new();
let tool_set = AuthorizedToolSet {
schema: TOOL_SET_SCHEMA.to_owned(),
schema_version: 1,
catalog_digest: authorized_tool_catalog_digest(&operations).unwrap(),
operations,
};
let mut state = AcpxDurableState::new(
serde_json::from_value(descriptor("claude")).unwrap(),
tool_set,
"retained-only-test".to_owned(),
);
state.lifecycle = "session_open".to_owned();
for event_type in ["turn.completed", "run.usage", "run.completed"] {
state
.push(NormalizedProviderEvent {
event_type: event_type.to_owned(),
priority: EventPriority::P0,
payload: json!({}),
})
.unwrap();
}
executor.state = Some(state);
let suffix = executor.retained_events().unwrap();
assert_eq!(
suffix
.iter()
.map(|event| event.event_type.as_str())
.collect::<Vec<_>>(),
vec!["turn.completed", "run.usage", "run.completed"],
);
// Reading is not acknowledgement: a retry sees the exact same FIFO.
assert_eq!(executor.retained_events().unwrap(), suffix);
assert!(executor.session.is_none());
assert_eq!(
fs::read(executor.state_path()).unwrap(),
b"not provider state"
);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn admits_only_exact_qualified_claude_and_codex_descriptors() {
for agent in ["claude", "codex"] {

View File

@ -360,7 +360,12 @@ fn executes_a_qualified_acpx_profile_through_the_native_selector() {
assert!(events
.iter()
.any(|event| event.event_type == "run.terminal"));
// runner.drain must see this exact terminal suffix without polling the
// provider again. An empty default implementation strands the suffix and
// makes shared native transport closure fail after a successful reply.
assert_eq!(executor.retained_events().unwrap(), events);
executor.acknowledge_events(events.len()).unwrap();
assert!(executor.retained_events().unwrap().is_empty());
executor
.execute(&command(4, "session.close", json!({})))
.unwrap();

View File

@ -3873,8 +3873,18 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
// A failed drain still proceeds through bounded suspension/containment,
// but can never authorize a reusable checkpoint or deletion of evidence.
}
const lastDrain = [...core.store.state.commands]
.reverse()
.find((command) => command.type === "runner.drain");
this.#diagnostic(
"provider suffix did not prove durable drain before bounded runner suspension",
"provider suffix did not prove durable drain before bounded runner suspension: " +
JSON.stringify({
providerState: this.#providerDrainState(),
semanticResultsSettled: core.semanticToolResultsSettled(),
drainStatus: lastDrain?.status ?? null,
retainedEventsDrained:
record(record(lastDrain?.result).result).retainedEventsDrained ?? null,
}),
);
return false;
}

View File

@ -37,6 +37,19 @@ describe("adapter session codecs", () => {
expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-1");
});
it("preserves Claude MCP identity across persistence so resumed turns keep their context", () => {
const params = {
sessionId: "11111111-1111-4111-8111-111111111111",
cwd: "/tmp/workspace",
mcpServerIdentity: JSON.stringify([{
name: "Paperclip projects",
url: "http://localhost:3100/api/mcp/project-tools",
connectionId: "paperclip-project-tools",
}]),
};
expect(claudeSessionCodec.deserialize(claudeSessionCodec.serialize(params))).toEqual(params);
});
it("preserves claude ACP session params for ACP lane resumes", () => {
const parsed = claudeSessionCodec.deserialize({
sessionKey: "paperclip:company:agent:task:fingerprint",

View File

@ -39,6 +39,7 @@ import {
deliverConversationComments,
conversationReplay,
isWaitingConversation,
isConversationExecutionWake,
prepareConversationTurn,
settleConversationTurn,
undeliveredConversationComments,
@ -614,6 +615,35 @@ const support = await getEmbeddedPostgresTestSupport();
eventType: "session.capabilities.updated", sourceRunId: run.id, sourceSeq: 500, payload: {},
})).toBeNull();
});
it("ignores execution dependency wakes during active and idle chat turns", async () => {
const chat = await create();
const heartbeat = heartbeatService(db);
const blocker = await issueService(db).create(companyId, { title: "Linked execution", status: "done" });
await issueService(db).update(chat.id, { blockedByIssueIds: [blocker.id] });
const ordinary = await issueService(db).create(companyId, {
title: "Ordinary dependent",
status: "in_review",
assigneeAgentId: agentId,
blockedByIssueIds: [blocker.id],
});
for (const state of [
{ status: "in_review", conversationState: "waiting" },
{ status: "blocked", conversationState: "active" },
]) {
await db.update(issues).set(state).where(eq(issues.id, chat.id));
for (const reason of ["issue_blockers_resolved", "issue_children_completed", "issue_unblock_requested"]) {
expect(await heartbeat.wakeup(agentId, {
source: "automation",
reason,
contextSnapshot: { issueId: chat.id, wakeReason: reason },
})).toBeNull();
}
expect((await issueService(db).listWakeableBlockedDependents(blocker.id)).map((issue) => issue.id))
.toEqual([ordinary.id]);
}
expect((await issueService(db).getDependencyReadiness(chat.id)).blockerIssueIds).toEqual([blocker.id]);
});
it("only parks answered turns and preserves idle across recovery classification", async () => {
const issue = await create();
const message = await issueService(db).addComment(
@ -675,6 +705,20 @@ const support = await getEmbeddedPostgresTestSupport();
},
);
describe("conversation execution wake policy", () => {
it.each(["issue_blockers_resolved", "issue_children_completed", "issue_unblock_requested"])(
"suppresses %s only for conversation containers",
(reason) => {
expect(isConversationExecutionWake({ conversationAgentId: "agent", conversationUserId: "user" }, reason)).toBe(true);
expect(isConversationExecutionWake({}, reason)).toBe(false);
},
);
it.each(["issue_commented", "interaction_resolved", "run_failed", "issue_recovery_action_restored"])(
"preserves %s handling for pending conversation turns",
(reason) => expect(isConversationExecutionWake({ conversationAgentId: "agent", conversationUserId: "user" }, reason)).toBe(false),
);
});
describe("chat prompt policy", () => {
it.each(["standard", "ask", "planning"])(
"keeps handoff instructions in %s, including accepted plans and resumes",

View File

@ -9,6 +9,7 @@ import {
claudeSessionCwdMatchesExecutionTarget,
execute,
resetClaudeCliCapabilitiesCacheForTests,
sessionCodec,
} from "@paperclipai/adapter-claude-local/server";
async function writeFailingClaudeCommand(
@ -1156,6 +1157,14 @@ describe("claude execute", () => {
},
},
context: {},
runtimeMcp: {
getServers: () => [{
name: "Paperclip projects",
url: "http://localhost:3100/api/mcp/project-tools",
connectionId: "paperclip-project-tools",
token: "run-jwt-token",
}],
},
authToken: "run-jwt-token",
onLog: async () => {},
});
@ -1179,7 +1188,7 @@ describe("claude execute", () => {
},
runtime: {
sessionId: null,
sessionParams: first.sessionParams ?? null,
sessionParams: sessionCodec.deserialize(sessionCodec.serialize(first.sessionParams ?? null)),
sessionDisplayId: null,
taskKey: null,
},
@ -1231,6 +1240,14 @@ describe("claude execute", () => {
fallbackFetchNeeded: false,
},
},
runtimeMcp: {
getServers: () => [{
name: "Paperclip projects",
url: "http://localhost:3100/api/mcp/project-tools",
connectionId: "paperclip-project-tools",
token: "next-run-jwt-token",
}],
},
authToken: "run-jwt-token",
onLog: async () => {},
});

View File

@ -7,6 +7,29 @@ import {
} from "../services/heartbeat.js";
describe("buildPaperclipTaskMarkdown", () => {
it("hands an accepted chat plan to assigned project tasks using the approved revision", () => {
const prompt = buildPaperclipTaskMarkdown({
issue: { id: "chat", title: "Agent chat", workMode: "planning", conversationAgentId: "agent", description: null },
interaction: { kind: "request_confirmation", status: "accepted" },
acceptedPlan: { documentId: "plan-document", revisionId: "approved-revision", revisionNumber: 2 },
});
expect(prompt).toContain("Perform that handoff now");
expect(prompt).toContain("ordinary assigned execution tasks");
expect(prompt).toContain("initialPlan before execution starts");
expect(prompt).toContain("revision 2 approved-revision");
expect(prompt).not.toContain("Implement the accepted plan on this issue");
});
it.each(["ask", "new-comment", "unbound-confirmation"])("does not treat %s as plan handoff authorization", (kind) => {
const prompt = buildPaperclipTaskMarkdown({
issue: { id: "chat", title: "Agent chat", workMode: kind === "ask" ? "ask" : "planning", conversationAgentId: "agent", description: null },
interaction: { kind: "request_confirmation", status: "accepted" },
...(kind === "unbound-confirmation" ? {} : { acceptedPlan: { documentId: "plan-document", revisionId: "approved-revision", revisionNumber: 2 } }),
...(kind === "new-comment" ? { wakeComment: { id: "later-comment", body: "Please revise it again first." } } : {}),
});
expect(prompt).not.toContain("Perform that handoff now");
});
it("adds planning directives for assignment and comment task context", () => {
const assignment = buildPaperclipTaskMarkdown({
issue: {

View File

@ -38,6 +38,21 @@ export function isWaitingConversation(
issue.status === "in_review"
);
}
/** Execution tasks may link to a conversation, but never drive its turns.
* Apply before enqueue, including while a reply is still running: waiting until
* finalization is too late to prevent a deferred dependency follow-up.
*/
export function isConversationExecutionWake(
issue: ConversationIdentity | null | undefined,
reason: string | null | undefined,
): boolean {
return isConversation(issue) && (
reason === "issue_blockers_resolved" ||
reason === "issue_children_completed" ||
reason === "issue_unblock_requested"
);
}
export function isConversationReset(body: string): boolean {
return body.trim() === "/new";
}
@ -50,7 +65,7 @@ Before handing off work, inspect available projects and repositories. Every task
Create ordinary assigned tasks, never subtasks of this conversation. Give each task a clear outcome, context, acceptance criteria, project, and appropriate assignee. Use create_task with initialPlan to copy the relevant plan into the new task before execution starts. Preserve the original plan here. When splitting work, include the relevant part of the plan in each task. Create and link each task before claiming it exists.
Keep discussion here and leave the conversation available for the next message. Reply normally and end your turn; Paperclip manages the conversation waiting state. Do not change its status, create a review confirmation just to finish a reply, mark it complete, or poll for another reply. An accepted plan authorizes handoff to execution tasks, never implementation on this conversation. Honor normal approvals. Ask mode is non-mutating. Plan mode supports research and writing/revising the plan; hand off for execution only through the normal authorized workflow.`;
Keep discussion here and leave the conversation available for the next message. Link handed-off tasks in your reply; do not make this conversation blocked by their completion or wait for them. Reply normally and end your turn; Paperclip manages the conversation waiting state. Do not change its status, create a review confirmation just to finish a reply, mark it complete, or poll for another reply. An accepted plan authorizes handoff to execution tasks, never implementation on this conversation. Honor normal approvals. Ask mode is non-mutating. Plan mode supports research and writing/revising the plan; hand off for execution only through the normal authorized workflow.`;
/** Runs under the normal issue execution lock, before any provider session is read. */
export async function prepareConversationTurn(

View File

@ -1,4 +1,4 @@
import { AGENT_CHAT_DIRECTIVE, conversationReplay, isConversation, isWaitingConversation, prepareConversationTurn, settleConversationTurn } from "./agent-conversations.js";
import { AGENT_CHAT_DIRECTIVE, conversationReplay, isConversation, isConversationExecutionWake, isWaitingConversation, prepareConversationTurn, settleConversationTurn } from "./agent-conversations.js";
import { legacyExecutionNeedsReconciliation, terminalizeLegacyExecution } from "./legacy-execution-recovery.js";
import { executionFailureRetryCount } from "./execution-recovery-attempt.js";
import { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-payload.js";
@ -7704,6 +7704,14 @@ export function buildPaperclipTaskMarkdown(input: {
(input.interaction?.kind === "request_confirmation" &&
input.interaction.status === "accepted" &&
issue?.workMode === "planning"));
const acceptedChatPlan = Boolean(
issue?.conversationAgentId &&
issue.workMode !== "ask" &&
!wakeComment &&
input.interaction?.kind === "request_confirmation" &&
input.interaction.status === "accepted" &&
(input.acceptedPlan?.revisionId || input.acceptedPlanContinuation),
);
if (!issue && !wakeComment) return null;
const lines = [
@ -7717,6 +7725,13 @@ export function buildPaperclipTaskMarkdown(input: {
);
if (issue.conversationAgentId) {
lines.push("", "Chat mode directive:", AGENT_CHAT_DIRECTIVE, `Current composer mode: ${issue.workMode ?? "standard"}.`);
if (acceptedChatPlan) {
lines.push(
"",
"Accepted chat plan directive:",
"The user has approved the plan for handoff. Perform that handoff now: select or create a suitable project, then create the ordinary assigned execution tasks with the relevant approved plan in initialPlan before execution starts. Do not stop at acknowledging approval or ask for another confirmation. Keep the original plan here, link the created tasks, and leave this conversation available for discussion. Do not implement here or create subtasks of this conversation.",
);
}
} else if (issue.workMode === "ask") {
lines.push(
`- Work mode: ${quoteTaskScalar("ask")}`,
@ -7755,7 +7770,7 @@ export function buildPaperclipTaskMarkdown(input: {
"Implement the accepted plan on this issue when the work is small and cohesive. Use the paperclip-converting-plans-to-tasks skill to decide whether decomposition is justified. Create the minimum child issue graph only for qualifying ownership, parallelism, dependency, review, or lifecycle boundaries. Do not create a child merely because a plan was accepted.",
);
}
if (acceptedPlanContinuation && input.acceptedPlan?.revisionId) {
if ((acceptedPlanContinuation || acceptedChatPlan) && input.acceptedPlan?.revisionId) {
const revisionNumber = input.acceptedPlan.revisionNumber
? ` revision ${input.acceptedPlan.revisionNumber}`
: " revision";
@ -23686,6 +23701,7 @@ export function heartbeatService(
if (issueId) {
const conversation = await getIssueExecutionContext(agent.companyId, issueId);
if (isConversation(conversation)) {
if (isConversationExecutionWake(conversation, reason ?? readNonEmptyString(enrichedContextSnapshot.wakeReason))) return null;
if (agent.id !== conversation!.conversationAgentId) return null;
if (!(await instanceSettings.getExperimental()).enableAgentChat) return null;
if (!wakeCommentId && isWaitingConversation(conversation) && !hasInteractionContinuationWakeContext(enrichedContextSnapshot)) return null;

View File

@ -6693,6 +6693,7 @@ export function issueService(db: Db) {
eq(issueRelations.companyId, blockerIssue.companyId),
eq(issueRelations.type, "blocks"),
eq(issueRelations.issueId, blockerIssueId),
isNull(issues.conversationAgentId),
),
);
if (candidates.length === 0) return [];

View File

@ -3820,6 +3820,7 @@ export function recoveryService(
const queryCandidates = (afterIssueId: string | null) => {
const filters = [
eq(issues.status, "blocked"),
isNull(issues.conversationAgentId),
visibleIssueCondition(),
sql`${issues.assigneeAgentId} is not null`,
];

View File

@ -74,6 +74,16 @@ report, history, and Pages jobs receive none of these secrets.
Each full-stack matrix cell receives only its selected profile credential, plus
Daytona only for Daytona cells. Secret-bearing and OIDC jobs use frozen installs
without a shared dependency cache.
On disposable GitHub Linux runners with Ubuntu's unprivileged-user-namespace
restriction, native Codex setup loads an AppArmor profile attached to the exact
lockfile-pinned Codex executable. It grants `userns` so Codex can construct its
filesystem sandbox; it does not disable the kernel restriction or Codex's
workspace policy. Setup fails before invoking a model if the noninteractive
profile load fails. This host-only profile disappears with the ephemeral runner.
See [Ubuntu's namespace restriction documentation](https://documentation.ubuntu.com/security/security-features/privilege-restriction/apparmor/).
Local developer machines are never modified by this setup. Legacy Codex fixtures
disable optional shell-environment snapshots to avoid persisting credentials;
the secret scanner retains its existing rejection rules.
The Paperclip server process also receives none; the browser posts each value
once to the encrypted company secret API and agents/environments retain only
secret references.

View File

@ -354,7 +354,14 @@ describe("runner E2E catalog", () => {
},
executionId: execution!.id,
}),
).toMatchObject({ adapterConfig: { engine: "cli" } });
).toMatchObject({
adapterConfig: {
engine: "cli",
...(profileId === "legacy-codex"
? { extraArgs: ["-c", "features.shell_snapshot=false"] }
: {}),
},
});
}
});

View File

@ -208,7 +208,13 @@ export const runnerProfiles: readonly RunnerProfileFixture[] = [
credential: "OPENAI_API_KEY",
// Keep this fixture on the classic adapter/CLI lane. ACP execution is
// covered independently by the native runner ACPX profiles below.
extraConfig: { engine: "cli" },
extraConfig: {
engine: "cli",
// Shell snapshots serialize inherited environment values into CODEX_HOME.
// These disposable runs carry short-lived API credentials; keep that
// optional optimization off rather than exempting leaked files from scans.
extraArgs: ["-c", "features.shell_snapshot=false"],
},
}),
legacyProfile({
id: "legacy-claude",

View File

@ -1,10 +1,17 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import type { AskUserQuestionsPayload } from "../../packages/shared/src/types/issue.js";
import {
assertChatHandoff,
assertChatTaskHandoff,
chatQuestionPresentation,
chatRunFailure,
collectChatRunEvidence,
readRunningChatLog,
isResetRun,
type ChatIssue,
type ChatRun,
} from "./chat-flow.js";
import type { RunnerApi } from "./api.js";
import { runnerMatrix } from "./catalog.js";
import { isPublicRunnerScreenshotRoute } from "./screenshot-policy.js";
@ -77,6 +84,114 @@ describe("chat acceptance contracts", () => {
).toThrow();
expect(() => assertChatHandoff(task, plan, [], source)).toThrow();
});
it("requires a plan for plan handoff, while direct requests need only normal task assignment", () => {
expect(() => assertChatTaskHandoff(task, [run], source)).not.toThrow();
expect(() =>
assertChatHandoff(task, { ...plan, body: "" }, [run], source),
).toThrow();
expect(() =>
assertChatTaskHandoff({ ...task, projectId: null }, [run], source),
).toThrow();
});
it("uses durable free-text labels, multi-selection, and the supplied submit label", () => {
const payload: AskUserQuestionsPayload = {
version: 1,
submitLabel: "Send brief",
questions: [
{
id: "audience",
prompt: "Who is it for?",
selectionMode: "multi",
required: true,
options: [
{ id: "members", label: "New members" },
{
id: "custom",
label: "Another audience or occasion",
freeText: true,
},
],
},
],
};
const presentation = chatQuestionPresentation(payload);
expect(presentation.submitLabel).toBe("Send brief");
expect(presentation.questions[0]).toMatchObject({
answerMode: "multi_select",
customAnswer: { enabled: true, label: "Another audience or occasion" },
});
const nativePayload: AskUserQuestionsPayload = {
...payload,
questionSet: {
schema: "paperclip.question_set.v1",
submitLabel: "Continue",
questions: [
{
id: "audience",
prompt: "Who is it for?",
required: true,
answerMode: "text",
},
],
},
};
expect(chatQuestionPresentation(nativePayload)).toBe(
nativePayload.questionSet,
);
});
it("retains reset events without requesting a provider log, and does not hide missing real logs", async () => {
const get = vi.fn().mockResolvedValue([{ type: "session_reset" }]);
const reset = { ...run, resultJson: { conversationReset: true } };
await expect(collectChatRunEvidence({ get }, reset)).resolves.toEqual({
runId: run.id,
log: null,
events: [{ type: "session_reset" }],
});
expect(get.mock.calls).toEqual([
[`/api/heartbeat-runs/${run.id}/events?limit=1000`],
]);
get.mockRejectedValue(new Error("Run log not found"));
await expect(collectChatRunEvidence({ get }, run)).rejects.toThrow(
"Run log not found",
);
});
it("waits for a newly running provider's log file without swallowing server failures", async () => {
const get = vi.fn().mockResolvedValue({ status: () => 404 });
const api = { request: { get } } as unknown as Pick<RunnerApi, "request">;
await expect(readRunningChatLog(api, "starting")).resolves.toBeUndefined();
get.mockResolvedValue({
status: () => 200,
ok: () => true,
json: async () => ({ content: "streamed reply" }),
});
await expect(readRunningChatLog(api, "running")).resolves.toBe(
"streamed reply",
);
get.mockResolvedValue({ status: () => 500, ok: () => false });
await expect(readRunningChatLog(api, "broken")).rejects.toThrow(
"log returned 500",
);
});
it("fails promptly on terminal provider failures while permitting only expected cancellations", () => {
expect(chatRunFailure([run])).toBeUndefined();
expect(chatRunFailure([{ ...run, status: "running" }])).toBeUndefined();
expect(
chatRunFailure([
{
...run,
status: "failed",
errorCode: "permission_denied",
error: "sandbox unavailable",
},
]),
).toContain("run run failed (permission_denied): sandbox unavailable");
expect(chatRunFailure([{ ...run, status: "cancelled" }])).toContain(
"cancelled",
);
expect(
chatRunFailure([{ ...run, status: "cancelled" }], true),
).toBeUndefined();
});
it("separates reset control runs from provider runs without treating failures as resets", () => {
expect(isResetRun(run)).toBe(false);
expect(isResetRun({ ...run, status: "failed" })).toBe(false);

View File

@ -1,5 +1,9 @@
import { expect, type Page } from "@playwright/test";
import type { RunnerApi } from "./api.js";
import { pollUntil, type RunnerApi } from "./api.js";
import type {
AskUserQuestionsPayload,
PaperclipQuestionSetPayload,
} from "../../packages/shared/src/types/issue.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
import type { MatrixExecution } from "./types.js";
@ -22,6 +26,8 @@ export interface ChatRun {
companyId: string;
agentId: string;
status: string;
error?: string | null;
errorCode?: string | null;
runtimeMode?: string;
contextSnapshot?: Record<string, unknown>;
resultJson?: Record<string, unknown>;
@ -40,17 +46,91 @@ type Plan = { body: string; latestRevisionId: string; updatedAt: string };
export const isResetRun = (run: ChatRun) =>
run.contextSnapshot?.conversationReset === true ||
run.resultJson?.conversationReset === true;
export function assertChatHandoff(
export function chatRunFailure(
runs: ChatRun[],
allowCancelled = false,
): string | undefined {
const failed = runs.find(
(run) =>
["failed", "timed_out"].includes(run.status) ||
(!allowCancelled && run.status === "cancelled"),
);
return failed
? `run ${failed.id} ${failed.status}${failed.errorCode ? ` (${failed.errorCode})` : ""}${failed.error ? `: ${failed.error}` : ""}`
: undefined;
}
/** Match the shared question form's durable/native presentation, including custom labels. */
export function chatQuestionPresentation(
payload: AskUserQuestionsPayload,
): PaperclipQuestionSetPayload {
if (payload.questionSet) return payload.questionSet;
return {
schema: "paperclip.question_set.v1",
...(payload.submitLabel ? { submitLabel: payload.submitLabel } : {}),
questions: payload.questions.map((question) => {
const freeText = question.options.find((option) => option.freeText);
return {
id: question.id,
prompt: question.prompt,
required: question.required === true,
answerMode:
question.selectionMode === "multi" ? "multi_select" : "single_select",
...(freeText
? { customAnswer: { enabled: true as const, label: freeText.label } }
: {}),
};
}),
};
}
export function assertChatTaskHandoff(
task: ChatIssue,
plan: Plan,
runs: ChatRun[],
source: ChatIssue,
) {
expect(task.parentId).toBeNull();
expect(task.projectId).toBeTruthy();
expect(task.assigneeAgentId).toBe(source.assigneeAgentId);
expect(plan.body.trim()).not.toBe("");
expect(runs.length).toBeGreaterThan(0);
}
/** A running row can precede creation of its log file. Only that expected 404 is retryable. */
export async function readRunningChatLog(
api: Pick<RunnerApi, "request">,
runId: string,
): Promise<string | undefined> {
const response = await api.request.get(
`/api/heartbeat-runs/${runId}/log?limitBytes=65536`,
);
if (response.status() === 404) return undefined;
if (!response.ok())
throw new Error(`Run ${runId} log returned ${response.status()}`);
return ((await response.json()) as { content?: string }).content;
}
/** Synthetic reset runs have durable events but never start a provider log. */
export async function collectChatRunEvidence(
api: Pick<RunnerApi, "get">,
run: ChatRun,
) {
return {
runId: run.id,
log: isResetRun(run)
? null
: await api.get(`/api/heartbeat-runs/${run.id}/log?limitBytes=1048576`),
events: await api.get(`/api/heartbeat-runs/${run.id}/events?limit=1000`),
};
}
export function assertChatHandoff(
task: ChatIssue,
plan: Plan,
runs: ChatRun[],
source: ChatIssue,
) {
assertChatTaskHandoff(task, runs, source);
expect(plan.body.trim()).not.toBe("");
for (const run of runs) {
expect(Date.parse(plan.updatedAt)).toBeLessThanOrEqual(
Date.parse(run.startedAt!),
@ -107,29 +187,35 @@ export async function runChatFlow(input: {
a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id),
);
const idle = async (minimumProviderRuns: number) => {
await expect
.poll(
async () => {
const resolved = await api.get<ChatIssue | null>(chatPath);
if (!resolved) return false;
issue = resolved;
runs = await allRuns();
input.observe(issue, runs);
return (
runs.filter((run) => !isResetRun(run)).length >=
minimumProviderRuns &&
runs.every((run) => !["queued", "running"].includes(run.status)) &&
issue.status === "in_review" &&
issue.conversationState === "waiting"
);
},
{
timeout: 240_000,
intervals: [500, 1000, 2000],
message: "chat turn settles to waiting",
},
)
.toBe(true);
await pollUntil({
label: "chat turn settles to waiting",
deadlineAt: Date.now() + 240_000,
intervalMs: 1000,
load: async () => {
const resolved = await api.get<ChatIssue | null>(chatPath);
if (resolved) issue = resolved;
runs = await allRuns();
if (resolved) input.observe(resolved, runs);
return {
resolved: Boolean(resolved),
status: resolved?.status,
conversationState: resolved?.conversationState,
providerRunCount: runs.filter((run) => !isResetRun(run)).length,
activeRuns: runs
.filter((run) => ["queued", "running"].includes(run.status))
.map((run) => run.id),
failure: chatRunFailure(runs, caseId === "stop-new-resume"),
};
},
reject: (state) => state.failure,
accept: (state) =>
!state.failure &&
state.resolved &&
state.providerRunCount >= minimumProviderRuns &&
state.activeRuns.length === 0 &&
state.status === "in_review" &&
state.conversationState === "waiting",
});
};
const turn = async (text: string, count: number) => {
await sendChatMessage(page, text);
@ -194,10 +280,8 @@ export async function runChatFlow(input: {
const events = await api.get<Array<Record<string, unknown>>>(
`/api/heartbeat-runs/${active.id}/events?limit=1000`,
);
const log = await api.get<{ content?: string }>(
`/api/heartbeat-runs/${active.id}/log?limitBytes=65536`,
);
if (!(events.length || log.content?.length)) return false;
const log = await readRunningChatLog(api, active.id);
if (!(events.length || log?.length)) return false;
cancelledId = active.id;
return true;
},
@ -273,6 +357,7 @@ export async function runChatFlow(input: {
await noTasks();
} else {
let existingProject: { id: string; name: string } | undefined;
let acceptedPlan: Plan | undefined;
if (caseId === "clarify-reuse") {
existingProject = await api.post(
`/api/companies/${f.company.id}/projects`,
@ -290,19 +375,17 @@ export async function runChatFlow(input: {
Array<{
status: string;
kind: string;
payload?: {
questions?: Array<{
selectionMode?: string;
answerMode?: string;
customAnswer?: { label?: string };
}>;
};
payload: AskUserQuestionsPayload;
}>
>(`/api/issues/${issue!.id}/interactions`);
const pendingQuestions = questions.find(
const pendingInteraction = questions.find(
(row) =>
row.status === "pending" && row.kind === "ask_user_questions",
)?.payload?.questions;
);
const questionSet = pendingInteraction
? chatQuestionPresentation(pendingInteraction.payload)
: undefined;
const pendingQuestions = questionSet?.questions;
expect(
Boolean(pendingQuestions?.length) ||
(await comments()).some(
@ -324,7 +407,7 @@ export async function runChatFlow(input: {
} else {
await page
.getByRole(
question.selectionMode === "multiple" ? "checkbox" : "radio",
question.answerMode === "multi_select" ? "checkbox" : "radio",
{
name: question.customAnswer?.label ?? "Other",
exact: true,
@ -343,7 +426,7 @@ export async function runChatFlow(input: {
.getByRole("button", {
name:
index === pendingQuestions.length - 1
? "Submit answers"
? (questionSet?.submitLabel ?? "Submit answers")
: "Next",
exact: true,
})
@ -359,7 +442,7 @@ export async function runChatFlow(input: {
.getByText("Plan mode", { exact: true })
.click();
await turn(
`Let's plan a two-sentence garden club welcome note. Write a plan in the plan panel, with the required phrase DRAFT_${nonce}. Do not create a project or task yet.`,
`Let's plan a two-sentence garden club welcome note. Write a plan in the plan panel, with the required phrase DRAFT_${nonce}, and present it for approval. When I approve the final revision, create a suitable repository-free project and an assigned task for yourself, copy the plan into that task, and have it save the note in its output document and finish. Do not create the project or task before approval.`,
1,
);
const draft = await api.get<Plan>(
@ -405,7 +488,7 @@ export async function runChatFlow(input: {
.locator('[contenteditable="true"],textarea')
.first()
.fill(
`Revise the plan: replace DRAFT_${nonce} with ${marker}. The execution task should save the welcome note in its output document. Present this revised plan for approval; do not hand it off yet.`,
`Revise the plan: replace DRAFT_${nonce} with ${marker}. The execution task should save the welcome note in its output document. Present this revised plan for approval; wait for that approval before handing it off as agreed.`,
);
await reviseButton.click();
await idle(2);
@ -415,6 +498,7 @@ export async function runChatFlow(input: {
expect(revised.body).toContain(marker);
expect(revised.body).not.toContain(`DRAFT_${nonce}`);
expect(revised.latestRevisionId).not.toBe(draft.latestRevisionId);
acceptedPlan = revised;
await noTasks();
const interactions = await api.get<
Array<{
@ -471,13 +555,25 @@ export async function runChatFlow(input: {
.toBe("done");
runs = await allRuns();
input.observe(issue!, runs);
const plan = await api.get<Plan>(
`/api/issues/${child.id}/documents/plan`,
);
const plan =
caseId === "plan-handoff"
? await api.get<Plan>(`/api/issues/${child.id}/documents/plan`)
: null;
const taskRuns = runs.filter(
(run) => run.contextSnapshot?.issueId === child.id,
);
assertChatHandoff(child, plan, taskRuns, issue!);
if (plan) {
assertChatHandoff(child, plan, taskRuns, issue!);
expect(plan.body).toContain(marker);
expect(plan.body).not.toContain(`DRAFT_${nonce}`);
const sourcePlan = await api.get<Plan>(
`/api/issues/${issue!.id}/documents/plan`,
);
expect(sourcePlan.body).toBe(acceptedPlan!.body);
expect(sourcePlan.latestRevisionId).toBe(
acceptedPlan!.latestRevisionId,
);
} else assertChatTaskHandoff(child, taskRuns, issue!);
const output = await api.get<Plan>(
`/api/issues/${child.id}/documents/output`,
);
@ -572,15 +668,7 @@ export async function runChatFlow(input: {
comments: await comments(),
activity: await api.get(`/api/issues/${issue!.id}/activity`),
runEvidence: await Promise.all(
runs.map(async (run) => ({
runId: run.id,
log: await api.get(
`/api/heartbeat-runs/${run.id}/log?limitBytes=1048576`,
),
events: await api.get(
`/api/heartbeat-runs/${run.id}/events?limit=1000`,
),
})),
runs.map((run) => collectChatRunEvidence(api, run)),
),
});
await input.capture(

View File

@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { codexUserNamespaceProfile } from "./codex-ci-sandbox.js";
describe("Codex CI user namespace profile", () => {
it("grants namespaces only to the exact pinned executable", () => {
const binary = "/home/runner/work/repo/node_modules/.pnpm/@openai+codex@1.0/vendor/bin/codex";
const profile = codexUserNamespaceProfile(binary);
expect(profile).toContain(`"${binary}" flags=(unconfined)`);
expect(profile).toContain("userns,");
expect(profile).not.toContain("*");
expect(profile).not.toContain("capability,");
});
it.each(["relative/codex", "/tmp/*", '/tmp/" { userns, }', "/tmp/\nprofile bad", "/tmp/[ab]"])(
"rejects attachment or policy injection: %s", (binary) => {
expect(() => codexUserNamespaceProfile(binary)).toThrow("Unsafe Codex executable path");
},
);
});

View File

@ -0,0 +1,37 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFile, realpath, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
export function codexUserNamespaceProfile(binary: string) {
// Exact executable attachment only; no wildcard or policy syntax from paths.
if (!path.posix.isAbsolute(binary) || !/^[/A-Za-z0-9_.@+\-]+$/.test(binary)) {
throw new Error("Unsafe Codex executable path for CI AppArmor profile");
}
const name = `paperclip-e2e-codex-${createHash("sha256").update(binary).digest("hex").slice(0, 16)}`;
return `abi <abi/4.0>,\ninclude <tunables/global>\nprofile ${name} "${binary}" flags=(unconfined) {\n userns,\n}\n`;
}
/** Ubuntu CI requires an explicit userns grant for Codex's filesystem sandbox.
* Keep the global AppArmor restriction and Codex's workspace policy enabled.
* This is only used on the protected workflow's disposable Linux runners.
*/
export async function prepareCodexCiSandbox(repositoryRoot: string, temporaryRoot: string) {
if (process.platform !== "linux" || process.env.GITHUB_ACTIONS !== "true") return;
const restricted = await readFile("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", "utf8")
.catch(() => "0");
if (restricted.trim() !== "1") return;
const runnerRequire = createRequire(path.join(repositoryRoot, "packages/paperclip-runner/package.json"));
const acpRequire = createRequire(runnerRequire.resolve("@agentclientprotocol/codex-acp/package.json"));
const codexRequire = createRequire(acpRequire.resolve("@openai/codex/package.json"));
const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : null;
if (!arch) throw new Error("Unsupported Codex CI architecture");
const platformPackage = codexRequire.resolve(`@openai/codex-linux-${arch}/package.json`);
const triple = arch === "x64" ? "x86_64-unknown-linux-musl" : "aarch64-unknown-linux-musl";
const binary = await realpath(path.join(path.dirname(platformPackage), "vendor", triple, "bin", "codex"));
const profilePath = path.join(temporaryRoot, "codex-userns.apparmor");
await writeFile(profilePath, codexUserNamespaceProfile(binary), { mode: 0o600 });
// sudo is noninteractive and bounded. Failure is a preflight error, before any model invocation.
execFileSync("sudo", ["-n", "apparmor_parser", "-r", profilePath], { timeout: 15_000, stdio: "pipe" });
}

View File

@ -1,4 +1,5 @@
import { randomBytes } from "node:crypto";
import { prepareCodexCiSandbox } from "./codex-ci-sandbox.js";
import { spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import { createRequire } from "node:module";
@ -645,6 +646,9 @@ async function runAttempt(input: {
temporaryRoot,
process.env.PATH,
);
if (execution.environment.id === "local" && execution.profile.id === "runner-codex") {
await prepareCodexCiSandbox(repositoryRoot, temporaryRoot);
}
const agentJwtSecret = secret(48);
const decisionSigningSecret = secret(48);
const toolActionSigningSecret = secret(48);