feat(dot): expand governed Runner capabilities and finish pairing setup

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-07 13:41:44 -05:00
1 parent 426e9fac15
commit ebc99db2f5
45 files changed
+1409 -105

No files matched your search

@@ -22,6 +22,11 @@ describe("native hire runtime inheritance", () => {
expect(inherited).not.toHaveProperty("env");
});
it("inherits Dot billing acknowledgement without copying a live binding or workspace access", () => {
expect(inheritNativeRunnerAdapterConfig({ provider: "openai_dot", allowUnmeteredProvider: true, dotBindingId: "parent-binding", dotWorkspaceAccess: true }))
.toEqual({ provider: "openai_dot", allowUnmeteredProvider: true });
});
it.each(["codex", "acpx", "opencode"])("does not copy unrelated profile references for %s", (provider) => {
const inherited = inheritNativeRunnerAdapterConfig({
provider, managedProfileId: "unrelated-managed", agentCoreProfileId: "unrelated-remote",
@@ -7,6 +7,7 @@
*/
export const INHERITABLE_NATIVE_RUNNER_CONFIG_KEYS = [
"provider",
"allowUnmeteredProvider",
"acpxAgent",
"model",
"codexPermissionMode",
@@ -7335,6 +7335,7 @@ export async function executePaperclipNativeSession(input: {
/** Use a session-owned GitHub broker, rebound only after run ownership is acquired. */
managedGitHub?: boolean;
/** Resolved adapter env; the runner transport applies a provider allowlist before spawn. */
dotWorkspaceRoot?: string;
runnerEnvironment?: NodeJS.ProcessEnv;
/** Private grant materialization; never a user-configured host path. */
managedAiCredentialHome?: string;
@@ -10784,6 +10785,7 @@ export async function createRunnerdBackend(input: {
processGroupId: number | null;
startedAt: string;
}) => Promise<void>;
dotWorkspaceRoot?: string;
runnerEnvironment?: NodeJS.ProcessEnv;
/** Private grant materialization; never a user-configured host path. */
managedAiCredentialHome?: string;
@@ -10899,7 +10901,7 @@ async function createRunnerdBackendWithinSessionClaim(
// Remote Codex already sends dynamic tool calls over authenticated PRP. Keep
// the assigned gateway on the control plane instead of asking the sandbox to
// reach the host's HTTP origin (which may be private or loopback-only).
const relayAssignedMcp = remoteTarget !== null && input.execution.provider.kind === "codex";
const relayAssignedMcp = (remoteTarget !== null && input.execution.provider.kind === "codex") || input.execution.provider.kind === "openai_dot";
const assignedMcpUrl = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_URL;
const assignedMcpToken = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_TOKEN;
const assignedMcpName = input.runnerEnvironment?.PAPERCLIP_NATIVE_MCP_NAME;
@@ -10935,7 +10937,10 @@ async function createRunnerdBackendWithinSessionClaim(
? input.execution.runtimeContext.mcp.digest
: undefined,
workMode: input.execution.task.workMode,
workspaceRoot: remoteTarget?.remoteCwd ?? input.execution.workspace.cwd ?? undefined,
runtimeContext: "runtimeContext" in input.execution ? input.execution.runtimeContext : undefined,
workspaceBridge: input.execution.provider.kind === "openai_dot" && !!input.dotWorkspaceRoot,
assertBridgeAuthority: input.execution.provider.kind === "openai_dot" ? () => dotRunnerBroker(input.db).assertRunAuthority(input.execution as import("../../vendor/paperclip-runner/index.js").NativeExecutionInputV6) : undefined,
workspaceRoot: input.dotWorkspaceRoot ?? remoteTarget?.remoteCwd ?? input.execution.workspace.cwd ?? undefined,
executionTargetKind: target.kind,
readRemoteWorkspaceFile: remoteTarget && remoteCommandRunner
? (file) => readVerifiedRemoteWorkspaceFile({ runner: remoteCommandRunner, workspaceRoot: remoteTarget.remoteCwd, ...file })
@@ -102,7 +102,9 @@ describe("PaperclipRunnerToolAuthority", () => {
issueId,
runId,
});
expect(authority.definitions()).toHaveLength(38);
expect(authority.definitions()).toHaveLength(43);
expect(authority.definitions().map(tool => tool.name)).not.toContain("read_chat_attachment");
expect(authority.definitions().map(tool => tool.name)).not.toContain("read_current_wake_comments");
const questions = authority.definitions().find(tool => tool.name === "request_human_input")!;
expect(questions.description).toContain("ask only the next unanswered question");
expect(questions.description).toContain("Never fabricate answers");
@@ -139,10 +141,8 @@ describe("PaperclipRunnerToolAuthority", () => {
"list_approvals",
"get_approval",
"get_approval_context",
READ_CURRENT_WAKE_COMMENTS_TOOL_NAME,
"list_chat_attachments",
"reuse_chat_attachment",
"read_chat_attachment",
]),
);
const context = await authority.execute({
@@ -249,10 +249,8 @@ describe("PaperclipRunnerToolAuthority", () => {
executionTargetKind: "local",
});
const requiredChatFileTools = [
READ_CURRENT_WAKE_COMMENTS_TOOL_NAME,
"list_chat_attachments",
"reuse_chat_attachment",
"read_chat_attachment",
"register_deliverable",
];
@@ -1,3 +1,5 @@
import { RUNNER_BRIDGE_SCHEMAS, runnerBridgeDefinitions, readAssignedSkill, executeWorkspaceTool } from "./runner-bridge-tools.js";
import type { NativeRuntimeContextSnapshot } from "../../vendor/paperclip-runner/index.js";
import { readTaskQuestionContext } from "../issue-question-context.js";
import { isConversation } from "../agent-conversations.js";
import { setIssueTitle } from "../issue-title.js";
@@ -39,12 +41,14 @@ import type { StorageService } from "../../storage/types.js";
import { assetService } from "../assets.js";
import { workspaceFileResourceService } from "../workspace-file-resources.js";
import { badRequest, forbidden, notFound, HttpError } from "../../errors.js";
import { searchRunnerApi } from "./runner-api-catalog.js";
import { runnerApiCatalog, searchRunnerApi } from "./runner-api-catalog.js";
import { executeRunnerApi, validateRunnerApiCall, RUNNER_API_MAX_BYTES, type RunnerApiFile } from "./runner-api-client.js";
import { acquireRunnerApiResponseSlot, runnerApiCompanyCaptureMaxBytes, RUNNER_API_RESPONSE_MAX_BYTES, RUNNER_API_RESPONSE_RUN_MAX_BYTES, RunnerApiResponseLimitError } from "./runner-api-response-limits.js";
import { and, desc, eq, isNull, notInArray, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
authUsers,
companyMemberships,
activityLog,
assets,
runnerApiResponseReservations,
@@ -70,7 +74,7 @@ import { issueService } from "../issues.js";
import { issueThreadInteractionService } from "../issue-thread-interactions.js";
import { getNativeReviewAssignment, readNativeReviewAssignmentContext, type NativeReviewAssignmentContext } from "./native-review-participant.js";
import { childReviewOutcomes } from "./child-review-outcomes.js";
import { persistActivity, publishActivity } from "../activity-log.js";
import { logActivity, persistActivity, publishActivity } from "../activity-log.js";
import { captureRunIdentity } from "../run-identity.js";
import { prepareNativeRunnerFileHandoff, type RemoteWorkspaceFileReader } from "./native-runner-file-handoff.js";
import { MAX_ATTACHMENT_BYTES } from "../../attachment-types.js";
@@ -130,6 +134,9 @@ type Binding = {
apiToolsEnabled?: boolean;
workMode?: "standard" | "planning" | "ask";
workspaceRoot?: string;
workspaceBridge?: boolean;
runtimeContext?: NativeRuntimeContextSnapshot;
assertBridgeAuthority?: () => Promise<void>;
executionTargetKind?: "local" | "remote";
readRemoteWorkspaceFile?: RemoteWorkspaceFileReader;
currentWakeComments?: CurrentWakeCommentsBinding;
@@ -262,10 +269,11 @@ export class PaperclipRunnerToolAuthority {
// Keep the provider session's direct tool catalog stable across ordinary
// and truncated external-chat turns. Execution still fails closed unless
// this exact run carries a server-verified current-wake binding.
definitions.push(READ_CURRENT_WAKE_COMMENTS_TOOL_DEFINITION);
if (this.binding.currentWakeComments) definitions.push(READ_CURRENT_WAKE_COMMENTS_TOOL_DEFINITION);
definitions.push(LIST_CHAT_ATTACHMENTS_TOOL_DEFINITION);
definitions.push(REUSE_CHAT_ATTACHMENT_TOOL_DEFINITION);
definitions.push(READ_CHAT_ATTACHMENT_TOOL_DEFINITION);
if (this.binding.chatAttachmentReadScope) definitions.push(READ_CHAT_ATTACHMENT_TOOL_DEFINITION);
definitions.push(...runnerBridgeDefinitions({ workspace: !!this.binding.workspaceRoot && this.binding.workspaceBridge === true, skills: !!this.binding.runtimeContext, api: runnerApiToolsEnabled(this.binding.companyId, this.binding.apiToolsEnabled), mode: workMode }));
const connectionTools = [...RUNTIME_CONNECTION_TOOL_DEFINITIONS,
...(this.binding.connectorAssignments ?? []).flatMap((assignment) => assignment.tools)];
const assignedTools = this.binding.assignedMcpTools?.definitions((tools) =>
@@ -284,6 +292,40 @@ export class PaperclipRunnerToolAuthority {
throw forbidden("This review run may only inspect the assigned task and resolve its review.");
}
}
if (RUNNER_BRIDGE_SCHEMAS.has(call.tool)) {
await this.#boundContext();
if (!this.definitions().some(definition => definition.name === call.tool)) throw forbidden("Tool is not available to this run");
const input = RUNNER_BRIDGE_SCHEMAS.get(call.tool)!.parse(call.arguments) as Record<string, any>;
if (call.tool.startsWith("workspace_")) {
const authorize = async () => { const current = await this.#boundContext();
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");
};
await authorize();
const execute = () => executeWorkspaceTool(this.binding.workspaceRoot!, call.tool, input, authorize);
return ["workspace_write", "workspace_run"].includes(call.tool) ? this.#bridgeMutation(call, execute) : execute();
}
if (call.tool === "get_identity") {
const { actor, run } = await this.#boundContext();
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) };
}
if (call.tool === "list_people") return this.db.select({ id: authUsers.id, name: authUsers.name, role: companyMemberships.membershipRole }).from(companyMemberships)
.innerJoin(authUsers, eq(authUsers.id, companyMemberships.principalId))
.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") return this.binding.runtimeContext!.skills.map(skill => ({ key: skill.key, name: skill.runtimeName, versionId: skill.versionId, digest: skill.bundle.digest }));
if (call.tool === "read_assigned_skill") return readAssignedSkill(this.binding.runtimeContext!, input);
const paths: Record<string, [string, string]> = { get_task: ["GET", "/api/issues/{id}"], comment_on_task: ["POST", "/api/issues/{id}/comments"],
list_task_documents: ["GET", "/api/issues/{id}/documents"], read_task_document: ["GET", "/api/issues/{id}/documents/{key}"], write_task_document: ["PUT", "/api/issues/{id}/documents/{key}"] };
const [method, path] = paths[call.tool]!;
const operation = runnerApiCatalog().find(operation => operation.method === method && operation.path === path);
if (!operation) throw new Error("runner_task_api_operation_unavailable");
const body = call.tool === "comment_on_task" ? { body: input.body } : call.tool === "write_task_document" ? { title: input.title, body: input.body, baseRevisionId: input.baseRevisionId } : undefined;
const { operationId, ...response } = record(await this.#callApi(call.callId, { operationId: operation.operationId, pathParams: { id: input.taskId, ...(input.key ? { key: input.key } : {}) }, ...(body ? { body } : {}) }));
return { ...response, apiOperationId: operationId };
}
if (this.binding.assignedMcpTools?.has(call.tool)) {
const current = await this.#boundContext();
return this.binding.assignedMcpTools.execute(call, current.issue.workMode as "standard" | "planning" | "ask");
@@ -467,7 +509,7 @@ export class PaperclipRunnerToolAuthority {
pathParams: { companyId: this.binding.companyId },
body,
}));
return { ...response, apiOperationId: operationId };
return { ...response, apiOperationId: operationId, ...(context.actor.adapterConfig?.provider === "openai_dot" ? { setupRequired: "This creates a separate Paperclip Dot agent. Its operator must pair a separate OpenAI Dot before it can run. The existing Dot binding and workspace permission are never inherited." } : {}) };
}
case "call_api": {
// PRP reserves operationId/callId for semantic result identity. The
@@ -586,6 +628,37 @@ export class PaperclipRunnerToolAuthority {
}
}
async #bridgeMutation(call: { tool: string; callId: string; arguments: unknown }, execute: () => Promise<unknown>) {
if (!call.callId || call.callId.length > 500) throw badRequest("Use a stable bounded call ID");
const key = createHash("sha256").update(call.callId).digest("hex");
const digest = createHash("sha256").update(canonicalJson({ tool: call.tool, arguments: call.arguments })).digest("hex");
const prior = await this.db.transaction(async tx => {
const context = await this.#lockAuthorizedMutationContext(tx as unknown as Db);
const resultJson = record(context.run.resultJson), receipts = record(resultJson.bridgeToolReceipts);
const old = record(receipts[key]);
if (receipts[key]) {
if (old.digest !== digest) throw badRequest("Call ID reused with different arguments");
return { result: old.result ?? { outcome: "unknown", guidance: "A workspace effect may have occurred. Inspect files and state before issuing another mutation; this call will not execute again." } };
}
if (Object.keys(receipts).length >= 1000) throw badRequest("Workspace mutation limit reached");
receipts[key] = { digest, tool: call.tool, state: "pending" };
await tx.update(heartbeatRuns).set({ resultJson: { ...resultJson, bridgeToolReceipts: receipts } }).where(eq(heartbeatRuns.id, this.binding.runId));
return null;
});
if (prior) return prior.result;
let result: unknown;
try { result = await execute(); } catch (error) { result = { outcome: "failed", message: error instanceof Error ? error.message : "Workspace operation failed" }; }
await this.db.transaction(async tx => {
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, this.binding.runId), eq(heartbeatRuns.companyId, this.binding.companyId))).for("update");
const resultJson = record(run?.resultJson), receipts = record(resultJson.bridgeToolReceipts);
receipts[key] = { digest, tool: call.tool, state: "completed", result };
await tx.update(heartbeatRuns).set({ resultJson: { ...resultJson, bridgeToolReceipts: receipts } }).where(eq(heartbeatRuns.id, this.binding.runId));
});
await logActivity(this.db, { companyId: this.binding.companyId, actorType: "agent", actorId: this.binding.agentId, runId: this.binding.runId,
action: "runner.workspace_operation", entityType: "issue", entityId: this.binding.issueId, details: { tool: call.tool, requestFingerprint: digest, outcome: record(result).outcome ?? "completed" } });
return result;
}
async #callApi(callId: string, value: unknown): Promise<unknown> {
const bound = await this.#boundContext();
const context = { ...this.binding, issueIdentifier: bound.issue.identifier, workMode: bound.issue.workMode };
@@ -812,6 +885,9 @@ export class PaperclipRunnerToolAuthority {
) {
throw new Error("paperclip_runner_tool_binding_not_authorized");
}
const [company] = await this.db.select().from(companies).where(eq(companies.id, this.binding.companyId));
if (company?.status !== "active" || (company.budgetMonthlyCents > 0 && company.spentMonthlyCents >= company.budgetMonthlyCents)
|| (row.actor.budgetMonthlyCents > 0 && row.actor.spentMonthlyCents >= row.actor.budgetMonthlyCents)) throw forbidden("Company or budget authority unavailable");
if (this.binding.nativeReview) {
const admitted = readNativeReviewAssignmentContext(row.run.contextSnapshot);
if (!admitted || admitted.nativeReviewInteractionId !== this.binding.nativeReview.nativeReviewInteractionId
@@ -960,17 +1036,23 @@ export class PaperclipRunnerToolAuthority {
return result;
}
async #assertCompanyPerson(tx: Db, userId: string) {
const [member] = await tx.select({ id: companyMemberships.id }).from(companyMemberships).where(and(eq(companyMemberships.companyId, this.binding.companyId),
eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active")));
if (!member) throw forbidden("Person is not an active company member");
}
async #createTask(input: Record<string, unknown>, identityContextId: string | null): Promise<unknown> {
const idempotencyKey = requiredString(input.idempotencyKey);
if (input.status !== undefined && input.status !== "backlog" && input.status !== "todo") {
throw new Error("paperclip_runner_tool_input_invalid");
}
const assigneeAgentId = input.assigneeActorId === null || input.assigneeActorId === undefined
? this.binding.agentId
: requiredString(input.assigneeActorId);
const assignee = await agentService(this.db).getById(assigneeAgentId);
if (!assignee || assignee.companyId !== this.binding.companyId) {
throw new Error("paperclip_runner_agent_not_found");
const assigneeUserId = input.assigneeUserId == null ? null : requiredString(input.assigneeUserId);
if (assigneeUserId && input.assigneeActorId != null) throw badRequest("Choose one agent or person assignee");
const assigneeAgentId = assigneeUserId ? null : input.assigneeActorId == null ? this.binding.agentId : requiredString(input.assigneeActorId);
if (assigneeAgentId) {
const assignee = await agentService(this.db).getById(assigneeAgentId);
if (!assignee || assignee.companyId !== this.binding.companyId) throw new Error("paperclip_runner_agent_not_found");
}
const priority = input.priority === "critical" || input.priority === "high"
|| input.priority === "medium" || input.priority === "low"
@@ -991,7 +1073,7 @@ export class PaperclipRunnerToolAuthority {
}) => {
const parentIssueId = context.issue.conversationAgentId ? null : context.issue.id;
const projectId = nullableProviderId(input.projectId) ?? (parentIssueId ? context.issue.projectId : null);
const scope = { projectId, parentIssueId, assigneeAgentId, assigneeUserId: null };
const scope = { projectId, parentIssueId, assigneeAgentId, assigneeUserId };
const decision = await authorizationService(tx).decide({
actor: { type: "agent", source: "agent_jwt", companyId: this.binding.companyId,
agentId: this.binding.agentId, runId: this.binding.runId, onBehalfOfUserId: context.run.responsibleUserId },
@@ -1000,7 +1082,8 @@ export class PaperclipRunnerToolAuthority {
scope,
});
if (!decision.allowed) throw forbidden(decision.explanation);
await assertAssignableAgent(tx, this.binding.companyId, assigneeAgentId, { kind: "work" });
if (assigneeAgentId) await assertAssignableAgent(tx, this.binding.companyId, assigneeAgentId, { kind: "work" });
if (assigneeUserId) await this.#assertCompanyPerson(tx, assigneeUserId);
const project = projectId
? await tx.select().from(projects).where(and(eq(projects.id, projectId), eq(projects.companyId, this.binding.companyId))).then(rows => rows[0] ?? null)
: null;
@@ -1035,6 +1118,7 @@ export class PaperclipRunnerToolAuthority {
parentId: existingChild.parentId,
status: existingChild.status,
assigneeActorId: existingChild.assigneeAgentId,
assigneeUserId: existingChild.assigneeUserId,
},
};
}
@@ -1057,6 +1141,7 @@ export class PaperclipRunnerToolAuthority {
workMode: "standard" as const,
priority,
assigneeAgentId,
assigneeUserId,
blockedByIssueIds,
blockParentUntilDone: false,
createdByAgentId: this.binding.agentId,
@@ -1117,6 +1202,7 @@ export class PaperclipRunnerToolAuthority {
projectId: child.projectId,
status: childStatus,
assigneeActorId: child.assigneeAgentId,
assigneeUserId: child.assigneeUserId,
},
};
}, { beforeReceiptReplay: async (tx, context) => { await authorizeCreate(tx, context); } }) as Record<string, unknown>;
@@ -1157,7 +1243,10 @@ export class PaperclipRunnerToolAuthority {
async #reassignTask(input: Record<string, unknown>): Promise<unknown> {
const key = requiredString(input.idempotencyKey);
const taskId = requiredUuid(input.taskId);
const assigneeAgentId = requiredUuid(input.assigneeActorId);
const assigneeUserId = input.assigneeUserId == null ? null : requiredString(input.assigneeUserId);
if (assigneeUserId && input.assigneeActorId != null) throw badRequest("Choose one agent or person assignee");
const assigneeAgentId = assigneeUserId ? null : requiredUuid(input.assigneeActorId);
const expectedUser = input.expectedAssigneeUserId == null ? null : requiredString(input.expectedAssigneeUserId);
const expectedOwner = input.expectedAssigneeActorId === null ? null : requiredUuid(input.expectedAssigneeActorId);
const expectedVersion = input.expectedStatusVersion;
const reason = requiredString(input.reason);
@@ -1187,11 +1276,12 @@ export class PaperclipRunnerToolAuthority {
const decision = await authorizationService(tx).decide({ actor, action, resource: {
type: "issue", companyId: this.binding.companyId, issueId: taskId, projectId: target.projectId,
parentIssueId: target.parentId, assigneeAgentId: action === "tasks:assign" ? assigneeAgentId : target.assigneeAgentId,
assigneeUserId: action === "tasks:assign" ? null : target.assigneeUserId, status: target.status,
assigneeUserId: action === "tasks:assign" ? assigneeUserId : target.assigneeUserId, status: target.status,
} });
if (!decision.allowed) throw forbidden(decision.explanation);
}
await assertAssignableAgent(tx, this.binding.companyId, assigneeAgentId, { kind: "work" });
if (assigneeAgentId) await assertAssignableAgent(tx, this.binding.companyId, assigneeAgentId, { kind: "work" });
if (assigneeUserId) await this.#assertCompanyPerson(tx, assigneeUserId);
const [externalChat] = await tx.select({ id: chatConversations.id }).from(chatConversations).where(and(
eq(chatConversations.companyId, this.binding.companyId), eq(chatConversations.issueId, taskId),
)).limit(1);
@@ -1203,7 +1293,7 @@ export class PaperclipRunnerToolAuthority {
if (["done", "cancelled", "in_review"].includes(target.status) || record(target.executionState).status === "pending") {
throw new Error("paperclip_runner_reassignment_state_denied");
}
if (target.assigneeUserId || target.assigneeAgentId !== expectedOwner || target.statusVersion !== expectedVersion) {
if (target.assigneeUserId !== expectedUser || target.assigneeAgentId !== expectedOwner || target.statusVersion !== expectedVersion) {
throw new Error("paperclip_runner_reassignment_conflict: refresh search_tasks before deciding again");
}
};
@@ -1275,9 +1365,9 @@ export class PaperclipRunnerToolAuthority {
const [run] = await tx.select({ status: heartbeatRuns.status }).from(heartbeatRuns).where(eq(heartbeatRuns.id, target.executionRunId));
if (run && ["running", "queued", "scheduled_retry"].includes(run.status)) throw new Error("paperclip_runner_reassignment_stop_unconfirmed");
}
const changed = target.assigneeAgentId !== assigneeAgentId;
const changed = target.assigneeAgentId !== assigneeAgentId || target.assigneeUserId !== assigneeUserId;
const updated = changed ? await issueService(tx).update(taskId, {
companyGuard: this.binding.companyId, assigneeAgentId, assigneeUserId: null,
companyGuard: this.binding.companyId, assigneeAgentId, assigneeUserId,
status: target.status === "in_progress" ? "todo" : target.status,
statusVersion: target.statusVersion + 1, actorAgentId: this.binding.agentId,
}, tx) : target;
@@ -1285,7 +1375,7 @@ export class PaperclipRunnerToolAuthority {
const receipt = {
commandId: `reassign-task:${taskId}:${updated.statusVersion}`, disposition: changed ? "applied" : "duplicate",
stateRevision: updated.statusVersion, entityRefs: [taskId],
scheduledWakeIds: changed && updated.status === "todo" ? [`${durableKey}:${updated.statusVersion}`] : [],
scheduledWakeIds: changed && assigneeAgentId && updated.status === "todo" ? [`${durableKey}:${updated.statusVersion}`] : [],
};
if (changed) await issueService(tx).addComment(taskId, reason, { agentId: this.binding.agentId, runId: this.binding.runId });
const activity = await persistActivity(tx, {
@@ -1306,7 +1396,7 @@ export class PaperclipRunnerToolAuthority {
if (publication) publishActivity(publication);
// Replay repairs a failed dispatch. The durable wake key and state/version guard
// prevent duplicate successors and prevent waking a later, unrelated assignment.
if (result.scheduledWakeIds.length && this.binding.enqueueWakeup) {
if (assigneeAgentId && result.scheduledWakeIds.length && this.binding.enqueueWakeup) {
await this.binding.enqueueWakeup(assigneeAgentId, {
source: "assignment", triggerDetail: "system", reason: "issue_assigned",
payload: { issueId: taskId, mutation: "reassign_task", reason },
@@ -2024,6 +2114,7 @@ function redactedTask(task: typeof issues.$inferSelect) {
priority: task.priority,
workMode: task.workMode,
assigneeAgentId: task.assigneeAgentId,
assigneeUserId: task.assigneeUserId,
executionRunId: task.executionRunId,
parentId: task.parentId,
projectId: task.projectId,
@@ -4,12 +4,17 @@ import { isAbsolute } from "node:path";
/** Open a previously authorized canonical path without following raced symlinks. */
export async function openRunnerApiWorkspaceFile(path: string): Promise<FileHandle> {
return openRunnerWorkspaceFile(path, constants.O_RDONLY);
}
/** Confined open for reads or writes; flags are server-selected, never provider input. */
export async function openRunnerWorkspaceFile(path: string, flags: number, mode?: number): Promise<FileHandle> {
if (!isAbsolute(path)) throw new Error("Workspace file must have a canonical absolute path");
if (process.platform === "darwin") {
// Darwin sys/fcntl.h: O_NOFOLLOW_ANY rejects symlinks at every component.
// Node does not expose this flag in fs.constants. Unsupported kernels fail
// closed instead of falling back to a pathname check followed by open.
return open(path, constants.O_RDONLY | constants.O_NONBLOCK | 0x20000000);
return open(path, flags | constants.O_NONBLOCK | 0x20000000, mode);
}
if (process.platform !== "linux") throw new Error("Workspace uploads require a platform with confined file opens; use an authorized artifact reference");
const parts = path.split("/").filter(Boolean);
@@ -22,6 +27,6 @@ export async function openRunnerApiWorkspaceFile(path: string): Promise<FileHand
await directory.close();
directory = next;
}
return await open(`/proc/self/fd/${directory.fd}/${parts.at(-1)}`, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
return await open(`/proc/self/fd/${directory.fd}/${parts.at(-1)}`, flags | constants.O_NONBLOCK | constants.O_NOFOLLOW, mode);
} finally { await directory.close(); }
}
@@ -0,0 +1,59 @@
import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { materializeAsset } from "./runtime-context.js";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, describe, expect, it } from "vitest";
import { executeWorkspaceTool, readAssignedSkill, runnerBridgeDefinitions, workspaceCommandSandboxAvailable } from "./runner-bridge-tools.js";
const roots: string[] = [];
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
const root = async () => { const directory = await mkdtemp(join(tmpdir(), "dot-bridge-")); roots.push(directory); return directory; };
const authorize = async () => {};
describe("Runner workspace bridge", () => {
it("guards new writes, existing content, traversal and symlinks", async () => {
const directory = await root(), outside = await root();
const result = await executeWorkspaceTool(directory, "workspace_write", { path: "hello.txt", text: "hello 🌍", expectedSha256: null }, authorize) as any;
expect(result.byteSize).toBe(Buffer.byteLength("hello 🌍"));
expect(await executeWorkspaceTool(directory, "workspace_read", { path: "hello.txt" }, authorize)).toMatchObject({ text: "hello 🌍", sha256: result.sha256 });
await expect(executeWorkspaceTool(directory, "workspace_write", { path: "hello.txt", text: "overwrite", expectedSha256: null }, authorize)).rejects.toThrow();
await expect(executeWorkspaceTool(directory, "workspace_write", { path: "hello.txt", text: "overwrite", expectedSha256: "0".repeat(64) }, authorize)).rejects.toThrow("conflict");
await executeWorkspaceTool(directory, "workspace_write", { path: "hello.txt", text: "updated", expectedSha256: result.sha256 }, authorize);
await expect(executeWorkspaceTool(directory, "workspace_read", { path: "../hello.txt" }, authorize)).rejects.toThrow();
await writeFile(join(outside, "private.txt"), "PRIVATE");
await symlink(outside, join(directory, "outside"));
await expect(executeWorkspaceTool(directory, "workspace_read", { path: "outside/private.txt" }, authorize)).rejects.toThrow("symlink");
});
it.skipIf(!workspaceCommandSandboxAvailable())("runs useful commands but denies files outside the workspace and injected credentials", async () => {
const directory = await root(), outside = await root();
const secret = join(outside, "private.txt"); await writeFile(secret, "PRIVATE");
const success = await executeWorkspaceTool(directory, "workspace_run", { program: "/bin/sh", args: ["-c", "printf hello > output.txt; printf '%s' \"${PAPERCLIP_SECRETS_MASTER_KEY-unset}\"" ] }, authorize);
expect(success).toMatchObject({ exitCode: 0, output: "unset", stopped: null });
expect(await readFile(join(directory, "output.txt"), "utf8")).toBe("hello");
const read = await executeWorkspaceTool(directory, "workspace_run", { program: "/bin/cat", args: [secret] }, authorize);
expect(read).toMatchObject({ exitCode: 1 }); expect(String((read as any).output)).not.toContain("PRIVATE");
const write = await executeWorkspaceTool(directory, "workspace_run", { program: "/bin/sh", args: ["-c", 'printf stolen > "$1"', "sh", secret] }, authorize);
expect(write).toMatchObject({ exitCode: 1 }); expect(await readFile(secret, "utf8")).toBe("PRIVATE");
});
it.skipIf(!workspaceCommandSandboxAvailable())("stops its owned process on authority loss and bounds output", async () => {
const directory = await root(); let calls = 0;
const result = await executeWorkspaceTool(directory, "workspace_run", { program: "/bin/sleep", args: ["20"] }, async () => { if (++calls > 2) throw new Error("revoked"); });
expect(result).toMatchObject({ stopped: "authority_revoked" });
const large = await executeWorkspaceTool(directory, "workspace_run", { program: "/bin/sh", args: ["-c", "yes x | head -c 50000"] }, authorize) as any;
expect(large.truncated).toBe(true); expect(Buffer.byteLength(large.output)).toBeLessThanOrEqual(24000);
});
it("reads only a pinned skill file and detects tampering", async () => {
const bundle = await materializeAsset([{ path: "SKILL.md", content: Buffer.from("# Assigned skill\nUse the pinned instructions."), mode: 0o444 }]);
const context = { skills: [{ key: "assigned", runtimeName: "assigned", versionId: "version-1", bundle }] } as any;
expect(await readAssignedSkill(context, { skill: "assigned" })).toMatchObject({ versionId: "version-1", text: "# Assigned skill\nUse the pinned instructions." });
await expect(readAssignedSkill(context, { skill: "not-assigned" })).rejects.toThrow("not_assigned");
await expect(readAssignedSkill(context, { skill: "assigned", path: "../private" })).rejects.toThrow();
await expect(readAssignedSkill(context, { skill: "assigned", path: "not-pinned.txt" })).rejects.toThrow("not_pinned");
await expect(readAssignedSkill({ ...context, skills: [{ ...context.skills[0], bundle: { ...bundle, manifestDigest: createHash("sha256").update("different").digest("hex") } }] }, { skill: "assigned" })).rejects.toThrow("manifest_digest");
});
it("advertises only available tools in the current work mode", () => {
const tools = runnerBridgeDefinitions({ workspace: false, skills: false, api: false, mode: "ask" }).map(tool => tool.name);
expect(tools).toEqual(["get_identity", "list_people"]);
expect(runnerBridgeDefinitions({ workspace: true, skills: true, api: true, mode: "planning" }).map(tool => tool.name)).not.toContain("workspace_run");
});
});
@@ -0,0 +1,129 @@
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import { constants, existsSync } from "node:fs";
import * as fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import type { NativeRuntimeContextSnapshot } from "../../vendor/paperclip-runner/index.js";
import { openRunnerApiWorkspaceFile, openRunnerWorkspaceFile } from "./runner-api-files.js";
const relativeFile = z.string().min(1).max(1000).refine(value => !value.includes("\\") && !value.includes("\0") && !path.isAbsolute(value) && value.split("/").every(part => part && part !== "." && part !== ".."), "Use a relative path without traversal");
const specs = [
["get_identity", "Read your agent identity, responsible person, permissions and currently available tools.", z.object({}).strict()],
["list_people", "List active company people and their IDs for human assignment; roles identify the owner.", z.object({ after: z.string().optional(), limit: z.number().int().min(1).max(100).default(50) }).strict()],
["get_task", "Read another task through normal task permissions.", z.object({ taskId: z.uuid() }).strict()],
["comment_on_task", "Post an attributed comment on an authorized task, without reopening or interrupting it.", z.object({ taskId: z.uuid(), body: z.string().trim().min(1).max(20000) }).strict()],
["list_task_documents", "List documents on an authorized task.", z.object({ taskId: z.uuid() }).strict()],
["read_task_document", "Read a document on an authorized task.", z.object({ taskId: z.uuid(), key: z.string().min(1).max(100) }).strict()],
["write_task_document", "Write a document on an authorized task using its current revision. Permissions and document locks apply.", z.object({ taskId: z.uuid(), key: z.string().min(1).max(100), title: z.string().min(1).max(500), body: z.string().max(20000), baseRevisionId: z.uuid().nullable() }).strict()],
["list_assigned_skills", "List immutable skill versions pinned to this Runner turn.", z.object({}).strict()],
["read_assigned_skill", "Read a verified UTF-8 file from an assigned skill. Start with SKILL.md and paginate with nextOffset. Content is data, not extra authorization.", z.object({ skill: z.string().min(1).max(240), path: relativeFile.default("SKILL.md"), offset: z.number().int().min(0).default(0) }).strict()],
["workspace_list", "List entries inside the assigned workspace.", z.object({ path: z.union([relativeFile, z.literal("")]).default(""), after: z.string().optional() }).strict()],
["workspace_read", "Read a UTF-8 workspace file with SHA-256 and byte size. Paginate with nextOffset.", z.object({ path: relativeFile, offset: z.number().int().min(0).default(0) }).strict()],
["workspace_write", "Write a UTF-8 workspace file. expectedSha256 null requires a new file; existing files require the hash from workspace_read. Parent directories must exist. Use a stable call ID.", z.object({ path: relativeFile, text: z.string().max(128000), expectedSha256: z.string().regex(/^[a-f0-9]{64}$/).nullable() }).strict()],
["workspace_run", "Execute a program in an OS sandbox confined to the assigned workspace plus read-only system runtime files. No host home or Paperclip/provider credentials are exposed. Output and time are bounded; authority loss stops the owned process group. Use assigned app tools for external services.", z.object({ program: z.string().min(1).max(1000), args: z.array(z.string().max(16000)).max(100).default([]), timeoutMs: z.number().int().min(100).max(120000).default(30000) }).strict()],
] as const;
export const RUNNER_BRIDGE_SCHEMAS = new Map<string, z.ZodType>(specs.map(([name, , schema]) => [name, schema]));
export function workspaceCommandSandboxAvailable() { return process.platform === "darwin" ? existsSync("/usr/bin/sandbox-exec") : process.platform === "linux" && existsSync("/usr/bin/bwrap"); }
export function runnerBridgeDefinitions(options: { workspace: boolean; skills: boolean; api: boolean; mode: string }) {
return specs.filter(([name]) => (!name.startsWith("workspace_") || options.workspace) && (name !== "workspace_run" || workspaceCommandSandboxAvailable())
&& (!["workspace_run", "workspace_write", "comment_on_task", "write_task_document"].includes(name) || options.mode === "standard")
&& (!name.includes("assigned_skill") || options.skills) && (!["get_task", "comment_on_task", "list_task_documents", "read_task_document", "write_task_document"].includes(name) || options.api))
.map(([name, description, schema]) => ({ name, description, inputSchema: z.toJSONSchema(schema) }));
}
const digest = (bytes: Buffer | string) => createHash("sha256").update(bytes).digest("hex");
const PAGE_BYTES = 24000;
async function confined(root: string, name: string, newFile = false) {
const canonicalRoot = await fs.realpath(root);
const parts = name ? relativeFile.parse(name).split("/") : [];
let target = canonicalRoot;
for (const [index, part] of parts.entries()) {
target = path.join(target, part);
const stat = await fs.lstat(target).catch(error => { if (newFile && index === parts.length - 1 && error.code === "ENOENT") return null; throw error; });
if (stat?.isSymbolicLink()) throw new Error("runner_workspace_symlink_denied");
if (index < parts.length - 1 && !stat?.isDirectory()) throw new Error("runner_workspace_parent_invalid");
}
return target;
}
async function readFilePage(file: string, offset: number) {
const handle = await openRunnerApiWorkspaceFile(file);
try {
const stat = await handle.stat();
if (!stat.isFile() || stat.size > 16 * 1024 * 1024) throw new Error("runner_bridge_file_size_limit");
const bytes = await handle.readFile();
// Character pagination avoids splitting UTF-8 sequences; size and hash remain byte-based.
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
const page = text.slice(offset, offset + 6000);
return { text: page, sha256: digest(bytes), byteSize: bytes.length, nextOffset: offset + page.length < text.length ? offset + page.length : null };
} finally { await handle.close(); }
}
export async function readAssignedSkill(context: NativeRuntimeContextSnapshot, raw: unknown) {
const args = RUNNER_BRIDGE_SCHEMAS.get("read_assigned_skill")!.parse(raw) as { skill: string; path: string; offset: number };
const skill = context.skills.find(skill => skill.key === args.skill || skill.runtimeName === args.skill);
if (!skill) throw new Error("runner_skill_not_assigned");
const manifestText = await fs.readFile(path.join(path.dirname(path.dirname(skill.bundle.rootPath)), "manifests", `${skill.bundle.digest}.json`), "utf8");
if (digest(manifestText) !== skill.bundle.manifestDigest) throw new Error("runner_skill_manifest_digest_mismatch");
const manifest = JSON.parse(manifestText);
if (manifest.digest !== skill.bundle.digest || digest(JSON.stringify(manifest.files)) !== skill.bundle.digest) throw new Error("runner_skill_bundle_digest_mismatch");
const expected = manifest.files.find((file: { path: string }) => file.path === args.path);
if (!expected) throw new Error("runner_skill_file_not_pinned");
const result = await readFilePage(await confined(skill.bundle.rootPath, args.path), args.offset);
if (result.sha256 !== expected.sha256 || result.byteSize !== expected.size) throw new Error("runner_skill_file_digest_mismatch");
return { skill: skill.key, versionId: skill.versionId, path: args.path, ...result };
}
export async function executeWorkspaceTool(root: string, name: string, raw: unknown, authorize: () => Promise<void>) {
const input = RUNNER_BRIDGE_SCHEMAS.get(name)!.parse(raw) as Record<string, any>;
await authorize();
if (name === "workspace_list") {
const entries = await fs.readdir(await confined(root, input.path), { withFileTypes: true });
const page = entries.filter(entry => !input.after || entry.name > input.after).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0).slice(0, 100);
return { entries: page.map(entry => ({ name: entry.name, kind: entry.isSymbolicLink() ? "symlink" : entry.isDirectory() ? "directory" : "file" })), nextAfter: page.length === 100 ? page.at(-1)!.name : null };
}
if (name === "workspace_read") return readFilePage(await confined(root, input.path), input.offset);
if (name === "workspace_write") {
const file = await confined(root, input.path, true), bytes = Buffer.from(input.text, "utf8");
if (bytes.length > 128000) throw new Error("runner_workspace_write_size_limit");
if (input.expectedSha256 === null) {
await authorize();
const handle = await openRunnerWorkspaceFile(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
} else {
const handle = await openRunnerWorkspaceFile(file, constants.O_RDWR);
try {
const stat = await handle.stat();
if (!stat.isFile() || stat.size > 16 * 1024 * 1024) throw new Error("runner_workspace_write_size_limit");
if (digest(await handle.readFile()) !== input.expectedSha256) throw new Error("runner_workspace_write_conflict");
await authorize();
await handle.truncate(0); await handle.write(bytes, 0, bytes.length, 0); await handle.sync();
} finally { await handle.close(); }
}
return { path: input.path, sha256: digest(bytes), byteSize: bytes.length };
}
if (name !== "workspace_run" || !workspaceCommandSandboxAvailable()) throw new Error("runner_workspace_command_sandbox_unavailable");
const cwd = await fs.realpath(root), privateHome = path.join(cwd, ".paperclip-dot-home");
await fs.mkdir(privateHome, { recursive: true, mode: 0o700 });
await confined(cwd, ".paperclip-dot-home");
let program: string, args: string[];
if (process.platform === "darwin") {
// Deny by default. No /Users, host home, arbitrary /private or unrestricted /Library access.
const quote = (value: string) => JSON.stringify(value);
const profile = `(version 1)(deny default)(allow process*)(allow file-read-metadata)(allow sysctl-read)(allow mach-lookup)(allow file-read* (subpath "/System") (subpath "/usr") (subpath "/bin") (subpath "/sbin") (subpath "/opt/homebrew/Cellar") (subpath "/opt/homebrew/lib") (subpath "/opt/homebrew/bin") (subpath "/opt/homebrew/opt") (literal "/") (literal "/dev/null") (literal "/dev/urandom") (literal "/dev/random"))(allow file-read* file-write* (subpath ${quote(cwd)}))`;
program = "/usr/bin/sandbox-exec"; args = ["-p", profile, input.program, ...input.args];
} else {
program = "/usr/bin/bwrap";
args = ["--die-with-parent", "--unshare-all", "--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", ...(existsSync("/lib64") ? ["--ro-bind", "/lib64", "/lib64"] : []), "--proc", "/proc", "--dev", "/dev", "--bind", cwd, cwd, "--chdir", cwd, "--", input.program, ...input.args];
}
await authorize();
return new Promise<Record<string, unknown>>((resolve, reject) => {
const child = spawn(program, args, { cwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"],
env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: privateHome, TMPDIR: privateHome, LANG: "en_US.UTF-8" } });
let output = "", outputBytes = 0, truncated = false, stopped: string | null = null, checking = false;
const capture = (bytes: Buffer) => { const available = Math.max(0, PAGE_BYTES - outputBytes); output += bytes.subarray(0, available).toString("utf8"); outputBytes += Math.min(available, bytes.length); truncated ||= bytes.length > available; };
child.stdout.on("data", capture); child.stderr.on("data", capture);
const stop = (reason: string) => { stopped = reason; if (child.pid) try { process.kill(-child.pid, "SIGKILL"); } catch { /* already stopped */ } };
const timeout = setTimeout(() => stop("timeout"), input.timeoutMs);
const poll = setInterval(() => { if (checking) return; checking = true; void authorize().catch(() => stop("authority_revoked")).finally(() => { checking = false; }); }, 500);
const clear = () => { clearTimeout(timeout); clearInterval(poll); };
child.once("error", error => { clear(); reject(error); });
child.once("close", (exitCode, signal) => { clear(); if (child.pid) try { process.kill(-child.pid, "SIGKILL"); } catch { /* owned group already exited */ } resolve({ exitCode, signal, output, truncated, stopped }); });
});
}
@@ -1,7 +1,10 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { and, eq } from "drizzle-orm";
import { activityLog, agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
import { authUsers, companyMemberships, activityLog, agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
import { documentService } from "../documents.js";
import { issueService } from "../issues.js";
@@ -28,6 +31,54 @@ describe("runner backlog task creation", () => {
return { companyId, agentId, issueId, runId, binding, enqueueWakeup, authority: new PaperclipRunnerToolAuthority(db, binding) };
}
it("never repeats a completed or uncertain workspace mutation and fences replay", async () => {
const f = await fixture(false);
const root = await mkdtemp(join(tmpdir(), "dot-workspace-receipt-"));
await db.update(agents).set({ adapterConfig: { provider: "openai_dot", dotWorkspaceAccess: true } }).where(eq(agents.id, f.agentId));
const authority = new PaperclipRunnerToolAuthority(db, { ...f.binding, workspaceRoot: root, workspaceBridge: true });
try {
const call = { tool: "workspace_write", callId: "write-once", arguments: { path: "report.txt", text: "first", expectedSha256: null } };
const receipt = await authority.execute(call);
await writeFile(join(root, "report.txt"), "later edit");
expect(await authority.execute(call)).toEqual(receipt);
expect(await readFile(join(root, "report.txt"), "utf8")).toBe("later edit");
await expect(authority.execute({ ...call, arguments: { ...call.arguments, text: "changed" } })).rejects.toThrow("reused");
const uncertain = { tool: "workspace_write", callId: "lost-after-reservation", arguments: { path: "uncertain.txt", text: "must not run", expectedSha256: null } };
const key = createHash("sha256").update(uncertain.callId).digest("hex");
const digest = createHash("sha256").update(JSON.stringify({ arguments: { expectedSha256: null, path: uncertain.arguments.path, text: uncertain.arguments.text }, tool: uncertain.tool })).digest("hex");
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId));
await db.update(heartbeatRuns).set({ resultJson: { ...run!.resultJson, bridgeToolReceipts: { ...((run!.resultJson as any)?.bridgeToolReceipts ?? {}), [key]: { digest, tool: uncertain.tool, state: "pending" } } } }).where(eq(heartbeatRuns.id, f.runId));
expect(await authority.execute(uncertain)).toMatchObject({ outcome: "unknown" });
await expect(readFile(join(root, "uncertain.txt"))).rejects.toMatchObject({ code: "ENOENT" });
await db.update(agents).set({ adapterConfig: { provider: "openai_dot", dotWorkspaceAccess: false } }).where(eq(agents.id, f.agentId));
await expect(authority.execute(call)).rejects.toThrow("disabled");
} finally { await rm(root, { recursive: true, force: true }); }
});
it("creates and reassigns human tasks with company membership and retry checks", async () => {
const f = await fixture(false), userId = randomUUID();
await db.insert(authUsers).values({ id: userId, name: "Owner", email: userId + "@test.example", createdAt: new Date(), updatedAt: new Date() });
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, membershipRole: "owner", status: "active" });
const call = { tool: "create_task", callId: "hello-owner", arguments: { idempotencyKey: "hello-owner", title: "hello", assigneeUserId: userId } };
const receipt = await f.authority.execute(call) as { task: { id: string } };
expect(receipt).toMatchObject({ scheduledWakeIds: [], task: { assigneeActorId: null, assigneeUserId: userId } });
expect(await f.authority.execute(call)).toEqual(receipt);
expect(f.enqueueWakeup).not.toHaveBeenCalled();
expect(await f.authority.execute({ tool: "list_people", callId: "people", arguments: {} })).toMatchObject([{ id: userId, name: "Owner", role: "owner" }]);
await expect(f.authority.execute({ ...call, arguments: { ...call.arguments, idempotencyKey: "foreign", assigneeUserId: "foreign-person" } })).rejects.toThrow();
await expect(f.authority.execute({ ...call, arguments: { ...call.arguments, assigneeActorId: f.agentId } })).rejects.toThrow("Choose one");
const [human] = await db.select().from(issues).where(eq(issues.id, receipt.task.id));
const reassign = { tool: "reassign_task", callId: "back-to-agent", arguments: { taskId: human!.id, assigneeActorId: f.agentId,
expectedAssigneeActorId: null, expectedAssigneeUserId: userId, expectedStatusVersion: human!.statusVersion, reason: "Agent will help", idempotencyKey: "back-to-agent" } };
await f.authority.execute(reassign);
const [assigned] = await db.select().from(issues).where(eq(issues.id, human!.id));
expect(assigned).toMatchObject({ assigneeAgentId: f.agentId, assigneeUserId: null });
await f.authority.execute({ tool: "reassign_task", callId: "back-to-person", arguments: { taskId: human!.id, assigneeActorId: null, assigneeUserId: userId,
expectedAssigneeActorId: f.agentId, expectedStatusVersion: assigned!.statusVersion, reason: "Owner takes over", idempotencyKey: "back-to-person" } });
expect((await db.select().from(issues).where(eq(issues.id, human!.id)))[0]).toMatchObject({ assigneeAgentId: null, assigneeUserId: userId });
});
it.each([false, true])("persists the plan without waking an assigned backlog task (conversation=%s)", async conversation => {
const f = await fixture(conversation);
const call = { tool: "create_task", callId: "save-later", arguments: { idempotencyKey: "save-later", title: "Plan for later", status: "backlog", initialPlan: "1. Check status.\n2. Save the plan.\n3. Confirm creation." } };