Merge remote-tracking branch 'origin/master' into codex/sleep-status-card-evaluation

* origin/master:
  fix(evals): support official TestOps job metadata (#18385)
  fix(config): require shared storage for managed cloud (#18350)
  feat(skills): make managed content independent of web-server disk (#18334)
  Add a portable agent question and answer resumption journey (#18346)
  fix(ci): isolate test selection and require current CI CODEOWNERS (#18376)
  fix(evals): capture TestOps registration after watch (#18375)
  fix(runner): allow session reset after confirmed task retirement (#18373)
  fix(evals): batch task search result readback across renders (#18370)
  fix(ui): keep task plugin views available for manual access (#18363)
  test: wait for the evidence expiry worker to settle (#18241)

# Conflicts:
#	server/src/services/company-skills.ts
#	server/src/services/native-runtime/paperclip-runner-tool-authority.ts
This commit is contained in:
Dotta committed 2026-10-30 17:25:31 -05:00
commit d334f59c98
149 files changed
+6624 -1410

No files matched your search

@@ -402,7 +402,7 @@ describe("assigned MCP runner tools", () => {
expect(f.executeTool).not.toHaveBeenCalled();
});
it.each(["planning", "ask"] as const)("exposes and permits only read tools in %s mode", async workMode => {
it.each(["planning", "ask", "skill_test"] as const)("exposes and permits only read tools in %s mode", async workMode => {
const f = fixture([descriptor("read"), descriptor("remember", "write"), descriptor("forget", "destructive")]);
const standard = await createAssignedMcpTools(f);
const restricted = await createAssignedMcpTools({ ...f, workMode });
@@ -423,7 +423,7 @@ describe("assigned MCP runner tools", () => {
for (const [call] of f.executeTool.mock.calls) expect(call).not.toHaveProperty("approvedActionRequestId");
});
it.each(["planning", "ask"] as const)("enforces fresh %s mode without relaxing the pinned mode", async currentMode => {
it.each(["planning", "ask", "skill_test"] as const)("enforces fresh %s mode without relaxing the pinned mode", async currentMode => {
const f = fixture([descriptor("read"), descriptor("remember", "write")]);
const standard = await createAssignedMcpTools(f);
const restricted = await createAssignedMcpTools({ ...f, workMode: currentMode });
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
import type { Db } from "@paperclipai/db";
import { deduplicateToolDescriptors, ToolGatewayHttpError, type ToolGatewayDescriptor, type ToolGatewayService } from "../tool-gateway.js";
type WorkMode = "standard" | "planning" | "ask";
type WorkMode = "standard" | "planning" | "ask" | "skill_test";
type ToolDefinition = Record<string, unknown>;
const SEARCH_TOOL = "paperclip_search_assigned_tools";
@@ -516,7 +516,7 @@ describe("native case tools through authenticated production routes", () => {
expect(review.definitions().map(tool => tool.name)).not.toContain("read_case");
await expect(review.execute({ tool: "update_case", callId: randomUUID(), arguments: { idempotencyKey: "review", title: "No" } })).rejects.toThrow("may only inspect");
await server.db.update(issues).set({ workMode: "skill_test" }).where(eq(issues.id, f.issueId));
await expect(f.call("read_case")).rejects.toThrow("mode_denied");
await expect(f.call("read_case")).rejects.toMatchObject({ status: 403 });
await server.db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, f.issueId));
await instanceSettingsService(server.db).updateExperimental({ enableCases: false });
try { await expect(f.call("read_case")).rejects.toThrow("Cases are disabled"); }
@@ -9,7 +9,7 @@ import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
import { buildNativeModelEnvelope, canonicalNativeRuntimeContextDigest } from "../../vendor/paperclip-runner/index.js";
describe("LCA-05 explicit native work mode", () => {
it.each(["standard", "planning", "ask"])("title and description cannot override %s mode", (workMode) => {
it.each(["standard", "planning", "ask", "skill_test"])("title and description cannot override %s mode", (workMode) => {
for (const text of ["Inspect files", "Making a plan", "Create a report", "Research proposal", "Implement now; no plan needed"]) {
const input = buildNativeExecutionInput({
companyId: "10000000-0000-4000-8000-000000000001",
@@ -144,8 +144,8 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { p
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot" | "muse"> }): NativeExecutionInputV5 | NativeExecutionInputV7 | NativeExecutionInputV9 | NativeExecutionInputV10;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9 | NativeExecutionInputV10;
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 | NativeExecutionInputV9 | NativeExecutionInputV10 {
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask") {
throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, or ask");
if (input.issue.workMode !== "standard" && input.issue.workMode !== "planning" && input.issue.workMode !== "ask" && input.issue.workMode !== "skill_test") {
throw new Error("native_execution_input_invalid: issue work mode must be standard, planning, ask, or skill_test");
}
const mode = resolvePaperclipRunnerCursorMode(input.provider, input.acpxAgent, input.acpxSessionMode);
const piThinkingLevel = resolvePaperclipRunnerPiThinkingLevel(input.provider, input.acpxAgent, input.piThinkingLevel);
@@ -75,6 +75,7 @@ import { agentInstructionWorkingCopies } from "@paperclipai/db";
import * as noLaunchProofModule from "./native-maintenance-no-launch.js";
import {
NativeSessionCleanupQuarantinedError,
NativeSessionCloseUnrecoverableError,
NativeProviderTerminalFailure,
NativeSessionProtocolIntegrityError,
} from "../../vendor/paperclip-runner/index.js";
@@ -9201,6 +9202,102 @@ describe("native warm session supervision", () => {
await closeIdleWarmNativeSessionsForRestart();
});
it.each(["retired", "unconfirmed", "other-error", "collection-failed", "same-session", "same-id-provider", "unbound", "pending-semantic"] as const)(
"handles a permanently rejected plugin close during reset (%s)", async mode => {
const { base, next, run } = fixture(`identity-poisoned-close-${mode}`);
const identity = { runnerInstanceId: "runner", environmentLeaseId: "lease", runId: base.binding.runId,
normalizedSessionId: base.session.normalizedSessionId!, turnId: "turn", itemId: "item" };
let stopped = false;
const task = vi.fn(async (operation: any) => {
if (operation.kind === "describe") return { kind: "description", taskId: "old-task", launchPreparationVersion: 1,
projectAuthCheckVersion: 1, runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } };
if (operation.kind === "stop") stopped = true;
if (operation.kind === "status") return { kind: "status", taskId: "old-task", phase: stopped ? "cancelled" : "running", executionStopped: stopped };
if (operation.kind === "complete" && mode === "unconfirmed") throw new Error("handoff unconfirmed");
return { kind: "accepted", taskId: "old-task" };
});
const createOwner = pluginTaskRuntime.createPluginRunnerTaskOwner;
let owner!: ReturnType<typeof createOwner>;
const ownerFactory = vi.spyOn(pluginTaskRuntime, "createPluginRunnerTaskOwner").mockImplementation(input => {
owner = createOwner({ ...input, adoptIdentity: identity });
return owner;
});
onTestFinished(() => ownerFactory.mockRestore());
// Bind through the production backend so the receipt belongs to this
// exact session object. The task boundary supplies the stop proof.
const backend = await createRunnerdBackend({ db: leaseDb(base),
execution: { ...base, session: { ...base.session, lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } } },
runnerInstanceId: "runner", runnerIngressAuthorized: true,
runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: ".", leaseId: "lease", runnerTask: task } });
const registration = await owner.registration({} as never, base.binding.runId);
const failure = mode === "other-error" ? new Error("controller retirement unconfirmed") : new NativeSessionCloseUnrecoverableError(undefined, mode !== "pending-semantic");
let closePromise: Promise<void> | undefined;
const close = vi.fn(() => closePromise ??= (async () => {
await registration.release();
await registration.checkpoint("unsettled").catch(() => undefined);
throw failure;
})());
const session = { close };
if (mode !== "unbound") backend.bindManagedSession(session as never);
const collectStopped = vi.fn(async () => {
expect(owner.isRetired()).toBe(true);
if (mode === "collection-failed") throw new Error("file collection failed");
});
const retirementFailed = vi.fn(async () => {});
state.execute.mockReset().mockImplementationOnce(async options => {
await options.onSession?.(session); return result;
}).mockImplementationOnce(async options => {
expect(options.existingSession).toBeUndefined();
expect(collectStopped).toHaveBeenCalled();
return result;
});
try {
await executePaperclipNativeSession({ db: leaseDb(base), execution: base, runnerInstanceId: "runner",
instructionWorkingCopy: { hasChanges: async () => false, collectStopped, retirementFailed } });
// First poison the retained owner through the real configuration
// retirement path. An explicit reset must not need a process restart.
const changed = { ...base, binding: { ...base.binding, runId: `${base.binding.runId}-changed` } };
await expect(executePaperclipNativeSession({ db: leaseDb(changed), execution: changed,
runnerInstanceId: "runner", refreshTools: true })).rejects.toThrow(failure.message);
expect(close).toHaveBeenCalledOnce();
expect(collectStopped).not.toHaveBeenCalled();
expect(retirementFailed).toHaveBeenCalledOnce();
retirementFailed.mockClear();
const replacement = mode === "same-session" ? { ...next, session: base.session }
: mode === "same-id-provider" ? { ...next, session: base.session,
provider: { kind: "opencode" as const, model: "openai/test", permissionMode: "allow" as const } } : next;
const attempt = () => mode === "same-session"
? executePaperclipNativeSession({ db: leaseDb(replacement), execution: replacement, runnerInstanceId: "runner", refreshTools: true })
: run(replacement);
if (mode === "retired") {
await attempt();
expect(state.execute).toHaveBeenCalledTimes(2);
expect(retirementFailed).not.toHaveBeenCalled();
} else {
// Both attempts see the same rejected promise, just as the real
// transport does; no mock succeeds on the second close.
const expected = mode === "collection-failed" ? "file collection failed" : failure.message;
await expect(attempt()).rejects.toThrow(expected);
await expect(attempt()).rejects.toThrow(expected);
expect(state.execute).toHaveBeenCalledTimes(1);
if (mode !== "collection-failed") expect(collectStopped).not.toHaveBeenCalled();
else {
collectStopped.mockResolvedValue(undefined);
await attempt();
expect(state.execute).toHaveBeenCalledTimes(2);
}
}
expect(task.mock.calls.filter(([operation]) => operation.kind === "stop")).toHaveLength(1);
expect(task.mock.calls.some(([operation]) => operation.kind === "read_state")).toBe(false);
} finally {
collectStopped.mockResolvedValue(undefined);
close.mockResolvedValue(undefined);
await closeIdleWarmNativeSessionsForRestart();
ownerFactory.mockRestore();
}
},
);
it("retains failed retirement for retry and never launches over an uncontained owner", async () => {
const { base, next, run } = fixture("identity-close-failure");
const close = vi.fn().mockRejectedValueOnce(new Error("containment unavailable")).mockResolvedValue(undefined);
@@ -12675,7 +12772,7 @@ describe("runnerd provider runtime wiring", () => {
const driver = { prepareLaunch: vi.fn(), runnerProcessLauncher: launcher, controlPlaneRegistration: registration };
state.toolAuthorityDefinitions.mockImplementation(async binding => binding.readRemoteTaskFile ? [{ name: "register_deliverable" }] as never : []);
const readWorkspaceFile = vi.fn(async () => Buffer.from("proof"));
const owner = vi.spyOn(pluginTaskRuntime, "createPluginRunnerTaskOwner").mockReturnValue({ launcher, registration, driver, readWorkspaceFile, rebind:vi.fn() });
const owner = vi.spyOn(pluginTaskRuntime, "createPluginRunnerTaskOwner").mockReturnValue({ launcher, registration, driver, readWorkspaceFile, isRetired: () => false, rebind:vi.fn() });
const task = vi.fn(async (operation: unknown) => {
environmentTaskOperationSchema.parse(operation);
return { kind: "description", launchPreparationVersion: 1, projectAuthCheckVersion: 1, ...(kind === "codex" ? { workspaceFileReadVersion: 1 } : {}), taskId: "task", runner: { version: "fixture", digest: `sha256:${"a".repeat(64)}` } };
@@ -1,3 +1,4 @@
import { rehydratePinnedSkillAssets } from "./runtime-context.js";
import { createNativeUsageDelivery } from "./native-usage-delivery.js";
import { createNativeTurnAccounting, nativeUsageCostUsd, nativeUsageBiller, normalizeNativeUsage, usesNativeUsageDelivery, isNativeUsageAccountingEvent } from "./native-usage-accounting.js";
export { nativeUsageCostUsd, nativeUsageBiller, normalizeNativeUsage } from "./native-usage-accounting.js";
@@ -127,6 +128,7 @@ import type {
import {
NativeProviderTerminalFailure,
NativeSessionCleanupQuarantinedError,
NativeSessionCloseUnrecoverableError,
NativeSessionProtocolIntegrityError,
completeRetainedNativeSessionCleanup,
completeTerminatedLocalNativeSessionCleanup,
@@ -581,6 +583,7 @@ type WarmNativeSession = {
configDigest: string;
configuredEnvironmentDigest: string;
ownerScope: string;
normalizedSessionId: string;
companyId: string;
environmentId: string | null;
busy: boolean;
@@ -589,18 +592,31 @@ type WarmNativeSession = {
lastActivityAt: string;
};
async function closeWarmNativeSession(entry: WarmNativeSession, reason: string, preserveInstructionsForRunId?: string) {
async function closeWarmNativeSession(entry: WarmNativeSession, reason: string, options?: {
preserveInstructionsForRunId?: string;
discardUnsettledSession?: boolean;
}) {
// Revoke before awaiting process retirement/checkpoint IO.
const stopping = entry.githubAccess?.stop();
try {
try { await entry.session.close({ reason }); }
catch (error) {
try { await entry.instructionWorkingCopy?.retirementFailed?.(); }
catch (receiptError) { throw new AggregateError([error, receiptError], "Warm instruction retirement and receipt both failed"); }
throw error;
// A new conversation does not consume the old checkpoint. Its failed
// suspension must not poison every later reset after Launcher has proved
// that the exact task stopped and completed its credential handoff.
// Other close errors can mean controller authority is still live.
const retiredWithoutCheckpoint = options?.discardUnsettledSession === true
&& error instanceof NativeSessionCloseUnrecoverableError
&& error.semanticToolsSettled
&& pluginTaskSessionOwners.get(entry.session)?.isRetired() === true;
if (!retiredWithoutCheckpoint) {
try { await entry.instructionWorkingCopy?.retirementFailed?.(); }
catch (receiptError) { throw new AggregateError([error, receiptError], "Warm instruction retirement and receipt both failed"); }
throw error;
}
}
await entry.instructionWorkingCopy?.collectStopped();
if (entry.instructionCopy?.runId !== preserveInstructionsForRunId) await entry.instructionCopy?.collectStopped();
if (entry.instructionCopy?.runId !== options?.preserveInstructionsForRunId) await entry.instructionCopy?.collectStopped();
}
finally { await stopping; }
}
@@ -1915,6 +1931,7 @@ function nativeSessionOwnerScope(
async function retireSupersededWarmNativeSessions(
ownerScope: string,
nextSessionScope: string,
nextSessionId: string,
): Promise<void> {
for (const [scope, entry] of warmNativeSessions) {
if (scope === nextSessionScope || entry.ownerScope !== ownerScope) continue;
@@ -1926,7 +1943,9 @@ async function retireSupersededWarmNativeSessions(
entry.busy = true;
entry.closeOnReleaseReason = "warm native session identity changed";
try {
await closeWarmNativeSession(entry, entry.closeOnReleaseReason);
await closeWarmNativeSession(entry, entry.closeOnReleaseReason, {
discardUnsettledSession: entry.normalizedSessionId !== nextSessionId,
});
forgetWarmNativeSession(scope, entry);
} finally {
// A failed close retains the owner and prevents launch. A later attempt
@@ -7939,7 +7958,7 @@ export async function executePaperclipNativeSession(input: {
}
executingNativeOwnerScopes.set(ownerScope, ownerToken);
await retireSupersededWarmNativeSessions(
ownerScope, nativeSessionScopeKey(input.execution),
ownerScope, nativeSessionScopeKey(input.execution), nativeSessionKey(input.execution),
);
if ((input.execution.schema === "paperclip.native-execution-input.v6" || input.execution.schema === "paperclip.native-execution-input.v8") && isCloudManagedInstance()
&& (!input.useRunnerd || input.runnerExecutionTarget?.kind !== "remote"
@@ -9025,7 +9044,7 @@ async function executePaperclipNativeSessionWithinScope(
entry.busy = true;
entry.closeOnReleaseReason = "warm native session configuration changed";
try {
await closeWarmNativeSession(entry, entry.closeOnReleaseReason, input.execution.binding.runId);
await closeWarmNativeSession(entry, entry.closeOnReleaseReason, { preserveInstructionsForRunId: input.execution.binding.runId });
forgetWarmNativeSession(warmSessionId, entry);
} finally { entry.busy = false; }
if (preparedCopy) throw new Error("native_instruction_preparation_configuration_changed");
@@ -9481,6 +9500,7 @@ async function executePaperclipNativeSessionWithinScope(
ownerScope: nativeSessionOwnerScope(
input.execution, input.runnerExecutionTarget?.environmentId ?? null,
),
normalizedSessionId: nativeSessionKey(input.execution),
companyId: input.execution.binding.companyId,
environmentId:
input.runnerExecutionTarget?.environmentId ?? null,
@@ -12382,8 +12402,9 @@ async function createRunnerdBackendWithinSessionClaim(
: remoteRunnerFilesystemRoot
? posix.join(remoteRunnerFilesystemRoot, directory.name)
: null;
const sourceRuntimeContext =
"runtimeContext" in input.execution ? input.execution.runtimeContext : null;
const sourceRuntimeContext = "runtimeContext" in input.execution
? await rehydratePinnedSkillAssets(input.db, input.execution.binding, input.execution.runtimeContext)
: null;
const remoteRuntimeContext: NativeRuntimeContextSnapshot | null =
remoteRunnerFilesystemRoot && sourceRuntimeContext
? {
@@ -0,0 +1,66 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { agentWakeupRequests, heartbeatRuns, issues } from "@paperclipai/db";
import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
describe("native Studio test authorization", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
const oldSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
beforeAll(async () => {
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
server = await startRunnerApiTestServer();
}, 60_000);
afterAll(async () => {
await server?.close();
if (oldSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
else process.env.PAPERCLIP_AGENT_JWT_SECRET = oldSecret;
});
it.each(["skill_test", "standard"] as const)("keeps admitted tests task-scoped with current mode %s", async workMode => {
const fixture = await server.fixture();
await server.db.update(issues).set({ workMode }).where(eq(issues.id, fixture.issueId));
await server.db.update(issues).set({ parentId: fixture.issueId }).where(eq(issues.id, fixture.blockerId));
await server.db.update(heartbeatRuns).set({ contextSnapshot: {
issueId: fixture.issueId, paperclipAgentJwtScope: { kind: "skill_test", issueId: fixture.issueId },
} }).where(eq(heartbeatRuns.id, fixture.runId));
const authority = new PaperclipRunnerToolAuthority(server.db, { ...fixture, workMode });
const call = (tool: string, args: unknown = {}) => ({ tool, callId: randomUUID(), arguments: args });
const before = await fixture.snapshot();
const wakes = await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, fixture.companyId));
const create = call("create_task", { title: "Must not escape Studio", idempotencyKey: "denied-create", assigneeActorId: fixture.agentId });
for (const denied of [create, call("search_tasks"), call("list_agents"), call("list_people"), call("list_approvals"),
call("get_agent", { actorId: fixture.agentId }), call("connection_request", { service: "notion" })]) {
await expect(authority.execute(denied)).rejects.toMatchObject({ status: 403 });
await expect(authority.recover(denied)).rejects.toMatchObject({ status: 403 });
}
expect((await fixture.snapshot()).issues).toEqual(before.issues);
expect(await server.db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, fixture.companyId))).toEqual(wakes);
const context = await authority.execute(call("get_task_context"));
expect(context).toMatchObject({ activeTask: { id: fixture.issueId }, childTasks: [], childReviewOutcomes: [] });
expect(JSON.stringify(context)).not.toContain(fixture.blockerId);
const write = call("write_document", { key: "proof", title: "Studio evidence", body: "Executed the delivered skill", idempotencyKey: "own-proof" });
const receipt = await authority.execute(write);
expect(await authority.recover(write)).toEqual({ result: receipt });
expect(await authority.execute(call("read_document", { key: "proof" }))).toMatchObject({ document: { body: "Executed the delivered skill" } });
expect(await authority.execute(call("get_task", { taskId: fixture.issueId }))).toMatchObject({ ok: true, status: 200, data: { id: fixture.issueId } });
expect(await authority.execute(call("get_task", { taskId: fixture.blockerId }))).toMatchObject({ ok: false, status: 404 });
if (workMode === "skill_test") {
const names = authority.definitions().map(tool => tool.name);
expect(names).toContain("write_document");
expect(names).toContain("get_task_context");
expect(names).not.toContain("create_task");
expect(names).not.toContain("list_agents");
expect(names).not.toContain("connection_request");
}
});
it("still creates ordinary tasks for a standard run", async () => {
const fixture = await server.fixture({ disableWakeOnDemand: true });
await fixture.authority.execute({ tool: "create_task", callId: randomUUID(), arguments: {
title: "Normal child task", idempotencyKey: "standard-create", assigneeActorId: fixture.agentId,
} });
expect((await fixture.snapshot()).issues.filter(issue => issue.title === "Normal child task")).toHaveLength(1);
});
});
@@ -0,0 +1,39 @@
import type { AgentApiKeyScope } from "@paperclipai/shared";
import { forbidden } from "../../errors.js";
const taskTools = new Set([
"get_identity", "get_task_context", "get_task_history", "get_task",
"list_documents", "read_document", "list_document_revisions", "write_document",
"list_task_documents", "read_task_document", "write_task_document", "comment_on_task",
"report_progress", "set_task_title", "request_human_input", "register_deliverable",
"list_assigned_skills", "read_assigned_skill", "search_api", "call_api",
"read_current_wake_comments", "list_chat_attachments", "read_chat_attachment",
"reuse_chat_attachment", "list_task_attachments", "read_task_attachment",
]);
const record = (value: unknown): Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : {};
export function nativeRunKeyScope(input: {
contextSnapshot?: unknown;
issueId: string;
workMode?: string;
}): AgentApiKeyScope {
const snapshot = record(input.contextSnapshot);
const admitted = record(snapshot.paperclipAgentJwtScope);
const skillTest = admitted.kind === "skill_test" || (admitted.kind !== "standard"
&& (Boolean(snapshot.paperclipSkillTest) || input.workMode === "skill_test"
|| record(snapshot.paperclipIssue).workMode === "skill_test"));
return skillTest ? { kind: "skill_test", issueId: input.issueId } : { kind: "standard" };
}
export function nativeSkillTestToolAllowed(tool: string) {
return taskTools.has(tool);
}
export function assertNativeSkillTestTool(scope: AgentApiKeyScope, tool: string) {
if (scope.kind === "skill_test" && !nativeSkillTestToolAllowed(tool)) {
throw forbidden("Skill-test runs may only use their own harness task and admitted skill contents.");
}
}
@@ -0,0 +1,94 @@
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
activityLog, agents, companies, companySkills, companySkillVersions, companySkillTestRuns,
completionContracts, createDb, documents, heartbeatRuns, issueDocuments, issues,
nativeRunFinalizations, nativeRunResults, workAssessments, type Db,
} from "@paperclipai/db";
import { recoveryPostgresSupport, startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js";
import { commitNativeStatusDecision } from "./status-decision-committer.js";
import { NATIVE_STATUS_ARBITER_POLICY_VERSION } from "./status-arbiter.js";
describe.skipIf(!recoveryPostgresSupport.supported)("native Studio status publication", () => {
let database: Awaited<ReturnType<typeof startRecoveryPostgres>>, db: Db;
beforeAll(async () => {
database = await startRecoveryPostgres("native-skill-test-status-");
db = createDb(database.connectionString);
}, 60_000);
afterAll(async () => { await db?.$client.end(); await database?.cleanup(); });
async function seed() {
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID();
const skillId = randomUUID(), versionId = randomUUID(), testId = randomUUID(), documentId = randomUUID();
const contractId = randomUUID(), resultId = randomUUID(), assessmentId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Studio status", issuePrefix: `S${companyId.slice(0, 7)}` });
await db.insert(agents).values({ id: agentId, companyId, name: "Tester", adapterType: "paperclip_runner" });
await db.insert(companySkills).values({ id: skillId, companyId, key: "company/test", slug: "test", name: "Test", markdown: "# Test", sourceType: "managed" });
await db.insert(companySkillVersions).values({ id: versionId, companyId, companySkillId: skillId, revisionNumber: 1 });
// Task mode can change after admission; the saved Studio obligation survives.
await db.insert(issues).values({ id: issueId, companyId, title: "Studio output", status: "in_progress", assigneeAgentId: agentId });
await db.insert(companySkillTestRuns).values({ id: testId, companyId, skillId, skillVersionId: versionId, agentId, issueId, inputSnapshot: "Execute the skill" });
await db.insert(documents).values({ id: documentId, companyId, latestBody: "Verified the selected immutable bytes." });
await db.insert(issueDocuments).values({ companyId, issueId, documentId, key: "output" });
await db.insert(completionContracts).values({ id: contractId, companyId, issueId, revision: 1, schemaVersion: "paperclip.completion-contract.v1",
policyVersion: "studio-test", risk: "standard", completionAuthority: "server_arbiter", incompleteCriteriaPolicy: "preserve_non_terminal",
contractJson: { revision: "1", criteria: [] }, canonicalSha256: randomUUID(), createdByActorType: "system", createdByActorId: "test" });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId,
completionContractId: contractId, contextSnapshot: { issueId, paperclipAgentJwtScope: { kind: "skill_test", issueId } } });
await db.insert(nativeRunResults).values({ id: resultId, companyId, issueId, runId, completionContractId: contractId,
serverFingerprint: randomUUID(), schemaStatus: "accepted", resultJson: {}, canonicalSha256: randomUUID() });
const [task] = await db.select().from(issues).where(eq(issues.id, issueId));
await db.insert(workAssessments).values({ id: assessmentId, companyId, issueId, runId, contractId, resultId, triggerKind: "native_result",
triggerActorCompanyId: companyId, priorIssueStatus: task.status, priorStatusVersion: task.statusVersion,
policyVersion: "studio-test", assessmentJson: {}, inputDigest: randomUUID() });
await db.insert(nativeRunFinalizations).values({ runId, companyId, issueId, phase: "arbitrating", resultId });
const read = async () => (await db.select().from(companySkillTestRuns).where(eq(companySkillTestRuns.id, testId)))[0]!;
const audit = () => db.select().from(activityLog).where(and(eq(activityLog.companyId, companyId), eq(activityLog.action, "company.skill_test_run_completed")));
const commit = (status: "done" | "cancelled" | "blocked", executor: Db = db) => commitNativeStatusDecision({ db: executor,
companyId, issueId, runId, assessmentId, priorStatus: task.status, priorStatusVersion: task.statusVersion, priorDecisionId: task.lastStatusDecisionId,
decision: { policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION, statusAction: status, toStatus: status,
reasonCode: "studio_status_test", unblockDescriptor: null, effects: [] } });
return { companyId, issueId, testId, documentId, read, audit, commit };
}
it.each(["done", "cancelled"] as const)("atomically publishes %s and its output once across replay", async status => {
const f = await seed();
await f.commit(status);
const saved = await f.read();
expect(saved).toMatchObject({ status: status === "done" ? "succeeded" : "cancelled", outputSnapshot: "Verified the selected immutable bytes." });
expect(await f.audit()).toHaveLength(1);
await db.update(documents).set({ latestBody: "Changed after completion" }).where(eq(documents.id, f.documentId));
const reopened = createDb(database.connectionString);
try { expect((await f.commit(status, reopened)).replayed).toBe(true); }
finally { await reopened.$client.end(); }
expect(await f.read()).toEqual(saved);
expect(await f.audit()).toHaveLength(1);
});
it("rolls back task, Studio output, and audit together", async () => {
const f = await seed();
await expect(db.transaction(async tx => {
await f.commit("done", tx as unknown as Db);
expect((await tx.select().from(companySkillTestRuns).where(eq(companySkillTestRuns.id, f.testId)))[0].status).toBe("succeeded");
throw new Error("rollback Studio fixture");
})).rejects.toThrow("rollback Studio fixture");
expect(await f.read()).toMatchObject({ status: "queued", outputSnapshot: "" });
expect((await db.select().from(issues).where(eq(issues.id, f.issueId)))[0].status).toBe("in_progress");
expect(await f.audit()).toHaveLength(0);
await f.commit("done");
expect((await f.read()).status).toBe("succeeded");
});
it("keeps blocked tests pending and does not revive superseded tests", async () => {
const blocked = await seed();
await blocked.commit("blocked");
expect((await blocked.read()).status).toBe("queued");
expect(await blocked.audit()).toHaveLength(0);
const superseded = await seed();
await db.update(companySkillTestRuns).set({ supersededAt: new Date() }).where(eq(companySkillTestRuns.id, superseded.testId));
await superseded.commit("done");
expect((await superseded.read()).status).toBe("queued");
expect(await superseded.audit()).toHaveLength(0);
});
});
@@ -1,4 +1,5 @@
import { notifyStatusCardIssueChange } from "../status-card-notifications.js";
import { nativeRunKeyScope, nativeSkillTestToolAllowed, assertNativeSkillTestTool } from "./native-skill-test-scope.js";
import { requiredQualityReviewSchema } from "@paperclipai/shared";
import { priorRecoveryApiReceipt } from "../recovery-effect-receipts.js";
import { DELIVERY_QUEUES, notifyDeliveryWork } from "../delivery-work-notifications.js";
@@ -185,7 +186,7 @@ type Binding = {
connectorAssignments?: Pick<ConnectorAssignment, "tools">[];
assignedMcpTools?: Awaited<ReturnType<typeof createAssignedMcpTools>>;
apiToolsEnabled?: boolean;
workMode?: "standard" | "planning" | "ask";
workMode?: "standard" | "planning" | "ask" | "skill_test";
casesEnabled?: boolean;
workspaceRoot?: string;
workspaceBridge?: boolean;
@@ -390,14 +391,15 @@ export class PaperclipRunnerToolAuthority {
if (!this.binding.firstPartyOnly && this.binding.assignedMcpTools) definitions.push(READ_TOOL_RESULT_DEFINITION);
const assignedTools = this.binding.firstPartyOnly ? [] : this.binding.assignedMcpTools?.definitions((tools) =>
runnerCodexDynamicToolsFit([...connectionTools, ...tools, ...definitions])) ?? [];
const all = [...connectionTools, ...assignedTools, ...definitions];
const all = [...connectionTools, ...assignedTools, ...definitions]
.filter(tool => workMode !== "skill_test" || nativeSkillTestToolAllowed(String(tool.name)));
return this.binding.museRuntime ? all.filter(definition => MUSE_NATIVE_OPERATIONS.has(String(definition.name))) : all;
}
async recover(call: Parameters<PaperclipRunnerToolAuthority["execute"]>[0]): Promise<{ result: unknown; isError?: boolean } | null> {
await this.#assertToolScope(call.tool);
if (!this.binding.museRuntime) await this.binding.assertBridgeAuthority?.();
const context = await this.#boundContext();
const context = await this.#boundContext({ tool: call.tool });
const { run } = context;
if (RECOVERABLE_READ_OPERATIONS.has(call.tool)) return this.#recoverRead(call);
if (call.tool === "update_case" || call.tool === "create_workflow" || call.tool === "update_workflow") {
@@ -532,6 +534,7 @@ export class PaperclipRunnerToolAuthority {
const [run] = await this.db.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, this.binding.runId),
eq(heartbeatRuns.companyId, this.binding.companyId), eq(heartbeatRuns.agentId, this.binding.agentId)));
if (!run) throw forbidden("Run binding unavailable");
assertNativeSkillTestTool(nativeRunKeyScope({ ...this.binding, contextSnapshot: run.contextSnapshot }), call.tool);
const request = create ? {
idempotencyKey: create.idempotencyKey,
case: { create: { caseType: create.caseType, title: create.title,
@@ -552,7 +555,7 @@ export class PaperclipRunnerToolAuthority {
if (this.binding.nativeReview && call.tool === "resolve_review") return this.#resolveReview(call.arguments);
if (Object.hasOwn(TASK_ATTACHMENT_SCHEMAS, call.tool)) {
const authorize = async () => {
const current = await this.#boundContext();
const current = await this.#boundContext({ tool: call.tool });
if (!this.binding.taskAttachmentRead || current.actor.adapterType !== "paperclip_runner"
|| current.actor.adapterConfig?.provider !== "openai_dot" || current.actor.adapterConfig?.dotAttachmentAccess !== true)
throw forbidden("Dot task attachment reading was not enabled or was disabled");
@@ -570,13 +573,13 @@ export class PaperclipRunnerToolAuthority {
});
}
if (RUNNER_BRIDGE_SCHEMAS.has(call.tool)) {
await this.#boundContext();
await this.#boundContext({ tool: call.tool });
if (!this.definitions().some(definition => definition.name === call.tool)) throw forbidden("Tool is not available to this run");
const parsed = RUNNER_BRIDGE_SCHEMAS.get(call.tool)!.safeParse(call.arguments);
if (!parsed.success) return { outcome: "failed", code: "runner_bridge_invalid_arguments", message: "Arguments do not match this tool's advertised schema." };
const input = parsed.data as Record<string, any>;
if (call.tool.startsWith("workspace_")) {
const authorize = async () => { const current = await this.#boundContext();
const authorize = async () => { const current = await this.#boundContext({ tool: call.tool });
if (current.actor.adapterConfig?.dotWorkspaceAccess !== true) throw forbidden("Dot workspace access was disabled");
await this.binding.assertBridgeAuthority?.();
if (["workspace_write", "workspace_run"].includes(call.tool) && current.issue.workMode !== "standard") throw forbidden("This work mode cannot change files or run commands");
@@ -586,7 +589,7 @@ export class PaperclipRunnerToolAuthority {
return ["workspace_write", "workspace_run"].includes(call.tool) ? this.#bridgeMutation(call, execute) : settleRunnerBridgeRead(execute);
}
if (call.tool === "get_identity") {
const { actor, run } = await this.#boundContext();
const { actor, run } = await this.#boundContext({ tool: call.tool });
const [person] = run.responsibleUserId ? await this.db.select({ id: authUsers.id, name: authUsers.name }).from(authUsers).where(eq(authUsers.id, run.responsibleUserId)) : [];
return { agent: redactedActor(actor), responsibleUser: person ?? null, permissions: actor.permissions, tools: this.definitions().map(tool => tool.name) };
}
@@ -595,7 +598,7 @@ export class PaperclipRunnerToolAuthority {
.where(and(eq(companyMemberships.companyId, this.binding.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.status, "active"), input.after ? sql`${authUsers.id} > ${input.after}` : undefined))
.orderBy(authUsers.id).limit(input.limit);
if (call.tool === "list_assigned_skills" || call.tool === "read_assigned_skill") {
const current = await this.#boundContext();
const current = await this.#boundContext({ tool: call.tool });
const visibility = await skillAccessService(this.db, this.#privacyActor(current.run)).condition(this.binding.companyId);
const known = await this.db.select({ key: companySkills.key }).from(companySkills).where(eq(companySkills.companyId, this.binding.companyId));
const permitted = await this.db.select({ key: companySkills.key }).from(companySkills).where(and(eq(companySkills.companyId, this.binding.companyId), visibility));
@@ -611,21 +614,24 @@ export class PaperclipRunnerToolAuthority {
}
if (this.binding.firstPartyOnly && (call.tool === READ_TOOL_RESULT || this.binding.assignedMcpTools?.has(call.tool) || isConnectorTool(call.tool)
|| RUNTIME_CONNECTION_TOOL_DEFINITIONS.some(tool => tool.name === call.tool))) throw forbidden("This Grok Bot connection exposes first-party Paperclip operations. Use your own provider apps under their existing permissions.");
if (call.tool === READ_TOOL_RESULT) return this.#readToolResult(call.arguments);
if (call.tool === READ_TOOL_RESULT) {
await this.#boundContext({ tool: call.tool });
return this.#readToolResult(call.arguments);
}
if (this.binding.assignedMcpTools?.has(call.tool)) {
const current = await this.#boundContext();
const current = await this.#boundContext({ tool: call.tool });
const result = await this.binding.assignedMcpTools.execute(call, current.issue.workMode as "standard" | "planning" | "ask");
return captureRunnerToolResult(result, this.#responseCaptureIo("runner.assigned_tool"));
}
if (isConnectorTool(call.tool)) {
if (!(this.binding.connectorAssignments ?? []).some((assignment) => assignment.tools.some((tool) => tool.name === call.tool))) throw forbidden("Connector tool is not available to this run");
const { run } = await this.#boundContext({ includeChatWake: call.tool.startsWith("agentmail_") });
const { run } = await this.#boundContext({ tool: call.tool, includeChatWake: call.tool.startsWith("agentmail_") });
const snapshot = record(run.contextSnapshot);
if (call.tool.startsWith("agentmail_") && (isPaperclipExternalChatContractTurn(snapshot.paperclipWake) || String(snapshot.source ?? "").startsWith("chat:") || snapshot.paperclipExternalChatQuestionResponse)) throw forbidden("Restricted chat runs cannot use email actions");
return executeConnectorTool(this.db, this.binding, call.tool, call.arguments);
}
if (RUNTIME_CONNECTION_TOOL_DEFINITIONS.some((tool) => tool.name === call.tool)) {
await this.#boundContext();
await this.#boundContext({ tool: call.tool });
const { run } = await captureRunIdentity(this.db, this.binding, { readScope: "identity" });
if (!run.responsibleUserId) throw forbidden("This task needs a responsible user before requesting a connection");
const claims = {
@@ -678,7 +684,7 @@ export class PaperclipRunnerToolAuthority {
) {
throw new Error("paperclip_runner_tool_not_advertised");
}
const context = await this.#boundContext();
const context = await this.#boundContext({ tool: call.tool });
const input = record(call.arguments);
if (call.tool === READ_CHAT_ATTACHMENT_TOOL_NAME) {
const scope = this.binding.chatAttachmentReadScope;
@@ -801,6 +807,11 @@ export class PaperclipRunnerToolAuthority {
return apiToolResult(await this.#callApi(call.callId, call.arguments));
}
case "get_task_context": {
if (this.#apiScope(context).kind === "skill_test") return {
company: { id: this.binding.companyId }, actor: redactedActor(context.actor),
activeTask: redactedTask(context.issue), childTasks: [], childReviewOutcomes: [],
run: { id: context.run.id, status: context.run.status, invocationSource: context.run.invocationSource },
};
const monitorCheckAt = !this.binding.nativeReview && context.issue.workMode === "standard" &&
context.issue.executionRunId === this.binding.runId
? eligibleIssueMonitorWait(context.issue, this.binding.agentId) : null;
@@ -995,11 +1006,8 @@ export class PaperclipRunnerToolAuthority {
issue: { workMode: string };
run: { contextSnapshot: unknown };
}) {
const snapshot = record(context.run.contextSnapshot);
const admittedKind = record(snapshot.paperclipAgentJwtScope).kind;
const skillTest = admittedKind === "skill_test" || (admittedKind !== "standard"
&& (Boolean(snapshot.paperclipSkillTest) || context.issue.workMode === "skill_test"));
return skillTest ? { kind: "skill_test" as const, issueId: this.binding.issueId } : { kind: "standard" as const };
return nativeRunKeyScope({ contextSnapshot: context.run.contextSnapshot,
issueId: this.binding.issueId, workMode: context.issue.workMode });
}
#apiToken(context: {
@@ -1371,13 +1379,14 @@ export class PaperclipRunnerToolAuthority {
return outcomes.filter(outcome => { const id = taskId(outcome); return typeof id === "string" && readable.has(id); });
}
#privacyActor(run: { responsibleUserId: string | null }): AuthorizationActor {
#privacyActor(run: { responsibleUserId: string | null; contextSnapshot?: unknown }): AuthorizationActor {
return { type: "agent", agentId: this.binding.agentId, companyId: this.binding.companyId,
runId: this.binding.runId, source: "agent_jwt", onBehalfOfUserId: run.responsibleUserId,
keyScope: nativeRunKeyScope({ ...this.binding, contextSnapshot: run.contextSnapshot }),
...(this.binding.museRuntime ? { authorizingUserId: this.binding.externalAuthorizingUserId } : {}) };
}
async #boundContext(options: { includeChatWake?: boolean } = {}) {
async #boundContext(options: { includeChatWake?: boolean; tool?: string } = {}) {
if (this.binding.museRuntime && !this.binding.externalAuthorizingUserId) throw forbidden("Muse authorizer is unavailable");
const [row] = await this.db.select({ issue: issues, actor: agents, run: {
id: heartbeatRuns.id,
@@ -1439,6 +1448,7 @@ export class PaperclipRunnerToolAuthority {
const privacy = await authorizationService(this.db).decide({ actor: this.#privacyActor(row.run), action: "issue:read",
resource: { type: "issue", companyId: this.binding.companyId, issueId: row.issue.id } });
if (!privacy.allowed) throw forbidden("The task is no longer available to this run");
if (options.tool) assertNativeSkillTestTool(this.#apiScope(row), options.tool);
return row;
}
@@ -2574,6 +2584,7 @@ export class PaperclipRunnerToolAuthority {
const context = await this.#lockAuthorizedMutationContext(
tx as unknown as Db,
);
assertNativeSkillTestTool(this.#apiScope(context), operationId);
const isFilePreparation =
operationId === "register_deliverable" ||
operationId === REUSE_CHAT_ATTACHMENT_TOOL_NAME;
@@ -137,6 +137,39 @@ describe("plugin native Runner ownership", () => {
expect(retained).toHaveBeenCalledWith(expect.objectContaining(requirements.identity));
expect(task.mock.calls.map(([op]) => op.kind)).toEqual(["status", "read_state", "connection", "stop", "status", "read_state", "complete"]);
});
it.each(["settled", "unsettled"] as const)("proves retirement independently of %s checkpoint reuse", async settlement => {
const { task } = provider();
const retained = vi.fn(async () => {});
const owner = createPluginRunnerTaskOwner({ task, projectIds: [], harness: "codex",
adoptIdentity: requirements.identity, onSessionRetained: retained });
const registration = await owner.registration({} as never, "run");
expect(owner.isRetired()).toBe(false);
await registration.release();
expect(owner.isRetired()).toBe(false);
await registration.checkpoint(settlement);
expect(owner.isRetired()).toBe(true);
expect(retained).toHaveBeenCalledTimes(settlement === "settled" ? 1 : 0);
expect(task.mock.calls.map(([op]) => op.kind)).toEqual(settlement === "settled"
? ["stop", "status", "read_state", "complete"] : ["stop", "status", "complete"]);
});
it.each(["credentials", "read_state", "complete"])("does not prove retirement when %s obligations fail after execution stops", async failingOperation => {
const { task } = provider();
const perform = task.getMockImplementation()!;
task.mockImplementation(async operation => {
if (operation.kind === failingOperation) throw new Error("handoff failed");
if (operation.kind === "credentials") return { kind: "credentials", taskId: "task", credential: null };
return perform(operation);
});
const owner = createPluginRunnerTaskOwner({ task, projectIds: [], harness: "codex", accountIdentity: "account",
adoptIdentity: requirements.identity, onCredentials: async () => {}, onSessionRetained: async () => {} });
const registration = await owner.registration({} as never, "run");
await registration.release();
await expect(registration.checkpoint("settled")).rejects.toThrow("handoff failed");
expect(await owner.driver.adoptExistingRunner!.isAlive()).toBe(false);
expect(owner.isRetired()).toBe(false);
});
it("submits only a prepared identity and configuration ID", async () => {
const { task } = provider();
const owner = createPluginRunnerTaskOwner({ projectAuthCheckVersion: 1, task, projectIds: [], harness: "codex", pollMs: 1 });
@@ -59,6 +59,7 @@ export function createPluginRunnerTaskOwner(input: {
let failReady!: (error: unknown) => void;
let confirmStopped: (code: number) => void = () => {};
let stoppedState: Extract<PluginEnvironmentTaskResult, { kind: "status" }> | undefined;
let completed = false;
let rejectFailure!: (error: unknown) => void;
const failure = new Promise<never>((_resolve, reject) => { rejectFailure = reject; });
void failure.catch(() => undefined);
@@ -284,7 +285,10 @@ export function createPluginRunnerTaskOwner(input: {
}
await input.onSessionRetained(state);
}
if (submitted) await call({ kind: "complete" });
if (submitted) {
await call({ kind: "complete" });
completed = true;
}
},
release: async (reason?: "detach") => {
registrationReleased = true;
@@ -328,6 +332,9 @@ export function createPluginRunnerTaskOwner(input: {
return bytes;
};
return { launcher, registration, driver, readWorkspaceFile,
// This is a lease-bound Launcher receipt, not a terminal phase or a sent
// stop request. Completion also proves credential/writeback obligations ran.
isRetired: () => stoppedState?.executionStopped === true && completed && !detached,
rebind(next: typeof input) {
if (!launched || stopRequested || stoppedState || detached || next.harness !== input.harness ||
input.accountIdentity !== next.accountIdentity || input.credentialIdentity !== next.credentialIdentity ||
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
import { chmod, writeFile, symlink, mkdir, open } from "node:fs/promises";
import { join, dirname } from "node:path";
import { and, eq } from "drizzle-orm";
import { agents, agentWakeupRequests, assets, documents, heartbeatRuns, issues, projects, routineDocuments, routines, runnerApiResponseReservations, companySkills, companyMemberships, principalPermissionGrants } from "@paperclipai/db";
import { agents, agentWakeupRequests, assets, documents, heartbeatRuns, issues, projects, routineDocuments, routines, runnerApiResponseReservations, companySkills, companySkillVersions, companySkillTestRuns, companyMemberships, principalPermissionGrants } from "@paperclipai/db";
import { initializeRunIdentity } from "../run-identity.js";
import { beforeAll, afterAll, beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { secretService } from "../secrets.js";
@@ -16,6 +16,7 @@ import { createLocalAgentJwt } from "../../agent-auth-jwt.js";
import { RUNNER_API_RESPONSE_MAX_BYTES, RUNNER_API_RESPONSE_RUN_MAX_BYTES } from "./runner-api-response-limits.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
import { prepareHeartbeatTaskContext } from "../heartbeat/task-context.js";
import { createHeartbeatRunPreparation } from "../heartbeat/run-preparation.js";
import { createAssignedMcpTools } from "./assigned-mcp-tools.js";
import type { ToolGatewayService } from "../tool-gateway.js";
import { RUNNER_TOOL_RESULT_MAX_BYTES } from "./runner-tool-results.js";
@@ -498,7 +499,8 @@ else if(m.id!==undefined) send({id:m.id,result:{}});
await prepareHeartbeatTaskContext(server.db, { run, agent,
task: { issueRef: issue, issueContext: null, wakeCommentContext: null, context, taskSession: null },
policy: { recovering: true, trustPreset: { kind: "standard" }, experimentalInstanceSettings: {} },
services: { issuesSvc: { getAncestors: async () => [] }, getPinnedSkillTestContext: async () => null },
services: { issuesSvc: { getAncestors: async () => [] },
getPinnedSkillTestContext: createHeartbeatRunPreparation(server.db).getPinnedSkillTestContext },
});
await server.db.update(heartbeatRuns).set({ contextSnapshot: context }).where(eq(heartbeatRuns.id, fixture.runId));
}
@@ -510,7 +512,17 @@ else if(m.id!==undefined) send({id:m.id,result:{}});
} }).where(eq(heartbeatRuns.id, fixture.runId));
await server.db.update(issues).set({ workMode: "skill_test" }).where(eq(issues.id, fixture.issueId));
await server.db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, fixture.issueId));
await expect(reprepareRun(fixture)).rejects.toThrow("The skill test's saved version is unavailable");
const [skill] = await server.db.insert(companySkills).values({ companyId: fixture.companyId,
key: `company/${fixture.companyId}/scope-test`, slug: "scope-test", name: "Scope test", markdown: "# Scope test", sourceType: "managed" }).returning();
const [version] = await server.db.insert(companySkillVersions).values({ companyId: fixture.companyId,
companySkillId: skill.id, revisionNumber: 1, fileInventory: [{ path: "SKILL.md", kind: "skill", content: "# Scope test" }] }).returning();
await server.db.insert(companySkillTestRuns).values({ companyId: fixture.companyId, skillId: skill.id,
skillVersionId: version.id, agentId: fixture.agentId, issueId: fixture.issueId, inputSnapshot: "Exercise the saved scope" });
await reprepareRun(fixture);
const [prepared] = await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.runId));
expect(prepared.contextSnapshot).toMatchObject({ paperclipAgentJwtScope: { kind: "skill_test", issueId: fixture.issueId },
paperclipSkillTest: { skillKey: skill.key, skillVersionId: version.id } });
const body = { kind: "secret", name: "parity/skill-test", value: "disposable-skill-test-value", justification: "Must remain denied" };
const token = createLocalAgentJwt(fixture.agentId, fixture.companyId, "paperclip_runner", fixture.runId,
fixture.responsibleUserId, { kind: "skill_test", issueId: fixture.issueId });
@@ -1,3 +1,4 @@
import { skillExecutionContent } from "../skill-execution-content.js";
import { withRuntimeAssetUse, removeRuntimeAssetTree } from "./runtime-asset-retention.js";
import { cachedSkillAsset, rememberSkillAsset } from "./skill-asset-cache.js";
import { verifyFileManifest, type FileManifestEntry } from "../file-manifest-cache.js";
@@ -11,7 +12,7 @@ import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { and, eq } from "drizzle-orm";
import { heartbeatRuns, type Db } from "@paperclipai/db";
import { companySkillVersions, heartbeatRuns, type Db } from "@paperclipai/db";
import type { PaperclipSkillEntry } from "@paperclipai/adapter-utils/server-utils";
import { isToolConnectionAttentionHealth } from "@paperclipai/shared";
import {
@@ -185,6 +186,54 @@ async function materializeInstructionBundle(db: Db, agent: RuntimeAgent, agentFi
return { entryPath, bundle: await materializeAsset(files) };
}
async function materializeSkillAsset(source: string) {
let bundle = await measureRunPhase("runtime_context.asset_verify", () => cachedSkillAsset(source));
if (!bundle) {
const files = await measureRunPhase("runtime_context.asset_read", () => collectDirectoryFiles(source));
bundle = await materializeAsset(files);
const manifest: FileManifestEntry[] = files.map(file => ({ path: file.path, size: file.content.length,
sha256: sha256(file.content), mode: file.mode & 0o555, executable: Boolean(file.mode & 0o111) }));
if (await verifyFileManifest(source, manifest)) rememberSkillAsset(source, manifest, bundle);
}
return bundle;
}
export async function rehydratePinnedSkillAssets(
db: Db,
binding: { companyId: string; runId: string; agentId: string },
context: NativeRuntimeContextSnapshot,
): Promise<NativeRuntimeContextSnapshot> {
if (!context.skills.some(skill => skill.versionId)) return context;
const [run] = await db.select({ responsibleUserId: heartbeatRuns.responsibleUserId,
contextSnapshot: heartbeatRuns.contextSnapshot }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.companyId, binding.companyId), eq(heartbeatRuns.id, binding.runId), eq(heartbeatRuns.agentId, binding.agentId)));
if (!run) throw new Error("Skill recovery has no matching admitted run");
const actor = { type: "agent" as const, companyId: binding.companyId, agentId: binding.agentId,
runId: binding.runId, source: "agent_jwt" as const,
onBehalfOfUserId: run.responsibleUserId === "local-board" ? null : run.responsibleUserId };
const delivery = skillExecutionContent(db, { actor });
const pins = run.contextSnapshot?.skillVersions;
const skills: NativeRuntimeContextSnapshot["skills"] = [];
for (const skill of context.skills) {
if (!skill.versionId) { skills.push(skill); continue; }
if (pins !== undefined && (!pins || typeof pins !== "object" || Array.isArray(pins)
|| (pins as Record<string, unknown>)[skill.key] !== skill.versionId)) {
throw new Error("Skill recovery differs from the admitted version selection");
}
const [version] = await db.select({ skillId: companySkillVersions.companySkillId }).from(companySkillVersions).where(and(
eq(companySkillVersions.companyId, binding.companyId), eq(companySkillVersions.id, skill.versionId)));
if (!version) throw new Error("Pinned skill version is unavailable for recovery");
const bundle = await delivery.withVersion({ companyId: binding.companyId, skillId: version.skillId, versionId: skill.versionId },
source => materializeSkillAsset(source));
if (bundle.digest !== skill.bundle.digest || bundle.manifestDigest !== skill.bundle.manifestDigest
|| bundle.fileCount !== skill.bundle.fileCount || bundle.totalBytes !== skill.bundle.totalBytes) {
throw new Error("Recovered skill projection does not match its admitted digest");
}
skills.push({ ...skill, bundle });
}
return { ...context, skills };
}
export async function materializeSelectedSkills(runtimeConfig: Record<string, unknown>, entries: PaperclipSkillEntry[], omitLegacy: boolean) {
const desiredKeys = resolvePaperclipDesiredSkillNames(runtimeConfig, entries).filter(
(key) => !omitLegacy || key !== PAPERCLIP_OPERATIONAL_SKILL_KEY,
@@ -194,16 +243,7 @@ export async function materializeSelectedSkills(runtimeConfig: Record<string, un
const entry = byKey.get(key);
if (!entry) throw new Error(`assigned runtime skill is missing from the company library: ${key}`);
if (entry.sourceStatus === "missing") throw new Error(entry.missingDetail ?? `assigned runtime skill source is missing: ${key}`);
let bundle = await measureRunPhase("runtime_context.asset_verify", () => cachedSkillAsset(entry.source));
if (!bundle) {
const files = await measureRunPhase("runtime_context.asset_read", () => collectDirectoryFiles(entry.source));
bundle = await materializeAsset(files);
const manifest: FileManifestEntry[] = files.map(file => ({ path: file.path, size: file.content.length,
sha256: sha256(file.content), mode: file.mode & 0o555, executable: Boolean(file.mode & 0o111) }));
// Populate the stat/digest cache against these exact source bytes before
// remembering the reference. A concurrent source edit cannot seed it.
if (await verifyFileManifest(entry.source, manifest)) rememberSkillAsset(entry.source, manifest, bundle);
}
const bundle = await materializeSkillAsset(entry.source);
return { key: entry.key, runtimeName: safeRelativePath(entry.runtimeName, `runtime name for ${key}`), versionId: entry.versionId ?? entry.currentVersionId ?? null, bundle };
}));
}
@@ -419,10 +419,16 @@ describe("resolveNativeRuntimeMode", () => {
it("rejects an explicit native profile outside the approved boundary", () => {
expect(resolveNativeRuntimeMode({ ...eligible, agent: { ...eligible.agent, adapterType: "claude_local" } }))
.toEqual(expect.objectContaining({ kind: "legacy", reason: "direct_adapter" }));
expect(() => resolveNativeRuntimeMode({ ...eligible, issue: { id: "issue", workMode: "skill_test" } }))
expect(() => resolveNativeRuntimeMode({ ...eligible, issue: { id: "issue", workMode: "unknown" } }))
.toThrow(NativeRuntimeEligibilityError);
});
it("admits Skills Studio tests on remote Runners", () => {
expect(resolveNativeRuntimeMode({ ...eligible,
issue: { id: "skill-test", workMode: "skill_test" }, target: { kind: "remote" },
})).toMatchObject({ kind: "native", profile: { backend: "codex_app_server" } });
});
it("admits remote targets only through paperclip_runner", () => {
expect(resolveNativeRuntimeMode({
...eligible,
@@ -156,11 +156,11 @@ export function resolveNativeRuntimeMode(input: {
"Paperclip Runner requires an active agent.",
);
}
const allowedWorkModes = ["standard", "planning", "ask"];
const allowedWorkModes = ["standard", "planning", "ask", "skill_test"];
if (!input.issue || !allowedWorkModes.includes(input.issue.workMode)) {
throw ineligible(
"paperclip_runner_issue_ineligible",
"Paperclip Runner requires a standard, planning, or ask task.",
"Paperclip Runner requires a standard, planning, ask, or skill test task.",
);
}
const rollout = resolveNativeMigrationStatus({
@@ -66,6 +66,7 @@ import {
resolveExternalChatResponseWaitAuthorizationInTransaction,
} from "./chat-attachment-reuse.js";
import { issueService } from "../issues.js";
import { companySkillService } from "../company-skills.js";
import { syncRoutineRunStatusForIssue } from "../routine-run-status.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { issueRecoveryActionService } from "../issue-recovery-actions.js";
@@ -2231,6 +2232,31 @@ export async function commitNativeStatusDecision(input: {
if (updated.originKind === "routine_execution") {
await syncRoutineRunStatusForIssue(tx as unknown as Db, input.issueId);
}
// Native task completion bypasses the REST issue route. Commit the Studio
// output with the authoritative task status so restart cannot lose it.
// The durable test row, not a mutable task mode, identifies the obligation.
if (issue.status !== updated.status && (updated.status === "done" || updated.status === "cancelled")) {
const testRun = await companySkillService(tx as unknown as Db).completeTestRunForIssue({
companyId: input.companyId,
issueId: input.issueId,
outcome: updated.status === "done" ? "succeeded" : "cancelled",
error: updated.status === "cancelled" ? "Harness issue was cancelled" : null,
});
if (testRun) {
const { publication } = await persistActivity(tx as unknown as Db, {
companyId: input.companyId,
actorType: "system",
actorId: "native-status-committer",
runId: input.runId,
action: "company.skill_test_run_completed",
entityType: "company_skill_test_run",
entityId: testRun.id,
issueId: input.issueId,
details: { issueId: input.issueId, status: testRun.status, outputDocumentKey: testRun.outputDocumentKey },
});
publications.push(publication);
}
}
materialized.unshift({
effectKind: "issue_status_projection",
targetType: "issue",
@@ -2,17 +2,17 @@ import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq, and } from "drizzle-orm";
import { activityLog, agents, authUsers, companyMemberships, companies, createDb, heartbeatRuns, issues, issueComments } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { startRecoveryPostgres } from "../../__tests__/helpers/recovery-postgres.js";
import { issueService } from "../issues.js";
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
describe("runner task reassignment", () => {
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let temporary: Awaited<ReturnType<typeof startRecoveryPostgres>>;
let db: ReturnType<typeof createDb>;
beforeAll(async () => {
temporary = await startEmbeddedPostgresTestDatabase("runner-reassign-");
temporary = await startRecoveryPostgres("runner-reassign-");
db = createDb(temporary.connectionString);
});
}, 60_000);
afterAll(async () => { await temporary?.cleanup(); });
async function fixture(status = "todo") {
@@ -183,7 +183,9 @@ describe("runner task reassignment", () => {
it.each(["planning", "ask", "skill_test"])("denies reassignment from %s mode", async workMode => {
const f = await fixture();
await db.update(issues).set({ workMode }).where(eq(issues.id, f.issueId));
await expect(f.authority.execute(f.call)).rejects.toThrow("mode_denied");
if (workMode === "skill_test") await expect(f.authority.execute(f.call)).rejects.toMatchObject({ status: 403 });
else await expect(f.authority.execute(f.call)).rejects.toThrow("mode_denied");
expect(f.stopTaskForReassignment).not.toHaveBeenCalled();
});
it("denies foreign targets and unavailable assignees", async () => {