mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Share the external Runner lifecycle and add strict Muse contracts (#16444)
Add the external Runner foundation with distinct Dot/Grok V6, canonical home-mode V7 and Muse V8 contracts. Keep generated schemas and profiles synchronized. Bound default question-answer command IDs in a disjoint namespace while preserving existing IDs and durable replay. Preserve canonical migration history and add normally generated, replay-safe Muse schema milestones with literal preview-history fixtures. Refs #16444. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
4de6335c3b
commit
2cc7992369
95 files changed
+12594
-1600
No files matched your search
@@ -60,6 +60,7 @@ describe("instance settings service", () => {
|
||||
enableAgentChat: false,
|
||||
enablePublicMcp: false,
|
||||
enableOpenAiDot: false,
|
||||
enableMuse: false,
|
||||
enableGrokBot: false,
|
||||
enableCombinedInboxTasks: false,
|
||||
enableChatConnectors: false,
|
||||
|
||||
@@ -311,6 +311,10 @@ describe("redaction", () => {
|
||||
providerValue: input.nested.providerValue,
|
||||
},
|
||||
});
|
||||
expect(sanitizeRecord({ schema: "paperclip.native-execution-input.v7", runtimeSchema: "paperclip.native-execution-input.v8" })).toEqual({
|
||||
schema: "paperclip.native-execution-input.v7",
|
||||
runtimeSchema: "paperclip.native-execution-input.v8",
|
||||
});
|
||||
expect(sanitizeRecord({ schema: "paperclip.native-execution-input.v4", runtimeSchema: "paperclip.native-model-envelope.v2" })).toEqual({
|
||||
schema: "paperclip.native-execution-input.v4",
|
||||
runtimeSchema: "paperclip.native-model-envelope.v2",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, heartbeatRuns, dotRunnerAssignments, externalAgentHolds, museRunnerAssignments } from "@paperclipai/db";
|
||||
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { conflict } from "../../errors.js";
|
||||
export type ExternalAdmissionTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
/** Acquire only after the queue's issue/wake/run locks. Generation/stop operations acquire this guard before binding locks. */
|
||||
export async function withExternalAdmissionGuard<T>(tx: ExternalAdmissionTransaction, companyId: string, agentId: string, action: () => Promise<T>): Promise<T> {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`paperclip:external-admission:${companyId}:${agentId}`},0))`);
|
||||
return action();
|
||||
}
|
||||
export async function assertNoExternalOverlap(tx: ExternalAdmissionTransaction, companyId: string, agentId: string): Promise<void> {
|
||||
const [hold] = await tx.select({ id: externalAgentHolds.id }).from(externalAgentHolds).where(and(eq(externalAgentHolds.companyId,companyId),eq(externalAgentHolds.agentId,agentId),isNull(externalAgentHolds.releasedAt),or(eq(externalAgentHolds.workerUnknown,true),eq(externalAgentHolds.nativeEffectsUnknown,true)))).limit(1);
|
||||
const [muse] = await tx.select({ id:museRunnerAssignments.id }).from(museRunnerAssignments).where(and(eq(museRunnerAssignments.companyId,companyId),eq(museRunnerAssignments.agentId,agentId),inArray(museRunnerAssignments.status,["offered","claimed","accepted"]))).limit(1);
|
||||
const [dot] = await tx.select({ id:dotRunnerAssignments.id }).from(dotRunnerAssignments).where(and(eq(dotRunnerAssignments.companyId,companyId),eq(dotRunnerAssignments.agentId,agentId),inArray(dotRunnerAssignments.status,["offered","accepted"]))).limit(1);
|
||||
const [claimed] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId,companyId),eq(heartbeatRuns.agentId,agentId),eq(heartbeatRuns.status,"running"),or(inArray(heartbeatRuns.driverKind,["openai_dot_mcp","grok_bot_mcp","muse_external"]),sql`exists (select 1 from ${agents} where ${agents.id} = ${agentId}::uuid and ${agents.companyId} = ${companyId}::uuid and ${agents.adapterConfig}->>'provider' in ('openai_dot','grok_bot','muse'))`))).limit(1);
|
||||
if (hold || muse || dot || claimed) throw conflict("External work is live or unresolved for this agent. Reconcile the exact assignment before starting another run.", { code: "external_agent_overlap" });
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export const PAPERCLIP_PUBLIC_SCHEMA_IDS = new Set([
|
||||
"paperclip.native-execution-input.v4",
|
||||
"paperclip.native-execution-input.v5",
|
||||
"paperclip.native-execution-input.v7",
|
||||
"paperclip.native-execution-input.v8",
|
||||
"paperclip.native-finalization.v1",
|
||||
"paperclip.native-finalization.v2",
|
||||
"paperclip.native-harness-backup-stamp.v1",
|
||||
|
||||
@@ -130,6 +130,34 @@ describe.skipIf(!support.supported)("heartbeat environment selection boundary",
|
||||
expect(result.requestedReusableExecutionWorkspaceConfig).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["muse", "openai_dot", "grok_bot"] as const)("keeps %s managed placement without adopting the task's selected workspace", async provider => {
|
||||
const { input, issue, workspace } = await fixture();
|
||||
const source = await workspace();
|
||||
const [sandbox] = await db.insert(environments).values({ name: `External controller ${provider}`, driver: "sandbox", status: "active", config: { provider: "fake" } }).returning();
|
||||
input.agent.adapterType = "paperclip_runner";
|
||||
input.agent.adapterConfig = { provider };
|
||||
input.agent.defaultEnvironmentId = sandbox.id;
|
||||
input.task.issueRef!.executionWorkspaceId = source.id;
|
||||
input.task.issueRef!.projectWorkspaceId = randomUUID();
|
||||
input.task.boundSourceWorkspace = source;
|
||||
input.task.workspaceProjectContext = { hasWorkspace: true };
|
||||
input.task.hasSelectedWorkspaceSource = true;
|
||||
input.task.taskWorkspaceIntent = { version: 1, source: "explicit", selection: { kind: "task_directory" } };
|
||||
await db.update(issues).set({ originKind: "chat_channel" }).where(eq(issues.id, issue.id));
|
||||
const read = vi.spyOn(input.services.executionWorkspacesSvc, "getStoredById").mockRejectedValue(new Error("Task source must remain inaccessible"));
|
||||
const select = vi.spyOn(input.services.executionWorkspacesSvc, "validateSelection").mockRejectedValue(new Error("Task selection must remain inaccessible"));
|
||||
try {
|
||||
const result = await selectHeartbeatEnvironment(db, input);
|
||||
expect(result).toMatchObject({ isDotRun: true, selectedEnvironmentId: sandbox.id,
|
||||
existingExecutionWorkspace: null, reusableExistingExecutionWorkspace: null,
|
||||
nativeChatWorkspaceScope: null, useIsolatedTaskDirectory: false, explicitTaskDirectory: false });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(select).not.toHaveBeenCalled();
|
||||
expect(input.services.findSharedWorkspaceHolder).not.toHaveBeenCalled();
|
||||
expect(input.task.issueRef!.executionWorkspaceId).toBe(source.id);
|
||||
} finally { read.mockRestore(); select.mockRestore(); }
|
||||
});
|
||||
|
||||
it("uses the low-trust task sandbox even when workspace feature gates hide issue settings", async () => {
|
||||
const { input, issue } = await fixture();
|
||||
const [sandbox] = await db.insert(environments).values({ name: "Task sandbox", driver: "sandbox", status: "active", config: { provider: "fake" } }).returning();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { heartbeatExternalProvider, nativeExecutionUsesTaskWorkspace } from "./external-provider.js";
|
||||
import { heartbeatRuns, issues, type Db, type agents } from "@paperclipai/db";
|
||||
import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings, ProjectExecutionWorkspacePolicy } from "@paperclipai/shared";
|
||||
import { parseObject } from "../../adapters/utils.js";
|
||||
@@ -97,11 +98,11 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
persistedRunnerProfile.nativeExecutionInput,
|
||||
)
|
||||
: null;
|
||||
const isDotRun = persistedNativeExecutionInput?.provider.kind === "openai_dot"
|
||||
|| (!persistedNativeExecutionInput && agent.adapterType === "paperclip_runner" && parseObject(agent.adapterConfig).provider === "openai_dot");
|
||||
// This retained name covers both external bridges; neither adopts task files.
|
||||
const isDotRun = heartbeatExternalProvider({ run, agent }) !== null;
|
||||
const persistedNativeExecutionWorkspaceId =
|
||||
persistedNativeExecutionInput?.binding.executionWorkspaceId ?? null;
|
||||
const requestedExecutionWorkspaceId =
|
||||
const requestedExecutionWorkspaceId = isDotRun ? null :
|
||||
persistedNativeExecutionWorkspaceId ??
|
||||
readNonEmptyString(issueRef?.executionWorkspaceId);
|
||||
const existingExecutionWorkspace = requestedExecutionWorkspaceId
|
||||
@@ -115,7 +116,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
const workspaceReuseRequest =
|
||||
resolveExecutionWorkspaceReuseRequestForIssue({
|
||||
issueExecutionWorkspaceId: requestedExecutionWorkspaceId,
|
||||
issueExecutionWorkspacePreference: nativeRecoveryExecutionWorkspaceId
|
||||
issueExecutionWorkspacePreference: isDotRun ? null : nativeRecoveryExecutionWorkspaceId
|
||||
? "reuse_existing"
|
||||
: (issueRef?.executionWorkspacePreference ?? null),
|
||||
existingExecutionWorkspaceStatus:
|
||||
@@ -129,7 +130,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
: null;
|
||||
const workspaceAuthorizationActor = { type: "agent" as const, agentId: agent.id, companyId: agent.companyId,
|
||||
source: "agent_jwt" as const, runId: run.id, onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId };
|
||||
if (!persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
|
||||
if (!isDotRun && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
|
||||
const workspaceRequiringAccess = reusableExistingExecutionWorkspace ?? boundSourceWorkspace;
|
||||
if (workspaceRequiringAccess) {
|
||||
await assertTaskWorkspaceAccess(db, workspaceAuthorizationActor, agent.companyId, workspaceRequiringAccess.id, { write: true, issueId });
|
||||
@@ -253,13 +254,13 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
: selectedEnvironmentId
|
||||
? await environmentsSvc.getById(selectedEnvironmentId)
|
||||
: null;
|
||||
const nativeChatWorkspaceScope = await findNativeChatWorkspaceScope(db, {
|
||||
const nativeChatWorkspaceScope = isDotRun ? null : await findNativeChatWorkspaceScope(db, {
|
||||
adapterType: agent.adapterType,
|
||||
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
issueId,
|
||||
admittedCwd: persistedNativeExecutionInput && persistedNativeExecutionInput.schema !== "paperclip.native-execution-input.v6"
|
||||
admittedCwd: nativeExecutionUsesTaskWorkspace(persistedNativeExecutionInput)
|
||||
? persistedNativeExecutionInput.workspace.cwd : null,
|
||||
});
|
||||
let nativeChatExpectedCwd = nativeChatWorkspaceScope
|
||||
@@ -271,8 +272,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
: null;
|
||||
if (
|
||||
nativeChatWorkspaceScope &&
|
||||
persistedNativeExecutionInput &&
|
||||
persistedNativeExecutionInput.schema !== "paperclip.native-execution-input.v6" &&
|
||||
nativeExecutionUsesTaskWorkspace(persistedNativeExecutionInput) &&
|
||||
!nativeChatWorkspaceMatches({
|
||||
scope: nativeChatWorkspaceScope,
|
||||
expectedCwd: nativeChatExpectedCwd,
|
||||
@@ -352,7 +352,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
// project or issue settings request serialization. Sandbox protection still uses
|
||||
// the existing holder staleness and workspace_busy retry ladder.
|
||||
if (
|
||||
issueRef?.projectWorkspaceId &&
|
||||
!isDotRun && issueRef?.projectWorkspaceId &&
|
||||
effectiveExecutionWorkspaceMode === "shared_workspace"
|
||||
) {
|
||||
const workspaceHolder = await findSharedWorkspaceHolder({
|
||||
@@ -433,14 +433,14 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron
|
||||
);
|
||||
}
|
||||
}
|
||||
if (taskWorkspaceIntent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) {
|
||||
if (!isDotRun && taskWorkspaceIntent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) {
|
||||
await executionWorkspacesSvc.validateSelection({ companyId: agent.companyId,
|
||||
actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, runId: run.id, source: "agent_jwt", onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId },
|
||||
selection: taskWorkspaceIntent.selection, issueId,
|
||||
preserveImplicitDefaultSettings: ["project", "task_default"].includes(taskWorkspaceIntent.source) });
|
||||
}
|
||||
const explicitTaskDirectory = taskWorkspaceIntent?.selection.kind === "task_directory";
|
||||
const useIsolatedTaskDirectory = issueRef !== null && shouldUseIsolatedTaskDirectory({
|
||||
const explicitTaskDirectory = !isDotRun && taskWorkspaceIntent?.selection.kind === "task_directory";
|
||||
const useIsolatedTaskDirectory = !isDotRun && issueRef !== null && shouldUseIsolatedTaskDirectory({
|
||||
trustPreset: trustPreset.kind,
|
||||
environmentDriver: selectedEnvironmentForConfig?.driver ?? null,
|
||||
mode: requestedExecutionWorkspaceMode,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildNativeExecutionInput } from "../native-runtime/native-execution-input.js";
|
||||
import { nativeRuntimeContextFixture } from "../native-runtime/runtime-context.test-fixture.js";
|
||||
import { heartbeatExternalProvider, nativeRuntimeHasInstructionFilesystem, nativeExecutionUsesTaskWorkspace } from "./external-provider.js";
|
||||
|
||||
function execution(provider: "muse" | "openai_dot" | "grok_bot" | "codex") {
|
||||
const companyId = randomUUID(), agentId = randomUUID();
|
||||
const binding = { companyId, agentId, bindingId: randomUUID(), bindingGeneration: 1,
|
||||
acceptByUnixMs: Date.now() + 60_000, expiresAtUnixMs: Date.now() + 600_000 };
|
||||
return buildNativeExecutionInput({ companyId, agentId, runId: randomUUID(), provider,
|
||||
...(provider === "muse" ? { museBinding: binding } : provider === "openai_dot" || provider === "grok_bot" ? { dotBinding: binding } : {}),
|
||||
issue: { id: randomUUID(), identifier: "EXT-1", title: "Bound task", description: null, workMode: "standard" },
|
||||
taskPrompt: "Complete the task", normalizedSessionId: randomUUID(),
|
||||
workspace: { id: randomUUID(), cwd: "/controller", repoUrl: null, repoRef: null, branchName: null },
|
||||
completionContract: { id: randomUUID(), sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
|
||||
contract: { revision: "1", objective: "Complete the task", criteria: [{ id: "objective", requirement: "Complete the task" }] } },
|
||||
runtimeContext: nativeRuntimeContextFixture(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("shared external heartbeat classification", () => {
|
||||
it.each(["muse", "openai_dot", "grok_bot"] as const)("keeps %s external on fresh admission and after a configured provider change", provider => {
|
||||
const agent = { adapterType: "paperclip_runner", adapterConfig: { provider } };
|
||||
expect(heartbeatExternalProvider({ agent, run: { runtimeMode: "legacy", runnerProfileJson: null } })).toBe(provider);
|
||||
const admitted = execution(provider);
|
||||
expect(admitted.schema).toBe(provider === "muse" ? "paperclip.native-execution-input.v8" : "paperclip.native-execution-input.v6");
|
||||
expect(admitted.provider.kind).toBe(provider);
|
||||
expect(admitted.session.driverKind).toBe(provider === "muse" ? "muse_external" : provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp");
|
||||
expect(heartbeatExternalProvider({ agent: { ...agent, adapterConfig: { provider: "codex" } },
|
||||
run: { runtimeMode: "native", runnerProfileJson: { nativeExecutionInput: admitted } } })).toBe(provider);
|
||||
expect(nativeRuntimeHasInstructionFilesystem(admitted.session.driverKind)).toBe(false);
|
||||
expect(nativeExecutionUsesTaskWorkspace(admitted)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not turn an admitted local provider into an external bridge after configuration changes", () => {
|
||||
expect(heartbeatExternalProvider({ agent: { adapterType: "paperclip_runner", adapterConfig: { provider: "muse" } },
|
||||
run: { runtimeMode: "native", runnerProfileJson: { nativeExecutionInput: execution("codex") } } })).toBeNull();
|
||||
expect(nativeRuntimeHasInstructionFilesystem("codex_app_server")).toBe(true);
|
||||
expect(nativeExecutionUsesTaskWorkspace(execution("codex"))).toBe(true);
|
||||
expect(heartbeatExternalProvider({ agent: { adapterType: "process", adapterConfig: { provider: "muse" } },
|
||||
run: { runtimeMode: "legacy", runnerProfileJson: null } })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { parseObject } from "../../adapters/utils.js";
|
||||
import { parseNativeExecutionInput, type NativeExecutionInput } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
/** Provider placement is distinct from access to a task's workspace. */
|
||||
export function heartbeatExternalProvider(input: {
|
||||
run: { runtimeMode: string | null; runnerProfileJson: unknown };
|
||||
agent: { adapterType: string; adapterConfig: unknown };
|
||||
}): "openai_dot" | "grok_bot" | "muse" | null {
|
||||
const persisted = input.run.runtimeMode === "native"
|
||||
? parseObject(input.run.runnerProfileJson).nativeExecutionInput : undefined;
|
||||
const provider = persisted !== undefined
|
||||
? parseNativeExecutionInput(persisted).provider.kind
|
||||
: input.agent.adapterType === "paperclip_runner" ? parseObject(input.agent.adapterConfig).provider : null;
|
||||
return provider === "openai_dot" || provider === "grok_bot" || provider === "muse" ? provider : null;
|
||||
}
|
||||
|
||||
export function nativeRuntimeHasInstructionFilesystem(backend: string): boolean {
|
||||
return !["claude_managed_agents_api", "aws_agentcore_harness_api", "openai_dot_mcp", "grok_bot_mcp", "muse_external"].includes(backend);
|
||||
}
|
||||
|
||||
type TaskWorkspaceExecution = Exclude<NativeExecutionInput, {
|
||||
schema: "paperclip.native-execution-input.v6" | "paperclip.native-execution-input.v8";
|
||||
}>;
|
||||
|
||||
export function nativeExecutionUsesTaskWorkspace(execution: NativeExecutionInput | null): execution is TaskWorkspaceExecution {
|
||||
return execution !== null && execution.schema !== "paperclip.native-execution-input.v6"
|
||||
&& execution.schema !== "paperclip.native-execution-input.v8";
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { workspaceOperationService } from "../workspace-operations.js";
|
||||
import { nativeWorkspaceSyncInternals, prepareNativeWorkspaceSync } from "../native-runtime/native-workspace-sync.js";
|
||||
import { createHeartbeatWorkspaceResolver, prepareProjectRepositoryWorkspaces, resolveExecutionWorkspaceReuseRequestForIssue } from "./workspaces.js";
|
||||
import { prepareHeartbeatWorkspace, type HeartbeatWorkspacePreparationInput } from "./workspace-preparation.js";
|
||||
import { buildNativeExecutionInput } from "../native-runtime/native-execution-input.js";
|
||||
import { nativeRuntimeContextFixture } from "../native-runtime/runtime-context.test-fixture.js";
|
||||
import * as managedRepositoryCheckout from "../managed-repository-checkout.js";
|
||||
import { isolatedRemoteSourcePath } from "../isolated-remote-workspace-source.js";
|
||||
import { cleanupExecutionWorkspaceArtifacts } from "../workspace-runtime.js";
|
||||
@@ -105,6 +107,47 @@ describe.skipIf(!support.supported)("heartbeat workspace preparation boundary",
|
||||
input.run = run;
|
||||
}
|
||||
|
||||
it.each(["muse", "openai_dot", "grok_bot"] as const)("uses private %s controller storage without rebinding task files or preparing repositories", async provider => {
|
||||
const { input, issue } = await fixture();
|
||||
input.agent.adapterType = "paperclip_runner";
|
||||
input.config.mergedConfig = { provider, cwd: "/task/source/must-not-be-read", workspaceStrategy: { type: "git_worktree" } };
|
||||
input.reuse.isDotRun = true;
|
||||
const resolve = vi.spyOn(input.services, "resolveWorkspaceForRun").mockRejectedValue(new Error("Task files must not be adopted"));
|
||||
const bind = vi.spyOn(input.services.executionWorkspacesSvc, "bindTaskWorkspace").mockRejectedValue(new Error("Task binding must not change"));
|
||||
const repos = vi.spyOn(input.services.executionWorkspacesSvc, "prepareTaskRepositoriesForAdmission").mockRejectedValue(new Error("Repositories must not be prepared"));
|
||||
try {
|
||||
const result = await prepareHeartbeatWorkspace(db, input);
|
||||
expect(result.executionWorkspace.cwd).toContain(provider === "muse" ? "muse-controllers" : "dot-controllers");
|
||||
expect(result.persistedExecutionWorkspace).toBeNull();
|
||||
expect(result.projectRepositoryPaths).toEqual([]);
|
||||
expect((await readIssue(issue.id)).executionWorkspaceId).toBeNull();
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
expect(bind).not.toHaveBeenCalled();
|
||||
expect(repos).not.toHaveBeenCalled();
|
||||
const binding = { companyId: input.agent.companyId, agentId: input.agent.id, bindingId: input.run.id,
|
||||
bindingGeneration: 1, acceptByUnixMs: Date.now() + 60_000, expiresAtUnixMs: Date.now() + 600_000 };
|
||||
const admitted = buildNativeExecutionInput({ companyId: input.agent.companyId, agentId: input.agent.id,
|
||||
runId: input.run.id, provider, ...(provider === "muse" ? { museBinding: binding } : { dotBinding: binding }),
|
||||
issue: { id: issue.id, identifier: null, title: issue.title, description: null, workMode: "standard" },
|
||||
taskPrompt: "Preserve the admitted controller", normalizedSessionId: input.run.id,
|
||||
workspace: { id: input.run.id, cwd: result.executionWorkspace.cwd, repoUrl: null, repoRef: null, branchName: null },
|
||||
completionContract: { id: input.run.id, sha256: `sha256:${"a".repeat(64)}`, schemaVersion: "paperclip.run-result.v1",
|
||||
contract: { revision: "1", objective: "Preserve the controller", criteria: [{ id: "objective", requirement: "Preserve the original checkpoint path" }] } },
|
||||
runtimeContext: nativeRuntimeContextFixture(),
|
||||
});
|
||||
input.reuse.persistedNativeExecutionInput = admitted;
|
||||
input.run.runtimeMode = "native";
|
||||
input.run.runnerProfileJson = { nativeExecutionInput: admitted };
|
||||
input.config.mergedConfig = { provider: "codex" };
|
||||
const recovered = await prepareHeartbeatWorkspace(db, input);
|
||||
expect(recovered.executionWorkspace.cwd).toBe(result.executionWorkspace.cwd);
|
||||
expect(recovered.persistedExecutionWorkspace).toBeNull();
|
||||
expect(bind).not.toHaveBeenCalled();
|
||||
expect(repos).not.toHaveBeenCalled();
|
||||
|
||||
} finally { resolve.mockRestore(); bind.mockRestore(); repos.mockRestore(); }
|
||||
});
|
||||
|
||||
it("persists the workspace and issue binding before acquiring its environment", async () => {
|
||||
const { input, source, issue, envOrchestrator } = await fixture();
|
||||
const acquire = envOrchestrator.acquireForRun;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { nativeExecutionUsesTaskWorkspace } from "./external-provider.js";
|
||||
import { createHeartbeatPreparationTiming } from "./preparation-timing.js";
|
||||
import {
|
||||
resolveWorkspaceAfterLowTrustPreflight,
|
||||
@@ -247,8 +248,8 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
resolveWorkspace: async () => {
|
||||
if (isDotRun) {
|
||||
// This is private controller storage, never a provider filesystem.
|
||||
// v6 projects workspace.access=none and cwd=null to Dot.
|
||||
const cwd = path.resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", "dot-controllers", agent.companyId, run.id);
|
||||
// External providers receive workspace.access=none and cwd=null.
|
||||
const cwd = path.resolve(resolvePaperclipInstanceRoot(), "runtime", "paperclip-runner", (persistedNativeExecutionInput?.provider.kind ?? mergedConfig.provider) === "muse" ? "muse-controllers" : "dot-controllers", agent.companyId, run.id);
|
||||
await fs.mkdir(cwd, { recursive: true, mode: 0o700 });
|
||||
return { cwd, source: "agent_home" as const, projectId: null, workspaceId: null, repoUrl: null, repoRef: null,
|
||||
workspaceHints: [], warnings: [], baseCwdFallback: false, materializationFailures: [], additionalWorkspaces: [], referencedProjectFailures: [] };
|
||||
@@ -682,7 +683,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
const resolvedProjectWorkspaceId =
|
||||
resolvedWorkspaceReusePolicy.shouldRestoreExistingWorkspace && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"
|
||||
? reusableExistingExecutionWorkspace.projectWorkspaceId
|
||||
: issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
|
||||
: isDotRun ? null : issueRef?.projectWorkspaceId ?? resolvedWorkspace.workspaceId ?? null;
|
||||
let persistedExecutionWorkspace: ExecutionWorkspace | null = null;
|
||||
let issueExecutionWorkspaceIdForRun =
|
||||
issueRef?.executionWorkspaceId ?? null;
|
||||
@@ -703,7 +704,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
const bindIssueToPersistedExecutionWorkspace = async (
|
||||
workspace: ExecutionWorkspace | null,
|
||||
) => {
|
||||
if (!issueId || !workspace || nativeRecoveryExecutionWorkspaceId) {
|
||||
if (isDotRun || !issueId || !workspace || nativeRecoveryExecutionWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
const nextIssueWorkspaceMode =
|
||||
@@ -968,7 +969,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
}
|
||||
await bindIssueToPersistedExecutionWorkspace(persistedExecutionWorkspace);
|
||||
const projectRepositoryPaths: string[] = [];
|
||||
if (persistedExecutionWorkspace && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
|
||||
if (!isDotRun && persistedExecutionWorkspace && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
|
||||
const taskRepositories = await timeWorkspace("workspace_repository_admission", () => executionWorkspacesSvc.prepareTaskRepositoriesForAdmission({ companyId: agent.companyId,
|
||||
issueId, workspaceId: persistedExecutionWorkspace!.id, cwd: executionWorkspace.cwd,
|
||||
agentId: agent.id, runId: run.id, responsibleUserId }));
|
||||
@@ -1178,7 +1179,7 @@ async function prepareHeartbeatWorkspaceLocked(db: Db, input: HeartbeatWorkspace
|
||||
}
|
||||
const cwd = reference
|
||||
? await readNativeWorkspaceSyncLocalCwd({ companyId: agent.companyId, runId: run.id, workspaceId, reference })
|
||||
: persistedNativeExecutionInput && persistedNativeExecutionInput.schema !== "paperclip.native-execution-input.v6"
|
||||
: nativeExecutionUsesTaskWorkspace(persistedNativeExecutionInput)
|
||||
&& executionTarget?.kind !== "remote"
|
||||
? persistedNativeExecutionInput.workspace.cwd
|
||||
: executionWorkspace.cwd;
|
||||
|
||||
@@ -235,6 +235,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
enableMcpAggregators: true,
|
||||
enablePublicMcp: parsed.data.enablePublicMcp ?? false,
|
||||
enableOpenAiDot: parsed.data.enableOpenAiDot ?? false,
|
||||
enableMuse: parsed.data.enableMuse ?? false,
|
||||
enableGrokBot: parsed.data.enableGrokBot ?? false,
|
||||
enableChatConnectors: parsed.data.enableChatConnectors ?? false,
|
||||
enableGitHubReviewBots: parsed.data.enableGitHubReviewBots ?? false,
|
||||
@@ -285,6 +286,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
||||
enableMcpAggregators: true,
|
||||
enablePublicMcp: false,
|
||||
enableOpenAiDot: false,
|
||||
enableMuse: false,
|
||||
enableGrokBot: false,
|
||||
enableChatConnectors: false,
|
||||
enableGitHubReviewBots: false,
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
NativeCodexApprovalPolicy,
|
||||
NativeExecutionInputV5,
|
||||
NativeExecutionInputV6,
|
||||
NativeExecutionInputV8,
|
||||
MuseBindingSnapshot,
|
||||
DotBindingSnapshot,
|
||||
NativeExecutionInputV7,
|
||||
NativeCompletionSource,
|
||||
@@ -72,7 +74,8 @@ export interface BuildNativeExecutionInput {
|
||||
branchName: string | null;
|
||||
};
|
||||
normalizedSessionId: string | null;
|
||||
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot" | "grok_bot";
|
||||
provider?: "codex" | "opencode" | "claude_managed" | "aws_agentcore" | "acpx" | "openai_dot" | "grok_bot" | "muse";
|
||||
museBinding?: MuseBindingSnapshot;
|
||||
dotBinding?: DotBindingSnapshot;
|
||||
acpxAgent?: NativeAcpxAgent;
|
||||
homeMode?: "isolated" | "preconfigured";
|
||||
@@ -110,10 +113,11 @@ export interface BuildNativeExecutionInput {
|
||||
};
|
||||
runtimeContext: NativeRuntimeContextSnapshot;
|
||||
}
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "muse"; museBinding: MuseBindingSnapshot }): NativeExecutionInputV8;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider: "openai_dot" | "grok_bot"; dotBinding: DotBindingSnapshot }): NativeExecutionInputV6;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot"> }): NativeExecutionInputV5 | NativeExecutionInputV7;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 {
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput & { provider?: Exclude<BuildNativeExecutionInput["provider"], "openai_dot" | "grok_bot" | "muse"> }): NativeExecutionInputV5 | NativeExecutionInputV7;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8;
|
||||
export function buildNativeExecutionInput(input: BuildNativeExecutionInput): NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8 {
|
||||
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");
|
||||
}
|
||||
@@ -333,11 +337,15 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
credentialBindings: [],
|
||||
runtimeContext: input.runtimeContext,
|
||||
};
|
||||
return parseNativeExecutionInput(["openai_dot", "grok_bot"].includes(input.provider ?? "") ? { ...prepared,
|
||||
return parseNativeExecutionInput(input.provider === "muse" ? { ...prepared,
|
||||
schema: "paperclip.native-execution-input.v8", provider: { kind: "muse", model: null, binding: input.museBinding, bridgeRevision: "muse-v1" },
|
||||
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { ...prepared.session, driverKind: "muse_external", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }
|
||||
} : ["openai_dot", "grok_bot"].includes(input.provider ?? "") ? { ...prepared,
|
||||
schema: "paperclip.native-execution-input.v6", provider: { kind: input.provider, model: null, binding: input.dotBinding },
|
||||
workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null },
|
||||
session: { ...prepared.session, driverKind: input.provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp", lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null } }
|
||||
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7;
|
||||
} : prepared) as NativeExecutionInputV5 | NativeExecutionInputV6 | NativeExecutionInputV7 | NativeExecutionInputV8;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { activityLog, agents, companies, createDb, environmentLeases, environments, heartbeatRuns, issueRecoveryActions, issues, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { activityLog, agents, companies, createDb, environmentLeases, environments, externalAgentHolds, heartbeatRuns, issueRecoveryActions, issues, nativeRunFinalizations } from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { getExecutionBlocker } from "../execution-blocker.js";
|
||||
import { markExecutionReconciliation, validateExecutionReconciliation } from "../execution-recovery-resolution.js";
|
||||
@@ -76,23 +76,27 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes
|
||||
eq(activityLog.action, "issue.workspace_recovery_required")))).toHaveLength(1);
|
||||
});
|
||||
|
||||
async function bindExternalInput(f: Awaited<ReturnType<typeof seed>>, provider: "grok_bot" | "openai_dot", change?: (input: { schema: string; binding: { runId: string; companyId: string; agentId: string }; workspace: { access: string } }) => void) {
|
||||
async function bindExternalInput(f: Awaited<ReturnType<typeof seed>>, provider: "grok_bot" | "openai_dot" | "muse", change?: (input: { schema: string; binding: { runId: string; companyId: string; agentId: string; issueId: string }; provider: { kind: string; bridgeRevision?: string }; workspace: { access: string; cwd: string | null } }) => void) {
|
||||
const run = await readRun(f.runId);
|
||||
const input = { schema: "paperclip.native-execution-input.v6",
|
||||
const input = { schema: provider === "muse" ? "paperclip.native-execution-input.v8" : "paperclip.native-execution-input.v6",
|
||||
binding: { runId: f.runId, companyId: f.companyId, agentId: f.agentId, issueId: f.issueId },
|
||||
provider: { kind: provider }, workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null } };
|
||||
provider: { kind: provider, ...(provider === "muse" ? { bridgeRevision: "muse-v1" } : {}) }, workspace: { access: "none", cwd: null, repoUrl: null, repoRef: null, branchName: null } };
|
||||
change?.(input);
|
||||
const [updated] = await db.update(heartbeatRuns).set({ driverKind: provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp",
|
||||
const [updated] = await db.update(heartbeatRuns).set({ driverKind: provider === "muse" ? "muse_external" : provider === "grok_bot" ? "grok_bot_mcp" : "openai_dot_mcp",
|
||||
runnerProfileJson: { ...run.runnerProfileJson, nativeExecutionInput: input } }).where(eq(heartbeatRuns.id, f.runId)).returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
it.each(["grok_bot", "openai_dot"] as const)("does not invent file recovery for %s admitted without workspace access", async provider => {
|
||||
it.each(["grok_bot", "openai_dot", "muse"] as const)("does not invent file recovery for %s admitted without workspace access", async provider => {
|
||||
const f = await seed(), run = await bindExternalInput(f, provider);
|
||||
const [hold] = provider === "muse" ? await db.insert(externalAgentHolds).values({ companyId: f.companyId, agentId: f.agentId,
|
||||
provider: "muse", assignmentId: randomUUID(), bindingId: randomUUID(), bindingGeneration: 1, runId: f.runId,
|
||||
workerUnknown: true, nativeEffectsUnknown: true }).returning() : [];
|
||||
await preserveNativeFailedWorkspace(db, run);
|
||||
expect((await readRun(f.runId)).resultJson).not.toHaveProperty("workspaceRestoreFailure");
|
||||
expect(await readLease(f.lease.id)).toMatchObject({ status: "active", leasePolicy: "ephemeral" });
|
||||
expect(await actions(f.issueId)).toHaveLength(0);
|
||||
if (hold) expect((await db.select().from(externalAgentHolds).where(eq(externalAgentHolds.id, hold.id)))[0]).toEqual(hold);
|
||||
});
|
||||
|
||||
it.each(["run", "company", "agent", "schema", "write_access"])("preserves file safeguards when no-workspace evidence has an invalid %s", async mismatch => {
|
||||
@@ -109,6 +113,27 @@ const support = externalUrl ? { supported: true } : await getEmbeddedPostgresTes
|
||||
expect(await readLease(f.lease.id)).toMatchObject({ leasePolicy: "retain_on_failure" });
|
||||
});
|
||||
|
||||
it.each(["run", "company", "agent", "issue", "schema", "provider", "bridge", "missing_bridge", "driver", "write_access", "cwd"] as const)("retains file safeguards for a mismatched Muse %s contract", async mismatch => {
|
||||
const f = await seed();
|
||||
await bindExternalInput(f, "muse", input => {
|
||||
if (mismatch === "run") input.binding.runId = randomUUID();
|
||||
if (mismatch === "company") input.binding.companyId = randomUUID();
|
||||
if (mismatch === "agent") input.binding.agentId = randomUUID();
|
||||
if (mismatch === "issue") input.binding.issueId = randomUUID();
|
||||
if (mismatch === "schema") input.schema = "paperclip.native-execution-input.v6";
|
||||
if (mismatch === "provider") input.provider.kind = "grok_bot";
|
||||
if (mismatch === "bridge") input.provider.bridgeRevision = "unknown";
|
||||
if (mismatch === "missing_bridge") delete input.provider.bridgeRevision;
|
||||
if (mismatch === "write_access") input.workspace.access = "read_write";
|
||||
if (mismatch === "cwd") input.workspace.cwd = "/untrusted-task-workspace";
|
||||
});
|
||||
if (mismatch === "driver") await db.update(heartbeatRuns).set({ driverKind: "openai_dot_mcp" }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await preserveNativeFailedWorkspace(db, await readRun(f.runId));
|
||||
expect((await readRun(f.runId)).resultJson).toHaveProperty("workspaceRestoreFailure", "restore_failed");
|
||||
expect(await readLease(f.lease.id)).toMatchObject({ leasePolicy: "retain_on_failure" });
|
||||
expect(await actions(f.issueId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["grok_bot", "openai_dot"] as const)("keeps historical %s inspection holds with an available recovery path", async provider => {
|
||||
const f = await seed();
|
||||
// Reproduce a hold persisted by an older cancellation path, then attach its admitted contract.
|
||||
|
||||
@@ -1689,6 +1689,7 @@ export function nativeSessionWorkspaceScope(execution: { binding: Pick<NativeExe
|
||||
|
||||
function nativeProviderSessionScope(execution: NativeExecutionInput) {
|
||||
switch (execution.provider.kind) {
|
||||
case "muse":
|
||||
case "grok_bot":
|
||||
case "openai_dot": return { kind: execution.provider.kind, bindingId: execution.provider.binding.bindingId, generation: execution.provider.binding.bindingGeneration };
|
||||
case "claude_managed":
|
||||
@@ -5549,6 +5550,7 @@ function canonicalJson(value: unknown): string {
|
||||
|
||||
function runnerProviderStateFilename(execution: NativeExecutionInput): string {
|
||||
switch (execution.provider.kind) {
|
||||
case "muse": return "muse-provider-state.json";
|
||||
case "grok_bot":
|
||||
case "openai_dot":
|
||||
return "dot-provider-state.json";
|
||||
@@ -5584,9 +5586,10 @@ export function providerSessionIdentityFromDurableProviderState(input: {
|
||||
providerSessionIdentity: null,
|
||||
});
|
||||
switch (input.execution.provider.kind) {
|
||||
case "muse":
|
||||
case "grok_bot":
|
||||
case "openai_dot":
|
||||
// Dot checkpoints recover the bridge only; there is no native provider thread.
|
||||
// External checkpoints recover the bridge only; there is no native provider thread.
|
||||
return emptyIdentity();
|
||||
case "acpx": {
|
||||
const descriptor = record(state.descriptor);
|
||||
@@ -8658,7 +8661,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
: null;
|
||||
}
|
||||
const runnerExecution: NativeExecutionInput =
|
||||
input.execution.schema !== "paperclip.native-execution-input.v6" && input.useRunnerd && input.runnerExecutionTarget?.kind === "remote"
|
||||
input.execution.schema !== "paperclip.native-execution-input.v6" && input.execution.schema !== "paperclip.native-execution-input.v8" && input.useRunnerd && input.runnerExecutionTarget?.kind === "remote"
|
||||
? {
|
||||
...input.execution,
|
||||
workspace: {
|
||||
@@ -11431,6 +11434,9 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
retainedTransition?: VerifiedWarmTransitionBinding,
|
||||
): Promise<NativeSessionBackend & { bindManagedSession(session: NativeSession): NativeSession }> {
|
||||
const timeController = createRunPreparationTiming(input.execution.binding.runId, "native.controller");
|
||||
if (input.execution.schema === "paperclip.native-execution-input.v8") {
|
||||
throw new Error("paperclip_runner_provider_unsupported");
|
||||
}
|
||||
let recoveryPending = retainedTransition !== undefined;
|
||||
const target = input.runnerExecutionTarget ?? { kind: "local" as const };
|
||||
const remoteTarget = target.kind === "remote" ? target : null;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { connectorToolDefinitionsForSkills } from "../connector-runtime.js";
|
||||
import * as cloudIdentity from "../cloud-runtime-identity.js";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { upsertIssueDocumentSchema } from "@paperclipai/shared";
|
||||
import { LOW_TRUST_REVIEW_PRESET, upsertIssueDocumentSchema } from "@paperclipai/shared";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
@@ -24,9 +24,11 @@ import { initializeRunIdentity, reserveSteeredIdentity, reconcileSteeredIdentity
|
||||
import { documentService } from "../documents.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||
import { PaperclipRunnerSemanticAuthority } from "./runner-semantic-authority.js";
|
||||
import { createAssignedMcpTools } from "./assigned-mcp-tools.js";
|
||||
import type { ToolGatewayService } from "../tool-gateway.js";
|
||||
import { READ_CURRENT_WAKE_COMMENTS_TOOL_NAME } from "./current-wake-comments.js";
|
||||
import { buildLowTrustSourceTrust, buildPromotedSourceTrust, LOW_TRUST_QUARANTINED_BODY } from "../source-trust.js";
|
||||
import { CAPABILITY_SEMANTIC_TOOL_CATALOG, runnerCodexDynamicToolsFit } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
describe("PaperclipRunnerToolAuthority", () => {
|
||||
@@ -196,6 +198,109 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
).rejects.toThrow("paperclip_runner_tool_not_advertised");
|
||||
});
|
||||
|
||||
it("preserves platform receipt provenance in both mixed task history readers", async () => {
|
||||
const at = new Date("2026-10-10T12:00:00Z");
|
||||
const commentIds = Array.from({ length: 4 }, () => randomUUID());
|
||||
const requestId = randomUUID();
|
||||
await db.insert(issueComments).values([
|
||||
{ id: commentIds[0], companyId, issueId, authorUserId: "board-user", authorType: "user",
|
||||
body: "Check the configuration.", createdAt: at },
|
||||
{ id: commentIds[1], companyId, issueId, authorAgentId: agentId, authorType: "agent",
|
||||
origin: "fast_response", fastResponseRequestId: requestId, body: "I will check the configuration.",
|
||||
createdAt: new Date(at.getTime() + 1000) },
|
||||
{ id: commentIds[2], companyId, issueId, authorAgentId: agentId, authorType: "agent",
|
||||
body: "Verified the configuration: two settings need updates.", createdAt: new Date(at.getTime() + 2000) },
|
||||
{ id: commentIds[3], companyId, issueId, authorAgentId: agentId, authorType: "agent",
|
||||
origin: "fast_response", body: "Deleted acknowledgement", createdAt: new Date(at.getTime() + 3000),
|
||||
deletedAt: new Date(at.getTime() + 4000) },
|
||||
]);
|
||||
try {
|
||||
const tool = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
|
||||
const semantic = new PaperclipRunnerSemanticAuthority(db, { companyId, agentId, issueId, runId });
|
||||
const histories = [
|
||||
async (limit: number) => tool.execute({ tool: "get_task_history", callId: `mixed-tool-history-${limit}`, arguments: { limit } }),
|
||||
async (limit: number) => {
|
||||
const result = await semantic.dispatch({ callId: `mixed-semantic-history-${limit}`, operationId: "get_task_history", input: { limit },
|
||||
correlation: { runId, normalizedSessionId: randomUUID(), turnId: "history", itemId: "history" } });
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
return result.ok ? result.value : null;
|
||||
},
|
||||
];
|
||||
const expected = [
|
||||
{ id: commentIds[0], authorAgentId: null, authorUserId: "board-user", origin: "comment", body: "Check the configuration." },
|
||||
{ id: commentIds[1], authorAgentId: agentId, authorUserId: null, origin: "fast_response",
|
||||
body: "[Paperclip acknowledgement; not agent work] I will check the configuration." },
|
||||
{ id: commentIds[2], authorAgentId: agentId, authorUserId: null, origin: "comment",
|
||||
body: "Verified the configuration: two settings need updates." },
|
||||
];
|
||||
for (const history of histories) {
|
||||
expect(await history(3)).toMatchObject({ comments: expected });
|
||||
expect(await history(2)).toMatchObject({ comments: expected.slice(1) });
|
||||
}
|
||||
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentIds[1])))[0])
|
||||
.toMatchObject({ id: commentIds[1], origin: "fast_response", fastResponseRequestId: requestId,
|
||||
authorAgentId: agentId, body: "I will check the configuration." });
|
||||
} finally {
|
||||
await db.delete(issueComments).where(inArray(issueComments.id, commentIds));
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])("legacy history preserves source trust for a current reader run (low trust: %s)", async (lowTrustReader) => {
|
||||
const commentIds = Array.from({ length: 4 }, () => randomUUID());
|
||||
const [originalRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
||||
const [originalIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
const quarantine = buildLowTrustSourceTrust({ issueId, runId, agentId });
|
||||
const promotion = buildPromotedSourceTrust({
|
||||
sourceIssueId: issueId, sourceArtifactKind: "comment", sourceArtifactId: commentIds[2],
|
||||
promotedByActorType: "user", promotedByActorId: "board-user",
|
||||
});
|
||||
const at = new Date("2026-10-13T12:00:00Z");
|
||||
await db.insert(issueComments).values([
|
||||
{ id: commentIds[0], companyId, issueId, authorAgentId: agentId, authorType: "agent",
|
||||
body: "QUARANTINED legacy history canary", sourceTrust: quarantine, createdAt: at },
|
||||
{ id: commentIds[1], companyId, issueId, authorAgentId: agentId, authorType: "agent",
|
||||
origin: "fast_response", body: "QUARANTINED acknowledgement canary", sourceTrust: quarantine,
|
||||
createdAt: new Date(at.getTime() + 1000) },
|
||||
{ id: commentIds[2], companyId, issueId, authorAgentId: agentId, authorType: "agent",
|
||||
body: "Reviewed and promoted summary", sourceTrust: promotion, createdAt: new Date(at.getTime() + 2000) },
|
||||
{ id: commentIds[3], companyId, issueId, authorUserId: "board-user", authorType: "user",
|
||||
body: "Trusted coordination message", createdAt: new Date(at.getTime() + 3000) },
|
||||
]);
|
||||
try {
|
||||
if (lowTrustReader) {
|
||||
const executionPolicy = { authorizationPolicy: { trustBoundary: {
|
||||
mode: LOW_TRUST_REVIEW_PRESET, companyId, rootIssueId: issueId,
|
||||
} } };
|
||||
await db.update(issues).set({ executionPolicy }).where(eq(issues.id, issueId));
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId, executionPolicy } }).where(eq(heartbeatRuns.id, runId));
|
||||
// Relaxing the live task must not relabel the already-admitted reader run.
|
||||
await db.update(issues).set({ executionPolicy: null }).where(eq(issues.id, issueId));
|
||||
}
|
||||
const semantic = new PaperclipRunnerSemanticAuthority(db, { companyId, agentId, issueId, runId });
|
||||
const result = await semantic.dispatch({
|
||||
callId: `legacy-trust-history-${lowTrustReader}`, operationId: "get_task_history", input: { limit: 4 },
|
||||
correlation: { runId, normalizedSessionId: randomUUID(), turnId: "history", itemId: "history" },
|
||||
});
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(result.ok ? result.value : null).toMatchObject({ comments: [
|
||||
{ id: commentIds[0], sourceTrust: quarantine,
|
||||
body: lowTrustReader ? "QUARANTINED legacy history canary" : LOW_TRUST_QUARANTINED_BODY },
|
||||
{ id: commentIds[1], origin: "fast_response", sourceTrust: quarantine,
|
||||
body: `[Paperclip acknowledgement; not agent work] ${lowTrustReader ? "QUARANTINED acknowledgement canary" : LOW_TRUST_QUARANTINED_BODY}` },
|
||||
{ id: commentIds[2], sourceTrust: promotion, body: "Reviewed and promoted summary" },
|
||||
{ id: commentIds[3], sourceTrust: null, body: "Trusted coordination message" },
|
||||
] });
|
||||
if (!lowTrustReader) expect(JSON.stringify(result)).not.toContain("QUARANTINED legacy history canary");
|
||||
if (!lowTrustReader) expect(JSON.stringify(result)).not.toContain("QUARANTINED acknowledgement canary");
|
||||
expect((await db.select().from(issueComments).where(eq(issueComments.id, commentIds[0])))[0])
|
||||
.toMatchObject({ body: "QUARANTINED legacy history canary", sourceTrust: quarantine });
|
||||
} finally {
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: originalRun!.contextSnapshot }).where(eq(heartbeatRuns.id, runId));
|
||||
await db.update(issues).set({ executionPolicy: originalIssue!.executionPolicy }).where(eq(issues.id, issueId));
|
||||
await db.delete(issueComments).where(inArray(issueComments.id, commentIds));
|
||||
}
|
||||
});
|
||||
|
||||
it("returns public task URLs from the current claimed origin in context and search results", async () => {
|
||||
const origin = vi.spyOn(cloudIdentity, "runtimeCanonicalOrigin").mockReturnValue("https://board.example");
|
||||
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isConversation } from "../agent-conversations.js";
|
||||
import { setIssueTitle } from "../issue-title.js";
|
||||
import { externalObjectService } from "../external-objects.js";
|
||||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { setIssueTitleSchema } from "@paperclipai/shared";
|
||||
import { fastResponseHistoryBody, setIssueTitleSchema } from "@paperclipai/shared";
|
||||
import { authorizeInstructionCommit } from "../agent-instruction-authorization.js";
|
||||
import { executeAgentInstructionTool } from "./agent-instruction-tools.js";
|
||||
import { createReadStream } from "node:fs";
|
||||
@@ -625,6 +625,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
const comments = await this.db.select({
|
||||
id: issueComments.id,
|
||||
body: issueComments.body,
|
||||
origin: issueComments.origin,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
createdAt: issueComments.createdAt,
|
||||
@@ -636,7 +637,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
))
|
||||
.orderBy(desc(issueComments.createdAt))
|
||||
.limit(limit);
|
||||
return { comments: comments.reverse() };
|
||||
return { comments: comments.reverse().map(comment => ({ ...comment, body: fastResponseHistoryBody(comment) })) };
|
||||
}
|
||||
case "search_tasks": {
|
||||
const tasks = await issueService(this.db).list(this.binding.companyId, { readCondition: await issueReadSqlCondition(this.db, this.#privacyActor(context.run)) });
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { fastResponseHistoryBody } from "@paperclipai/shared";
|
||||
import { readTaskQuestionContext } from "../issue-question-context.js";
|
||||
import { isConversation } from "../agent-conversations.js";
|
||||
import {
|
||||
isLowTrustQuarantined,
|
||||
resolveActorSourceTrustForIssue,
|
||||
sanitizeQuarantinedCommentForHigherTrust,
|
||||
} from "../source-trust.js";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -155,6 +161,8 @@ export class PaperclipRunnerSemanticAuthority {
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
body: issueComments.body,
|
||||
origin: issueComments.origin,
|
||||
sourceTrust: issueComments.sourceTrust,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
createdAt: issueComments.createdAt,
|
||||
@@ -169,8 +177,19 @@ export class PaperclipRunnerSemanticAuthority {
|
||||
)
|
||||
.orderBy(desc(issueComments.createdAt))
|
||||
.limit(boundedLimit(input.limit));
|
||||
const readerSourceTrust = await resolveActorSourceTrustForIssue({
|
||||
db: this.#db,
|
||||
issue: context.issue,
|
||||
actor: { actorType: "agent", actorId: context.agent.id, agentId: context.agent.id, runId: context.run.id },
|
||||
});
|
||||
const comments = rows.reverse().map(row => {
|
||||
const comment = isLowTrustQuarantined(readerSourceTrust)
|
||||
? row
|
||||
: sanitizeQuarantinedCommentForHigherTrust(row);
|
||||
return { ...comment, body: fastResponseHistoryBody(comment) };
|
||||
});
|
||||
return {
|
||||
value: jsonValue({ comments: rows.reverse() }),
|
||||
value: jsonValue({ comments }),
|
||||
references: [{ kind: "task", id: context.issue.id }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ export const LEGACY_WORKSPACE_RECOVERY_SCHEMA = "paperclip.workspace-restore-rec
|
||||
export function nativeRunHasNoWorkspaceAccess(run: Pick<typeof heartbeatRuns.$inferSelect,
|
||||
"id" | "companyId" | "agentId" | "issueId" | "nativeIssueId" | "runtimeMode" | "driverKind" | "runnerProfileJson">): boolean {
|
||||
const input = run.runnerProfileJson?.nativeExecutionInput as Record<string, unknown> | undefined;
|
||||
if (run.runtimeMode !== "native" || input?.schema !== "paperclip.native-execution-input.v6") return false;
|
||||
if (run.runtimeMode !== "native" || !input || (input.schema !== "paperclip.native-execution-input.v6" && input.schema !== "paperclip.native-execution-input.v8")) return false;
|
||||
const binding = input.binding as Record<string, unknown> | undefined;
|
||||
const workspace = input.workspace as Record<string, unknown> | undefined;
|
||||
const provider = input.provider as Record<string, unknown> | undefined;
|
||||
const driver = provider?.kind === "grok_bot" ? "grok_bot_mcp"
|
||||
: provider?.kind === "openai_dot" ? "openai_dot_mcp" : null;
|
||||
const driver = input?.schema === "paperclip.native-execution-input.v8"
|
||||
? provider?.kind === "muse" && provider.bridgeRevision === "muse-v1" ? "muse_external" : null
|
||||
: provider?.kind === "grok_bot" ? "grok_bot_mcp"
|
||||
: provider?.kind === "openai_dot" ? "openai_dot_mcp" : null;
|
||||
return driver !== null && run.driverKind === driver && workspace?.access === "none" && workspace.cwd === null
|
||||
&& binding?.runId === run.id && binding.companyId === run.companyId && binding.agentId === run.agentId
|
||||
&& binding.issueId === (run.nativeIssueId ?? run.issueId);
|
||||
|
||||
+3
@@ -36,6 +36,8 @@ export type {
|
||||
NativeExecutionInputV4,
|
||||
NativeExecutionInputV5,
|
||||
NativeExecutionInputV6,
|
||||
NativeExecutionInputV8,
|
||||
MuseBindingSnapshot,
|
||||
DotBindingSnapshot,
|
||||
ExternalProviderPort,
|
||||
ExternalProviderOperation,
|
||||
@@ -158,6 +160,7 @@ export const bundledRemoteProviderPackManifestPath = runner.bundledRemoteProvide
|
||||
export const bundledRemoteRunnerBinary = runner.bundledRemoteRunnerBinary;
|
||||
|
||||
export const externalOperationDigest = runner.externalOperationDigest;
|
||||
export const digestPaperclipSemanticContent = runner.digestPaperclipSemanticContent;
|
||||
export const CONFIGURED_ENVIRONMENT_KEYS = runner.CONFIGURED_ENVIRONMENT_KEYS;
|
||||
export const GENERATED_RUNTIME_ENVIRONMENT_KEYS = runner.GENERATED_RUNTIME_ENVIRONMENT_KEYS;
|
||||
export const configuredEnvironmentProjection = runner.configuredEnvironmentProjection;
|
||||
|
||||
Reference in new issue
Block a user