mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
Merge master b189f586d into Codex runner default candidate
Preserve master transport, lifecycle, and release verification controls alongside macOS qualification. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
f4abdc2cfc
749 files changed
+124181
-68418
No files matched your search
@@ -28,6 +28,9 @@ import {
|
||||
} from "../../attachment-types.js";
|
||||
import { getStorageService } from "../../storage/index.js";
|
||||
import type { StorageService } from "../../storage/types.js";
|
||||
import { loadConfig } from "../../config.js";
|
||||
import { HttpError } from "../../errors.js";
|
||||
import { voiceSessionStore, type VoiceTransaction } from "../voice/voice-session-store.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { boundExternalChatProvider } from "./external-chat-provider.js";
|
||||
import { resolveExternalChatQuestionResponse } from "./external-chat-question-response.js";
|
||||
@@ -340,7 +343,7 @@ async function principalAuthorized(
|
||||
}
|
||||
}
|
||||
const principalQuery = tx
|
||||
.select({ id: chatExternalPrincipals.id })
|
||||
.select({ id: chatExternalPrincipals.id, externalId: chatExternalPrincipals.externalId })
|
||||
.from(chatExternalPrincipals)
|
||||
.where(
|
||||
and(
|
||||
@@ -359,6 +362,16 @@ async function principalAuthorized(
|
||||
? principalQuery.for("update", { noWait: true }).limit(1)
|
||||
: principalQuery.for("update").limit(1));
|
||||
if (!principal) return false;
|
||||
if (endpoint.provider === "speko") {
|
||||
try {
|
||||
await voiceSessionStore(tx, { allowLocalBoard: loadConfig().deploymentMode === "local_trusted", nonblockingAuthority: lockMode === "nonblocking" })
|
||||
.authorizePrincipal(tx as unknown as VoiceTransaction, endpoint.companyId, endpoint.id, principal.externalId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && [403, 404].includes(error.status)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const linkQuery = tx
|
||||
.select({
|
||||
status: chatIdentityLinks.status,
|
||||
|
||||
@@ -34,6 +34,7 @@ const EXTERNAL_CHAT_PROVIDERS = new Set([
|
||||
"microsoft-teams",
|
||||
"telegram",
|
||||
"imessage-photon",
|
||||
"speko",
|
||||
]);
|
||||
const ATTACHMENT_OMISSION_REASONS = new Set([
|
||||
"attachment_limit",
|
||||
|
||||
@@ -7,6 +7,7 @@ const BOUND_EXTERNAL_CHAT_PROVIDERS = [
|
||||
"microsoft-teams",
|
||||
"telegram",
|
||||
"imessage-photon",
|
||||
"speko",
|
||||
] as const;
|
||||
|
||||
export function boundExternalChatProvider(source: unknown) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { photonAnswersMatch } from "../photon/interactions.js";
|
||||
import { and, or, eq, inArray, notExists, sql } from "drizzle-orm";
|
||||
import { and, or, eq, lte, inArray, notExists, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agentWakeupRequests,
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatEndpoints,
|
||||
chatExternalPrincipals,
|
||||
chatIdentityLinks,
|
||||
chatVoiceSessions,
|
||||
chatVoiceToolCalls,
|
||||
chatVoiceReplies,
|
||||
toolConnections,
|
||||
chatMessageLinks,
|
||||
chatPublications,
|
||||
heartbeatRuns,
|
||||
@@ -21,6 +26,7 @@ import {
|
||||
validateChatQuestionFormSubmission,
|
||||
} from "../chat-question-forms.js";
|
||||
import { questionResponseDeliveryValues } from "../question-response-delivery.js";
|
||||
import { voiceSessionStore, type VoiceTransaction, currentVoiceCredentialFingerprint } from "../voice/voice-session-store.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
|
||||
export const EXTERNAL_CHAT_QUESTION_RESPONSE_KEY =
|
||||
@@ -339,7 +345,7 @@ async function resolveQuestionResponseChain(
|
||||
const provider =
|
||||
parent?.provider ??
|
||||
(
|
||||
["slack", "github", "discord", "microsoft-teams", "telegram", "imessage-photon"] as const
|
||||
["slack", "github", "discord", "microsoft-teams", "telegram", "imessage-photon", "speko"] as const
|
||||
).find(
|
||||
(candidate) =>
|
||||
sourceContext.source === `chat:${candidate}` ||
|
||||
@@ -358,7 +364,7 @@ async function resolveQuestionResponseChain(
|
||||
if (
|
||||
!provider ||
|
||||
source.agentId !== binding.agentId ||
|
||||
!sourceTaskMatches ||
|
||||
(!sourceTaskMatches && !(provider === "speko" && source.runtimeMode === "legacy" && source.nativeIssueId === null)) ||
|
||||
sourceContext.issueId !== binding.issueId ||
|
||||
(source.status !== "succeeded" &&
|
||||
!(
|
||||
@@ -418,6 +424,121 @@ async function resolveQuestionResponseChain(
|
||||
)
|
||||
return null;
|
||||
|
||||
if (provider === "speko") {
|
||||
// A model-selected interaction ID is insufficient. Require the exact
|
||||
// canonical answer, a presented publication, and the durable signed-tool
|
||||
// receipt committed atomically with that answer. No synthetic identity link.
|
||||
const proofs = await tx.select({ receipt: chatVoiceToolCalls, session: chatVoiceSessions,
|
||||
reply: chatVoiceReplies, publication: chatPublications, endpoint: chatEndpoints, connection: toolConnections,
|
||||
conversation: chatConversations }).from(chatVoiceToolCalls)
|
||||
.innerJoin(chatVoiceSessions, and(eq(chatVoiceSessions.id, chatVoiceToolCalls.sessionId), eq(chatVoiceSessions.companyId, binding.companyId)))
|
||||
.innerJoin(chatVoiceReplies, and(eq(chatVoiceReplies.sessionId, chatVoiceSessions.id), eq(chatVoiceReplies.companyId, binding.companyId)))
|
||||
.innerJoin(chatPublications, and(eq(chatPublications.id, chatVoiceReplies.publicationId), eq(chatPublications.companyId, binding.companyId)))
|
||||
.innerJoin(chatEndpoints, and(eq(chatEndpoints.id, chatVoiceSessions.endpointId), eq(chatEndpoints.companyId, binding.companyId)))
|
||||
.innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, binding.companyId)))
|
||||
.innerJoin(chatConversations, and(eq(chatConversations.id, chatVoiceSessions.conversationId), eq(chatConversations.companyId, binding.companyId)))
|
||||
.where(and(eq(chatVoiceToolCalls.companyId, binding.companyId), eq(chatVoiceToolCalls.tool, "answer_question"),
|
||||
sql`${chatVoiceToolCalls.response}->>'interactionId' = ${interaction.id}`,
|
||||
sql`${chatPublications.payload}->>'interactionId' = ${interaction.id}`,
|
||||
eq(chatVoiceSessions.issueId, binding.issueId),
|
||||
// Terminal acknowledgment publications use the same interaction ID.
|
||||
// Only a question actually delivered before its answer can be proof.
|
||||
lte(chatVoiceReplies.deliveredAt, interaction.resolvedAt)));
|
||||
if (proofs.length === 0 && interaction.resolvedByRunId === null) {
|
||||
// The authenticated task-page answer commits the canonical interaction
|
||||
// and response delivery too. Bind it to the original voice requester;
|
||||
// do not require hearing the question or fabricate a signed-tool receipt.
|
||||
const origins = await tx.select({ session: chatVoiceSessions, endpoint: chatEndpoints,
|
||||
connection: toolConnections, conversation: chatConversations, comment: issueComments,
|
||||
inbound: chatDeliveries, link: chatMessageLinks }).from(chatMessageLinks)
|
||||
.innerJoin(issueComments, and(eq(issueComments.id, chatMessageLinks.commentId), eq(issueComments.companyId, binding.companyId), eq(issueComments.issueId, binding.issueId)))
|
||||
.innerJoin(chatDeliveries, and(eq(chatDeliveries.id, chatMessageLinks.deliveryId), eq(chatDeliveries.companyId, binding.companyId), eq(chatDeliveries.endpointId, chatMessageLinks.endpointId), eq(chatDeliveries.conversationId, chatMessageLinks.conversationId)))
|
||||
.innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatDeliveries.principalId), eq(chatExternalPrincipals.companyId, binding.companyId), eq(chatExternalPrincipals.provider, "speko")))
|
||||
.innerJoin(chatVoiceSessions, and(sql`${chatExternalPrincipals.externalId} = 'voice:' || ${chatVoiceSessions.id}::text`, eq(chatVoiceSessions.companyId, binding.companyId), eq(chatVoiceSessions.endpointId, chatMessageLinks.endpointId), eq(chatVoiceSessions.conversationId, chatMessageLinks.conversationId), eq(chatVoiceSessions.issueId, binding.issueId)))
|
||||
.innerJoin(chatEndpoints, and(eq(chatEndpoints.id, chatVoiceSessions.endpointId), eq(chatEndpoints.companyId, binding.companyId)))
|
||||
.innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, binding.companyId)))
|
||||
.innerJoin(chatConversations, and(eq(chatConversations.id, chatVoiceSessions.conversationId), eq(chatConversations.companyId, binding.companyId), eq(chatConversations.endpointId, chatEndpoints.id)))
|
||||
.where(and(eq(chatMessageLinks.companyId, binding.companyId), eq(chatMessageLinks.commentId, context.sourceCommentId), eq(chatMessageLinks.direction, "inbound")));
|
||||
if (origins.length !== 1) return null;
|
||||
const { session, endpoint, connection, conversation, comment, inbound, link } = origins[0]!;
|
||||
if (comment.deletedAt || inbound.state !== "processed" || comment.authorUserId !== interaction.resolvedByUserId
|
||||
|| session.callerId !== interaction.resolvedByUserId || !["member", "instance_admin", "local_board"].includes(session.callerAuthority)
|
||||
|| session.assignedAgentId !== binding.agentId || endpoint.assignedAgentId !== binding.agentId
|
||||
|| endpoint.provider !== "speko" || !["active", "verifying"].includes(endpoint.status)
|
||||
|| !connection.enabled || connection.status !== "active" || connection.transport !== "voice"
|
||||
|| session.generation !== record(endpoint.setup).runtimeGeneration
|
||||
|| session.credentialFingerprint !== await currentVoiceCredentialFingerprint(tx, binding.companyId, connection.credentialSecretRefs)
|
||||
|| !["active", "waiting"].includes(conversation.state) || conversation.issueId !== binding.issueId
|
||||
|| (parent && (parent.marker.endpointId !== endpoint.id || parent.marker.conversationId !== conversation.id))) return null;
|
||||
try {
|
||||
const store = voiceSessionStore(tx, { allowLocalBoard: session.callerAuthority === "local_board" });
|
||||
await store.authorizePrincipal(tx as unknown as VoiceTransaction, binding.companyId, endpoint.id, `voice:${session.id}`);
|
||||
} catch (error) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "55P03") throw error;
|
||||
return null;
|
||||
}
|
||||
const marker: Marker = {
|
||||
schema: "paperclip.external_chat_question_response.v1", interactionId: interaction.id,
|
||||
responseDeliveryId: delivery.id, sourceRunId: source.id, sourceCommentId: context.sourceCommentId,
|
||||
endpointId: endpoint.id, conversationId: conversation.id,
|
||||
bindingSha256: nativeSha256({ binding: {companyId: binding.companyId, issueId: binding.issueId, runId: binding.runId, agentId: binding.agentId}, responseDeliveryId: delivery.id, payloadSha256: delivery.payloadSha256,
|
||||
sourceRunId: source.id, sourceCommentId: context.sourceCommentId, sourceOrigin: sourceContext.source,
|
||||
...(parent ? { sourceQuestionResponseSha256: parent.marker.bindingSha256 } : {}),
|
||||
interaction: { id: interaction.id, payload: interaction.payload, result: interaction.result,
|
||||
resolvedAt: interaction.resolvedAt.toISOString(), resolvedByUserId: interaction.resolvedByUserId },
|
||||
boardResponse: { sessionId: session.id, callerId: session.callerId, generation: session.generation,
|
||||
credentialFingerprint: session.credentialFingerprint, inboundId: inbound.id, linkId: link.id },
|
||||
conversationGeneration: conversation.sessionGeneration, wakeId: wake.id }),
|
||||
};
|
||||
if (!mint && nativeSha256(record(context[EXTERNAL_CHAT_QUESTION_RESPONSE_KEY])) !== nativeSha256(marker)) return null;
|
||||
return { marker, provider, answeredAtMs: interaction.resolvedAt.getTime(),
|
||||
interactionIds: [...(parent?.interactionIds ?? []), interaction.id],
|
||||
authorizationContext: { ...context, source: "chat:speko", paperclipHarnessCheckedOut: false, paperclipExternalChatExecutionBound: true } };
|
||||
}
|
||||
if (proofs.length !== 1) return null;
|
||||
const proof = proofs[0]!;
|
||||
const { receipt, session, reply, publication, endpoint, connection, conversation } = proof;
|
||||
const [original] = await tx.select({ comment: issueComments, inbound: chatDeliveries, link: chatMessageLinks })
|
||||
.from(chatMessageLinks).innerJoin(issueComments, eq(issueComments.id, chatMessageLinks.commentId))
|
||||
.innerJoin(chatDeliveries, eq(chatDeliveries.id, chatMessageLinks.deliveryId))
|
||||
.where(and(eq(chatMessageLinks.companyId, binding.companyId), eq(chatMessageLinks.endpointId, endpoint.id),
|
||||
eq(chatMessageLinks.conversationId, conversation.id), eq(chatMessageLinks.direction, "inbound"),
|
||||
eq(chatMessageLinks.commentId, context.sourceCommentId), eq(issueComments.companyId, binding.companyId),
|
||||
eq(issueComments.issueId, binding.issueId), eq(chatDeliveries.companyId, binding.companyId)));
|
||||
if (!original || original.comment.deletedAt || original.inbound.state !== "processed"
|
||||
|| original.comment.authorUserId !== interaction.resolvedByUserId
|
||||
|| session.callerId !== interaction.resolvedByUserId || !["member", "instance_admin", "local_board"].includes(session.callerAuthority)
|
||||
|| session.assignedAgentId !== binding.agentId || endpoint.assignedAgentId !== binding.agentId
|
||||
|| endpoint.provider !== "speko" || !["active", "verifying"].includes(endpoint.status)
|
||||
|| !connection.enabled || connection.status !== "active" || connection.transport !== "voice"
|
||||
|| session.generation !== record(endpoint.setup).runtimeGeneration
|
||||
|| session.credentialFingerprint !== await currentVoiceCredentialFingerprint(tx, binding.companyId, connection.credentialSecretRefs)
|
||||
|| !["active", "waiting"].includes(conversation.state) || conversation.issueId !== binding.issueId
|
||||
|| publication.conversationId !== conversation.id || publication.endpointId !== endpoint.id || publication.issueId !== binding.issueId
|
||||
|| publication.state !== "published" || !publication.providerMessageId || !reply.deliveredAt
|
||||
|| reply.deliveredAt > interaction.resolvedAt || receipt.response.status !== "answered"
|
||||
|| receipt.response.resultSha256 !== nativeSha256(interaction.result)
|
||||
|| (parent && (parent.marker.endpointId !== endpoint.id || parent.marker.conversationId !== conversation.id))) return null;
|
||||
const marker: Marker = {
|
||||
schema: "paperclip.external_chat_question_response.v1", interactionId: interaction.id,
|
||||
responseDeliveryId: delivery.id, sourceRunId: source.id, sourceCommentId: context.sourceCommentId,
|
||||
endpointId: endpoint.id, conversationId: conversation.id,
|
||||
bindingSha256: nativeSha256({ binding: { companyId: binding.companyId, issueId: binding.issueId, runId: binding.runId, agentId: binding.agentId }, responseDeliveryId: delivery.id, payloadSha256: delivery.payloadSha256,
|
||||
sourceRunId: source.id, sourceCommentId: context.sourceCommentId, sourceOrigin: sourceContext.source,
|
||||
...(parent ? { sourceQuestionResponseSha256: parent.marker.bindingSha256 } : {}),
|
||||
interaction: { id: interaction.id, payload: interaction.payload, result: interaction.result,
|
||||
resolvedAt: interaction.resolvedAt.toISOString(), resolvedByUserId: interaction.resolvedByUserId },
|
||||
voiceResponse: { receiptId: receipt.id, fingerprint: receipt.fingerprint, response: receipt.response,
|
||||
sessionId: session.id, callerId: session.callerId, generation: session.generation, credentialFingerprint: session.credentialFingerprint },
|
||||
publicationId: publication.id, replyId: reply.id, inboundId: original.inbound.id, linkId: original.link.id,
|
||||
conversationGeneration: conversation.sessionGeneration, wakeId: wake.id }),
|
||||
};
|
||||
if (!mint && nativeSha256(record(context[EXTERNAL_CHAT_QUESTION_RESPONSE_KEY])) !== nativeSha256(marker)) return null;
|
||||
return { marker, provider, answeredAtMs: interaction.resolvedAt.getTime(),
|
||||
interactionIds: [...(parent?.interactionIds ?? []), interaction.id],
|
||||
authorizationContext: { ...context, source: "chat:speko", paperclipHarnessCheckedOut: false, paperclipExternalChatExecutionBound: true } };
|
||||
}
|
||||
|
||||
const actionQuery = tx
|
||||
.select({
|
||||
action: chatActions,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
chatEndpointResources,
|
||||
chatEndpoints,
|
||||
chatExternalPrincipals,
|
||||
chatGitHubConfigurations,
|
||||
chatIdentityLinks,
|
||||
chatMessageLinks,
|
||||
chatPublications,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
statusDecisions,
|
||||
toolApplications,
|
||||
toolConnections,
|
||||
chatVoiceSessions, chatVoiceReplies, chatVoiceToolCalls,
|
||||
workspaceOperations,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
@@ -61,11 +63,13 @@ import {
|
||||
resolveExternalChatResponseWaitAuthorization,
|
||||
} from "./chat-attachment-reuse.js";
|
||||
import { attestReviewedExternalChatRun, buildPaperclipWakePayload } from "../heartbeat.js";
|
||||
import { voiceCredentialFingerprint } from "../voice/voice-session-store.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
import { questionResponseDeliveryValues } from "../question-response-delivery.js";
|
||||
import { resolveExternalChatQuestionResponse } from "./external-chat-question-response.js";
|
||||
import { materializeExternalChatQuestionResponseInput } from "./external-chat-question-response-input.js";
|
||||
import * as nativeInteractionBridge from "./native-interaction-bridge.js";
|
||||
import type { AskUserQuestionsInteraction } from "@paperclipai/shared";
|
||||
import { defaultGitHubReviewPolicy, type AskUserQuestionsInteraction } from "@paperclipai/shared";
|
||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||
import { createLocalDiskStorageProvider } from "../../storage/local-disk-provider.js";
|
||||
import { createStorageService } from "../../storage/service.js";
|
||||
@@ -1114,6 +1118,52 @@ describe("native external-chat response wait", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each((["native", "legacy"] as const).flatMap(sourceRuntime => (["voice", "board"] as const).map(answerRoute => ({sourceRuntime, answerRoute}))))("authorizes $sourceRuntime Speko question continuation from its exact $answerRoute answer", async ({sourceRuntime, answerRoute}) => {
|
||||
const f = await seedAnsweredChatTurn();
|
||||
const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, f.endpointId));
|
||||
const [connection] = await db.update(toolConnections).set({ transport: "voice", status: "active", enabled: true }).where(eq(toolConnections.id, endpoint.connectionId)).returning();
|
||||
await db.update(chatEndpoints).set({ provider: "speko", setup: { step: "complete", runtimeGeneration: 1 } as typeof endpoint.setup }).where(eq(chatEndpoints.id, f.endpointId));
|
||||
const [source] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
const sourceContext = JSON.parse(JSON.stringify(source.contextSnapshot).replaceAll("chat:telegram", "chat:speko").replaceAll('"telegram"', '"speko"'));
|
||||
await db.update(heartbeatRuns).set({ contextSnapshot: sourceContext, runtimeMode: sourceRuntime, nativeIssueId: sourceRuntime === "native" ? f.issueId : null }).where(eq(heartbeatRuns.id, f.sourceRunId));
|
||||
const resolve = () => resolveExternalChatQuestionResponse(db, f, f.context, "read", true);
|
||||
expect(await resolve()).toBeNull();
|
||||
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, f.interactionId));
|
||||
const sessionId = randomUUID();
|
||||
await db.insert(chatVoiceSessions).values({ id: sessionId, companyId: f.companyId, endpointId: f.endpointId,
|
||||
conversationId: f.conversationId, issueId: f.issueId, assignedAgentId: f.agentId, callerId: f.userId, callerAuthority: "member",
|
||||
mode: "browser", state: "ended", providerSessionId: randomUUID(), generation: 1, credentialFingerprint: voiceCredentialFingerprint(connection.credentialSecretRefs),
|
||||
toolTokenHash: "a".repeat(64), idempotencyKey: randomUUID(), requestFingerprint: "fixture", expiresAt: new Date(Date.now() + 60000) });
|
||||
await db.insert(chatVoiceReplies).values({ companyId: f.companyId, sessionId, publicationId: f.publicationId, cursor: 1,
|
||||
deliveredAt: new Date(interaction.resolvedAt!.getTime() - 1) });
|
||||
const [receipt] = answerRoute === "voice" ? await db.insert(chatVoiceToolCalls).values({ companyId: f.companyId, sessionId, providerToolCallId: randomUUID(),
|
||||
webhookId: randomUUID(), fingerprint: "signed-tool-fingerprint", tool: "answer_question",
|
||||
response: { status: "answered", interactionId: f.interactionId, resultSha256: nativeSha256(interaction.result) } }).returning() : [];
|
||||
if (answerRoute === "board") {
|
||||
await db.update(chatExternalPrincipals).set({ provider: "speko", externalId: `voice:${sessionId}` }).where(eq(chatExternalPrincipals.id, f.principalId));
|
||||
await db.insert(chatVoiceToolCalls).values({companyId: f.companyId, sessionId, providerToolCallId: randomUUID(),
|
||||
webhookId: randomUUID(), fingerprint: "accepted-source-request", tool: "submit_request", response: {status: "accepted"}});
|
||||
}
|
||||
// The answer also publishes a terminal acknowledgment. It cannot compete
|
||||
// with the pre-answer presentation as authority for the continuation.
|
||||
const [terminal] = await db.insert(chatPublications).values({ companyId: f.companyId, endpointId: f.endpointId,
|
||||
conversationId: f.conversationId, issueId: f.issueId, idempotencyKey: randomUUID(), state: "published",
|
||||
payload: { text: "Answered: High.", interactionId: f.interactionId } }).returning();
|
||||
await db.insert(chatVoiceReplies).values({ companyId: f.companyId, sessionId, publicationId: terminal.id, cursor: 2 });
|
||||
expect(await resolve()).toMatchObject({ provider: "speko", marker: { interactionId: f.interactionId } });
|
||||
// Voice authority is the authenticated session, not a synthetic provider
|
||||
// identity link. Exercise the real native run attestation and recheck path.
|
||||
await db.update(chatExternalPrincipals).set({ provider: "speko", externalId: `voice:${sessionId}` }).where(eq(chatExternalPrincipals.id, f.principalId));
|
||||
await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.principalId, f.principalId));
|
||||
await attestAnswer(f);
|
||||
expect(await authorizeChatConversationForBoundRun(db, f, f.context, "read")).toMatchObject({ conversationId: f.conversationId });
|
||||
await db.update(companyMemberships).set({ status: "removed" }).where(eq(companyMemberships.principalId, f.userId));
|
||||
await expect(authorizeChatConversationForBoundRun(db, f, f.context, "read")).rejects.toThrow(answerRoute === "board" ? "paperclip_runner_chat_attachment_binding_denied" : "paperclip_runner_chat_attachment_principal_denied");
|
||||
await db.update(companyMemberships).set({ status: "active" }).where(eq(companyMemberships.principalId, f.userId));
|
||||
if (receipt) await db.update(chatVoiceToolCalls).set({ response: { ...receipt.response, resultSha256: "different-answer" } }).where(eq(chatVoiceToolCalls.id, receipt.id));
|
||||
else await db.update(issueComments).set({authorUserId: randomUUID()}).where(eq(issueComments.id, f.commentId));
|
||||
expect(await resolve()).toBeNull();
|
||||
});
|
||||
it.each(["valid", "wrong_task", "revoked_identity", "wrong_answer_actor"] as const)(
|
||||
"attests legacy Slack question continuations only with the durable source binding: %s",
|
||||
async (condition) => {
|
||||
@@ -3837,6 +3887,24 @@ describe("native external-chat response wait", () => {
|
||||
const fixture = await seedWaitTurn(
|
||||
provider as Parameters<typeof seedWaitTurn>[0],
|
||||
);
|
||||
if (provider === "github") {
|
||||
// Only upgraded GitHub bots keep the selected final internal. Legacy
|
||||
// connection fixtures retain their automatic presentation behavior.
|
||||
await db.insert(chatGitHubConfigurations).values({
|
||||
companyId: fixture.companyId,
|
||||
endpointId: fixture.endpointId,
|
||||
configuration: {
|
||||
version: 1,
|
||||
toolsEnabled: true,
|
||||
responsibleUserId: fixture.userId,
|
||||
memberAccess: "all_linked",
|
||||
people: [],
|
||||
defaults: defaultGitHubReviewPolicy(),
|
||||
repositories: {},
|
||||
},
|
||||
updatedByUserId: fixture.userId,
|
||||
});
|
||||
}
|
||||
await placeWaitTurnInSetupTest(fixture, { generation });
|
||||
|
||||
await expect(
|
||||
@@ -3866,9 +3934,11 @@ describe("native external-chat response wait", () => {
|
||||
.select({ resultJson: heartbeatRuns.resultJson })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, fixture.runId));
|
||||
const authorizationReason = provider === "github"
|
||||
? "internal_agent_write" : "allow_chat_run_presentation";
|
||||
await expect(
|
||||
resolveChatRunPresentationAuthorizationReason(db, fixture),
|
||||
).resolves.toBe("allow_chat_run_presentation");
|
||||
).resolves.toBe(authorizationReason);
|
||||
const response = resolveHeartbeatRunResponse({
|
||||
resultJson: finalizedRun!.resultJson,
|
||||
preferFinalResponseOverExistingComment: true,
|
||||
@@ -3884,14 +3954,14 @@ describe("native external-chat response wait", () => {
|
||||
fixture.issueId,
|
||||
response.text!,
|
||||
{ agentId: fixture.agentId, runId: fixture.runId },
|
||||
{ authorizationReason: "allow_chat_run_presentation" },
|
||||
{ authorizationReason },
|
||||
);
|
||||
await expect(
|
||||
db
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(eq(chatPublications.commentId, comment.id)),
|
||||
).resolves.toEqual([
|
||||
).resolves.toEqual(provider === "github" ? [] : [
|
||||
expect.objectContaining({
|
||||
conversationId: fixture.conversationId,
|
||||
endpointId: fixture.endpointId,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { nativeCompletionSource, buildNativeCompletionContract } from "./complet
|
||||
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { buildNativeExecutionInput } from "./native-execution-input.js";
|
||||
import { nativeRuntimeContextFixture } from "./runtime-context.test-fixture.js";
|
||||
import { buildNativeModelEnvelope, canonicalNativeRuntimeContextDigest } from "../../vendor/paperclip-runner/index.js";
|
||||
|
||||
describe("LCA-05 explicit native work mode", () => {
|
||||
it.each(["standard", "planning", "ask"])("title and description cannot override %s mode", (workMode) => {
|
||||
@@ -455,6 +456,28 @@ describe("native execution input external-chat framing", () => {
|
||||
"Never infer the file's contents or substitute an older file",
|
||||
);
|
||||
|
||||
const runtimeContext = nativeRuntimeContextFixture();
|
||||
runtimeContext.skills = ["configured", "provider-mentioned"].map((name) => ({
|
||||
key: `company/test/${name}`,
|
||||
runtimeName: name,
|
||||
versionId: null,
|
||||
bundle: { ...runtimeContext.instructions.bundle },
|
||||
}));
|
||||
runtimeContext.aggregateDigest = canonicalNativeRuntimeContextDigest(runtimeContext);
|
||||
const skillArgs = {
|
||||
...args,
|
||||
issue: { ...args.issue, description: "Stale provider message: /provider-mentioned" },
|
||||
taskPrompt: `${args.taskPrompt}\nGitHub message: /provider-mentioned`,
|
||||
runtimeContext,
|
||||
githubInstructionSkillKeys: ["company/test/configured", "company/other/unknown"],
|
||||
};
|
||||
const skillInput = buildNativeExecutionInput(skillArgs);
|
||||
expect(skillInput.task.description).toBe("Configured GitHub instruction skills:\n/configured");
|
||||
expect(buildNativeModelEnvelope(skillInput)).toMatchObject({ requestedSkills: ["configured"] });
|
||||
expect(buildNativeModelEnvelope(buildNativeExecutionInput({
|
||||
...skillArgs, githubInstructionSkillKeys: [],
|
||||
}))).toMatchObject({ requestedSkills: [] });
|
||||
|
||||
const wake = args.wakePayload as Record<string, unknown>;
|
||||
for (const patch of [
|
||||
{ externalChatProvider: "slack" },
|
||||
@@ -472,6 +495,13 @@ describe("native execution input external-chat framing", () => {
|
||||
expect(unrelated.task.prompt).not.toContain(
|
||||
"Paperclip owns recovery navigation for unavailable GitHub attachments",
|
||||
);
|
||||
if (["slack", "discord", "telegram", "microsoft-teams"].includes(patch.externalChatProvider ?? "")) {
|
||||
const unrelatedSkills = buildNativeExecutionInput({
|
||||
...skillArgs, wakePayload: { ...wake, ...patch },
|
||||
});
|
||||
expect(unrelatedSkills.task.description).toBeNull();
|
||||
expect(buildNativeModelEnvelope(unrelatedSkills)).toMatchObject({ requestedSkills: [] });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface BuildNativeExecutionInput {
|
||||
wakePayload?: unknown;
|
||||
/** Additive source ownership emitted by the server task/wake builders. */
|
||||
turnContext?: unknown;
|
||||
/** Skill keys selected from the admitted, manager-authored GitHub configuration. */
|
||||
githubInstructionSkillKeys?: readonly string[];
|
||||
resumedSession?: boolean;
|
||||
previousTurn?: { runId: string; task: { title: string; description: string | null } } | null;
|
||||
conversationMode?: boolean;
|
||||
@@ -184,6 +186,15 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
const externalChatTurn =
|
||||
isPaperclipExternalChatContractTurn(wakePayload) ||
|
||||
isPaperclipExternalChatQuestionResponseTurn(wakePayload);
|
||||
const configuredGitHubSkillKeys = new Set(input.githubInstructionSkillKeys ?? []);
|
||||
const configuredGitHubSkills = externalChatTurn && wake?.externalChatProvider === "github"
|
||||
? input.runtimeContext.skills.filter((skill) => configuredGitHubSkillKeys.has(skill.key))
|
||||
: [];
|
||||
// Native skill invocation reads task.description. Project only verified skill
|
||||
// names here; never restore the first provider message or scan untrusted text.
|
||||
const externalChatSkillDescription = configuredGitHubSkills.length > 0
|
||||
? `Configured GitHub instruction skills:\n${configuredGitHubSkills.map((skill) => `/${skill.runtimeName}`).join("\n")}`
|
||||
: null;
|
||||
const taskPrompt = [
|
||||
wakePrompt,
|
||||
// Durable task questions must survive the current provider turn.
|
||||
@@ -232,7 +243,7 @@ export function buildNativeExecutionInput(input: BuildNativeExecutionInput): Nat
|
||||
// explicitly labeled background, but give authenticated external-chat
|
||||
// turns neutral structured fields.
|
||||
title: externalChatTurn ? "External chat follow-up" : input.issue.title,
|
||||
description: externalChatTurn ? null : input.issue.description,
|
||||
description: externalChatTurn ? externalChatSkillDescription : input.issue.description,
|
||||
prompt: taskPrompt,
|
||||
workMode: input.issue.workMode,
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ import { commitNativeStatusDecision } from "./status-decision-committer.js";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
import { recordNativeAttentionAssessment } from "./work-assessments.js";
|
||||
import { formatDurableQuestionResponseSummary } from "../question-response-delivery.js";
|
||||
import { findSatisfiedToolConnection } from "../satisfied-connection-intents.js";
|
||||
import {
|
||||
NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
type NativeAuthoritativeIssueStatus,
|
||||
@@ -163,7 +164,7 @@ export async function materializeNativeInteractionResponses(input: {
|
||||
if (requestedIds.size === 0) return [];
|
||||
const [interactions, issue] = await Promise.all([
|
||||
issueThreadInteractionService(input.db).listForIssue(input.issueId),
|
||||
input.db.select({ status: issues.status }).from(issues).where(and(
|
||||
input.db.select().from(issues).where(and(
|
||||
eq(issues.id, input.issueId),
|
||||
eq(issues.companyId, input.companyId),
|
||||
)).limit(1).then((rows) => rows[0] ?? null),
|
||||
@@ -201,6 +202,19 @@ export async function materializeNativeInteractionResponses(input: {
|
||||
if (invocation.status !== expectedInvocationStatus || (request.status !== "rejected" && interaction.result?.toolAction?.status !== request.status)) throw new NativeInteractionBridgeError("native_interaction_governed_result_mismatch", "Tool review outcome does not match its invocation");
|
||||
}
|
||||
const interactionResult = record(interaction.result);
|
||||
if (interaction.kind === "connection_intent" && interaction.status === "expired"
|
||||
&& interactionResult.outcome === "expired" && typeof interactionResult.connectionId === "string") {
|
||||
const [run] = await input.db.select({ responsibleUserId: heartbeatRuns.responsibleUserId }).from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.companyId, input.companyId), eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.agentId, input.agentId)));
|
||||
const ready = issue.assigneeAgentId === input.agentId && run?.responsibleUserId === interaction.addresseeUserId
|
||||
? await findSatisfiedToolConnection(input.db, issue, { ...interaction, addresseeUserId: interaction.addresseeUserId ?? null, status: "pending" }, input.runId) : null;
|
||||
if (!ready || ready.id !== interactionResult.connectionId) {
|
||||
throw new NativeInteractionBridgeError("native_interaction_unresolved", "The existing connection is no longer available");
|
||||
}
|
||||
responses.push({ interactionId: interaction.id, kind: interaction.kind,
|
||||
response: { status: "expired", result: structuredClone(interaction.result) as unknown as Record<string, unknown> } });
|
||||
continue;
|
||||
}
|
||||
const supersessionOutcome = interaction.status === "expired"
|
||||
&& ["superseded_by_newer_request", "superseded_by_comment", "stale_target"].includes(String(interactionResult.outcome));
|
||||
if (supersessionOutcome) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { eligibleIssueMonitorWait } from "../issue-monitors.js";
|
||||
import { isNativePlanWaitResult, readNativePlanWait } from "./native-plan-wait.js";
|
||||
import { activeIssueInteractionCondition } from "../issue-question-context.js";
|
||||
import { hasPendingNativeChildCompletion } from "./native-child-completion-delivery.js";
|
||||
import { settleSlackConversation } from "../slack-conversation-lifecycle.js";
|
||||
import { settleExternalConversation } from "../slack-conversation-lifecycle.js";
|
||||
import { executionFailureRetryCount } from "../execution-recovery-attempt.js";
|
||||
import { readPersistedNativeProviderFailure } from "./native-provider-failure-evidence.js";
|
||||
import { dismissAutomaticCompletionReviews } from "./automatic-completion-reviews.js";
|
||||
@@ -607,7 +607,10 @@ async function resolveCommittedFinalizationRecovery(db: Db, run: typeof heartbea
|
||||
eq(issueRecoveryActions.companyId, run.companyId), eq(issueRecoveryActions.sourceIssueId, issueId),
|
||||
eq(issueRecoveryActions.kind, "active_run_watchdog"), inArray(issueRecoveryActions.status, ["active", "escalated"]),
|
||||
sql`${issueRecoveryActions.evidence}->>'runId' = ${run.id}`,
|
||||
sql`${issueRecoveryActions.wakePolicy}->>'kind' = 'resume_native_run'`,
|
||||
or(
|
||||
sql`${issueRecoveryActions.wakePolicy}->>'kind' = 'resume_native_run'`,
|
||||
eq(issueRecoveryActions.cause, "native_workspace_finalization_owner_unverified"),
|
||||
),
|
||||
));
|
||||
for (const action of actions) await issueRecoveryActionService(db).resolveActiveForIssue({
|
||||
companyId: run.companyId, sourceIssueId: issueId, actionId: action.id,
|
||||
@@ -1176,7 +1179,7 @@ export async function finalizeNativeRun(input: {
|
||||
runId: run.id,
|
||||
});
|
||||
if (input.projectRunStatus) {
|
||||
await settleSlackConversation(input.db, run.companyId, coordinator.issueId).catch((err) => {
|
||||
await settleExternalConversation(input.db, run.companyId, coordinator.issueId).catch((err) => {
|
||||
logger.warn({ err, runId: run.id }, "Slack conversation settlement deferred to reconciliation");
|
||||
});
|
||||
}
|
||||
@@ -1581,7 +1584,7 @@ export async function finalizeNativeRun(input: {
|
||||
runId: run.id,
|
||||
});
|
||||
if (input.projectRunStatus && finalizationPhase === "committed") {
|
||||
await settleSlackConversation(input.db, run.companyId, coordinator.issueId).catch((err) => {
|
||||
await settleExternalConversation(input.db, run.companyId, coordinator.issueId).catch((err) => {
|
||||
logger.warn({ err, runId: run.id }, "Slack conversation settlement deferred to reconciliation");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
type NativeExecutionInput,
|
||||
type PrpEvent,
|
||||
} from "@paperclipai/paperclip-runner";
|
||||
import { createHash, generateKeyPairSync } from "node:crypto";
|
||||
import { createHash, generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { nativeSha256 } from "./canonical.js";
|
||||
import { agentDirectoryWorkingCopyService } from "../agent-directory-working-copies.js";
|
||||
@@ -203,6 +203,8 @@ const state = vi.hoisted(() => ({
|
||||
async (): Promise<Record<string, unknown> | null> => null,
|
||||
),
|
||||
assertCurrentWakeCommentsRead: vi.fn(async () => undefined),
|
||||
bundledPackManifestPath: null as string | null,
|
||||
bundledRunnerBinary: null as string | null,
|
||||
resolveRunnerBinary: vi.fn(() => "/tmp/paperclip-runnerd"),
|
||||
release: null as null | (() => void),
|
||||
}));
|
||||
@@ -231,6 +233,8 @@ vi.mock("../../vendor/paperclip-runner/index.js", async (importOriginal) => {
|
||||
>();
|
||||
return {
|
||||
...original,
|
||||
bundledRemoteProviderPackManifestPath: () => state.bundledPackManifestPath ?? original.bundledRemoteProviderPackManifestPath(),
|
||||
bundledRemoteRunnerBinary: () => state.bundledRunnerBinary ?? original.bundledRemoteRunnerBinary(),
|
||||
createNativeSessionBackend: state.createBackend,
|
||||
createRunnerdCodexTransport: state.createTransport,
|
||||
executeNativeSession: state.execute,
|
||||
@@ -365,6 +369,8 @@ import {
|
||||
} from "./native-session-executor.js";
|
||||
|
||||
beforeEach(() => {
|
||||
state.bundledPackManifestPath = null;
|
||||
state.bundledRunnerBinary = null;
|
||||
state.createAssignedMcpTools.mockReset();
|
||||
state.resolveRunnerBinary.mockReset().mockReturnValue("/tmp/paperclip-runnerd");
|
||||
state.resolveCurrentWakeCommentsBinding.mockReset().mockResolvedValue(null);
|
||||
@@ -828,7 +834,7 @@ describe("remote runner process supervision", () => {
|
||||
expect(handle.child.exitCode).toBeNull();
|
||||
});
|
||||
|
||||
it("terminates a detached runner when its process identity cannot be adopted", async () => {
|
||||
it.each(["missing marker", "missing fingerprint"])("terminates a detached runner with %s before admitting managed work", async kind => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let launchNonce = "";
|
||||
@@ -847,10 +853,10 @@ describe("remote runner process supervision", () => {
|
||||
}
|
||||
if (label === "paperclip-runner-process-identity") {
|
||||
return {
|
||||
exitCode: 3,
|
||||
exitCode: kind === "missing fingerprint" ? 0 : 3,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stdout: kind === "missing fingerprint" ? `${launchNonce}\n4321\n2026-09-06T00:00:00.000Z\nrunner-remote\n` : "",
|
||||
stderr: "",
|
||||
};
|
||||
}
|
||||
@@ -881,6 +887,7 @@ describe("remote runner process supervision", () => {
|
||||
stateDirectory: "/runtime",
|
||||
diagnosticsDirectory: "/runtime/diagnostics",
|
||||
runnerInstanceId: "runner-remote",
|
||||
requireProcessStartFingerprint: true,
|
||||
});
|
||||
|
||||
const handle = launcher({
|
||||
@@ -1042,6 +1049,10 @@ describe("native provider usage normalization", () => {
|
||||
it("reports the actual runtime billing identity", () => {
|
||||
expect(resolveNativeBilling({ kind: "opencode", model: "openrouter/deepseek/test", permissionMode: "allow" }))
|
||||
.toEqual({ provider: "deepseek", biller: "openrouter", billingType: "unknown" });
|
||||
expect(resolveNativeBilling({ kind: "opencode", model: "openrouter/deepseek/test", permissionMode: "allow" }, { OPENROUTER_API_KEY: "fixture" }))
|
||||
.toEqual({ provider: "deepseek", biller: "openrouter", billingType: "metered_api" });
|
||||
expect(resolveNativeBilling({ kind: "codex", model: "gpt-6.1-sol" } as NativeExecutionInput["provider"], { CODEX_API_KEY: "fixture" }))
|
||||
.toEqual({ provider: "openai", biller: "openai", billingType: "metered_api" });
|
||||
expect(resolveNativeBilling({ kind: "acpx", agent: "claude", model: "claude-test", permissionMode: "approve-all",
|
||||
profile: { driverKind: "acpx_runtime", protocolVersion: 1, acpxVersion: "0.13.1", agent: "claude", agentProfileVersion: 1,
|
||||
agentServerPackage: "@zed-industries/claude-agent-acp", agentServerVersion: "1", agentRuntimePackage: null,
|
||||
@@ -1056,6 +1067,8 @@ describe("native provider usage normalization", () => {
|
||||
it.each(["OPENAI_BASE_URL", "OPENAI_API_BASE", "OPENAI_API_BASE_URL"])("does not price native or managed proxy traffic as direct OpenAI using %s", key => {
|
||||
const provider = { kind: "codex", model: "gpt-6-astra" } as Parameters<typeof resolveNativeBilling>[0];
|
||||
expect(resolveNativeBilling(provider, { OPENAI_API_KEY: "fixture", [key]: "https://proxy.example/v1" }).biller).toBe("unknown");
|
||||
expect(resolveNativeBilling({ kind: "opencode", model: "openai/gpt-6-astra", permissionMode: "allow" }, { OPENAI_API_KEY: "fixture", [key]: "https://proxy.example/v1" }))
|
||||
.toMatchObject({ provider: "openai", biller: "unknown", billingType: "metered_api" });
|
||||
expect(resolveNativeBilling(provider, { [key]: "https://proxy.example/v1" }, { provider: "openai", biller: "openai", billingType: "metered_api" }).biller).toBe("unknown");
|
||||
expect(resolveNativeBilling(provider, {}, { provider: "openai", biller: "openai", billingType: "metered_api" })).toMatchObject({ biller: "openai", billingType: "metered_api" });
|
||||
});
|
||||
@@ -1120,6 +1133,63 @@ describe("remote provider pack manifest", () => {
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
|
||||
it.each(["opencode", "cursor"] as const)("uses the bundled Linux image authority for remote %s without controller overrides", async provider => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-bundled-image-"));
|
||||
const digest = "sha256:" + "a".repeat(64);
|
||||
const artifacts = Object.fromEntries(Object.entries({
|
||||
grokLauncher: "dist/providers/grok/launcher.cjs", nodeCommand: "node_modules/node/bin/node",
|
||||
productionLock: "pnpm-lock.yaml", opencodeCommand: "node_modules/.bin/opencode",
|
||||
opencodeExecutable: "node_modules/opencode-ai/bin/opencode.exe",
|
||||
opencodeProxy: "dist/cli/opencode-app-server-proxy.cjs", acpxSidecar: "dist/cli/acpx-runtime-sidecar.cjs",
|
||||
}).map(([key, path]) => [key, { path, sha256: digest }]));
|
||||
const payload = {
|
||||
target: { platform: "linux", architecture: "x64" }, runnerSourceRevision: "a".repeat(40),
|
||||
pins: { nodeMinimum: "24.11.0", codex: QUALIFIED_ACPX_PROFILES.codex.agentRuntimeVersion,
|
||||
opencode: "1.18.34", acpx: "0.13.1", grok: QUALIFIED_ACPX_PROFILES.grok.agentRuntimeVersion,
|
||||
claudeAcp: QUALIFIED_ACPX_PROFILES.claude.agentServerVersion, codexAcp: QUALIFIED_ACPX_PROFILES.codex.agentServerVersion },
|
||||
acpxProfileDigests: Object.fromEntries(["grok", "claude", "codex"].map(agent =>
|
||||
[agent, QUALIFIED_ACPX_PROFILES[agent as "grok" | "claude" | "codex"].commandDigest])),
|
||||
distDigest: digest, artifacts,
|
||||
bridgeDigest: "sha256:" + createHash("sha256").update(digest + "\n" + digest + "\n" + digest).digest("hex"),
|
||||
};
|
||||
const manifest = { schema: "paperclip-runner/remote-provider-pack/v1", payload,
|
||||
digest: "sha256:" + createHash("sha256").update(canonical(payload)).digest("hex") };
|
||||
state.bundledPackManifestPath = join(root, "provider-pack.json");
|
||||
state.bundledRunnerBinary = join(root, "linux-paperclip-runnerd");
|
||||
await writeFile(state.bundledPackManifestPath, JSON.stringify(manifest));
|
||||
await writeFile(state.bundledRunnerBinary, "verified Linux daemon fixture");
|
||||
const remoteExecution = { ...execution, provider: provider === "opencode"
|
||||
? { kind: "opencode", model: null } : { kind: "acpx", agent: "cursor", model: null },
|
||||
session: { ...execution.session, driverKind: provider === "opencode" ? "opencode_server" : "acpx_runtime" },
|
||||
} as unknown as NativeExecutionInputV1;
|
||||
const syncIn = vi.fn(async () => undefined);
|
||||
const execute = vi.fn(async (command: { command: string; args?: string[] }) => {
|
||||
if (command.args?.[0] === "--build-metadata") throw new Error("reached-staged-Linux-daemon-verification");
|
||||
return { exitCode: 0, signal: null, timedOut: false, stdout: "", stderr: "" };
|
||||
});
|
||||
try {
|
||||
await createRunnerdBackend({ db: leaseDb(remoteExecution), execution: remoteExecution,
|
||||
runnerInstanceId: "bundled-image-runner", runnerIngressAuthorized: true,
|
||||
runnerExecutionTarget: { kind: "remote", transport: "sandbox", remoteCwd: "/workspace",
|
||||
environmentId: "environment", leaseId: "lease", providerKey: "daytona",
|
||||
effectiveCapabilities: { runnerWebSocketIngress: true }, runner: { execute, syncIn } } as never });
|
||||
state.createTransport.mockClear();
|
||||
state.createBackend.mock.calls.at(-1)![1].codexTransportFactory!();
|
||||
const transport = state.createTransport.mock.calls.at(-1)![0] as RunnerTransportOptions & {
|
||||
controlPlaneRegistration: (authority: unknown) => Promise<unknown>;
|
||||
};
|
||||
expect(transport.runnerBinary).toBe(state.bundledRunnerBinary);
|
||||
await expect(transport.controlPlaneRegistration({})).rejects.toThrow("reached-staged-Linux-daemon-verification");
|
||||
expect(syncIn).toHaveBeenCalledWith([expect.objectContaining({ files: [expect.objectContaining({
|
||||
sourcePath: state.bundledRunnerBinary, targetPath: "/workspace/.paperclip-runtime/paperclip-runner/bin/paperclip-runnerd",
|
||||
})] })]);
|
||||
expect(execute.mock.calls.some(([call]) => call.args?.[1] === "uname -s; uname -m")).toBe(false);
|
||||
} finally {
|
||||
state.bundledPackManifestPath = null; state.bundledRunnerBinary = null;
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a fully digested pack and rejects artifact tampering", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "paperclip-provider-pack-"));
|
||||
await mkdir(join(root, "dist", "cli"), { recursive: true });
|
||||
@@ -2521,6 +2591,14 @@ describe("remote runner build metadata", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("requires an explicit Dot bridge capability only for remote Dot execution", () => {
|
||||
expect(() => assertRemoteRunnerBuildMetadata(current, "listen_ws", "openai_dot_mcp"))
|
||||
.toThrow("runner_remote_dot_capability_missing");
|
||||
expect(() => assertRemoteRunnerBuildMetadata({ ...current, externalProviderCapabilities: ["openai_dot_mcp"] },
|
||||
"listen_ws", "openai_dot_mcp")).not.toThrow();
|
||||
expect(() => assertRemoteRunnerBuildMetadata(current, "listen_ws")).not.toThrow();
|
||||
});
|
||||
|
||||
it("fails before dispatch when a preinstalled runner uses the stale contract", () => {
|
||||
expect(() =>
|
||||
assertRemoteRunnerBuildMetadata(
|
||||
@@ -12890,8 +12968,9 @@ describe("runnerd provider runtime wiring", () => {
|
||||
["opencode", { kind: "opencode", model: null }, "opencode_server"],
|
||||
["acpx", { kind: "acpx", agent: "codex", model: null }, "acpx_runtime"],
|
||||
])(
|
||||
"requires the build-owned provider pack before launching remote %s",
|
||||
"requires a build-owned provider pack identity before launching remote %s",
|
||||
async (providerKind, provider, driverKind) => {
|
||||
state.bundledPackManifestPath = join(tmpdir(), randomUUID(), "provider-pack.json");
|
||||
const remoteCwd = "/home/daytona/paperclip-workspace";
|
||||
const remoteProviderExecution = {
|
||||
...execution,
|
||||
@@ -12929,8 +13008,9 @@ describe("runnerd provider runtime wiring", () => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"runner_remote_provider_artifact_incompatible: configure PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH",
|
||||
).rejects.toThrow(providerKind === "opencode"
|
||||
? "runner_remote_provider_artifact_incompatible: provider-pack.json is unreadable"
|
||||
: "runner_remote_provider_artifact_incompatible: configure PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH",
|
||||
);
|
||||
expect(state.createBackend).not.toHaveBeenCalled();
|
||||
},
|
||||
@@ -13061,3 +13141,10 @@ it.each([
|
||||
),
|
||||
).toBe("native_event_replay_conflict");
|
||||
});
|
||||
|
||||
it("keeps an ACPX missing runtime package failure actionable and stops automatic retries", () => {
|
||||
const failure = new Error('PRP command session.open failed: {"result":{"code":"command_execution_failed","message":"failed to start ACPX provider: ACPX sidecar command session.open was rejected (retryable=false, classification=runtime_package_missing)"},"status":"failed"}');
|
||||
const code = nativeSessionFailureSourceCode(failure);
|
||||
expect(code).toBe("native_provider_runtime_missing");
|
||||
expect(nativeSessionFailureDisposition(1, new Date(), code)).toEqual({ phase: "terminal_failure", failureCode: code, nextAttemptAt: null });
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { configuredEnvironment } from "../../vendor/paperclip-runner/index.js";
|
||||
import { apiKeyBillingType } from "@paperclipai/adapter-utils/api-billing-type";
|
||||
import { agents } from "@paperclipai/db";
|
||||
import { isCloudManagedInstance } from "../cloud-instance.js";
|
||||
import { dotRunnerBroker } from "../dot-runner-broker.js";
|
||||
import { CURSOR_DISTRIBUTION_PINS, QUALIFIED_ACPX_PROFILES, QUALIFIED_ACPX_VERSION } from "../../vendor/paperclip-runner/index.js";
|
||||
import { isProviderMode } from "../../vendor/paperclip-runner/index.js";
|
||||
@@ -6187,6 +6189,7 @@ export function nativeSessionFailureDisposition(
|
||||
sourceFailureCode?: ReturnType<typeof nativeSessionFailureSourceCode>,
|
||||
) {
|
||||
const permanentFailure =
|
||||
sourceFailureCode === "native_provider_runtime_missing" ||
|
||||
sourceFailureCode === "native_provider_model_rejected" ||
|
||||
sourceFailureCode === "native_provider_approval_required" ||
|
||||
sourceFailureCode === "native_event_replay_conflict" ||
|
||||
@@ -6260,6 +6263,7 @@ export function nativeSessionFailureSourceCode(
|
||||
| "native_runner_process_exited"
|
||||
| "planning_mode_unsupported"
|
||||
| "native_event_replay_conflict"
|
||||
| "native_provider_runtime_missing"
|
||||
| "native_provider_model_rejected"
|
||||
| "native_current_wake_comments_unread"
|
||||
| "native_current_wake_comments_changed_after_read"
|
||||
@@ -6282,6 +6286,8 @@ export function nativeSessionFailureSourceCode(
|
||||
if (error instanceof NativeSessionCleanupQuarantinedError)
|
||||
return "native_session_cleanup_quarantined";
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/classification=runtime_package_missing\)/.test(message))
|
||||
return "native_provider_runtime_missing";
|
||||
if (/native_provider_model_rejected/i.test(message))
|
||||
return "native_provider_model_rejected";
|
||||
if (/native_adopted_runner_authentication_timeout/i.test(message)) {
|
||||
@@ -7525,6 +7531,11 @@ export async function executePaperclipNativeSession(input: {
|
||||
await retireSupersededWarmNativeSessions(
|
||||
ownerScope, nativeSessionScopeKey(input.execution),
|
||||
);
|
||||
if (input.execution.provider.kind === "openai_dot" && isCloudManagedInstance()
|
||||
&& (!input.useRunnerd || input.runnerExecutionTarget?.kind !== "remote"
|
||||
|| input.runnerExecutionTarget.transport !== "sandbox")) {
|
||||
throw new Error("dot_cloud_requires_managed_runner");
|
||||
}
|
||||
if (!input.useRunnerd) {
|
||||
return await executePaperclipNativeSessionWithinScope(input);
|
||||
}
|
||||
@@ -7546,7 +7557,6 @@ export async function executePaperclipNativeSession(input: {
|
||||
await closingWarmNativeSessions.get(sessionScopeId);
|
||||
|
||||
if (input.execution.provider.kind === "openai_dot") {
|
||||
if (input.runnerExecutionTarget?.kind === "remote") throw new Error("dot_requires_local_runner_controller");
|
||||
return await executePaperclipNativeSessionWithinScope(input);
|
||||
}
|
||||
const workspaceRoot = input.execution.workspace.cwd;
|
||||
@@ -8532,7 +8542,7 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
: []),
|
||||
),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
activeIssueInteractionCondition(),
|
||||
activeIssueInteractionCondition({ runId: input.execution.binding.runId }),
|
||||
// Live provider questions resume their current turn; only durable
|
||||
// wake-based cards park it. A timeout creates a separate fallback.
|
||||
sql`not (${issueThreadInteractions.kind} = 'ask_user_questions' and ${issueThreadInteractions.continuationPolicy} = 'none' and coalesce(${issueThreadInteractions.idempotencyKey}, '') like 'paperclip-runner-question:%')`,
|
||||
@@ -9784,11 +9794,15 @@ export function resolveNativeBilling(provider: NativeExecutionInput["provider"],
|
||||
const parts = (provider.model ?? "").split("/");
|
||||
const biller = parts.length > 1 ? parts[0] : provider.kind === "acpx" ? nativeUsageBiller(provider) : "unknown";
|
||||
const modelProvider = biller === "openrouter" && parts.length > 2 ? parts[1] : biller;
|
||||
return { provider: modelProvider, biller, billingType: "unknown" };
|
||||
const billingType = apiKeyBillingType(biller, env);
|
||||
const effectiveBiller = biller === "openai" && billingType === "metered_api"
|
||||
? inferOpenAiCompatibleBiller(env, "openai") ?? "unknown" : biller;
|
||||
return { provider: modelProvider, biller: effectiveBiller, billingType };
|
||||
}
|
||||
if (provider.kind !== "codex" && !(provider.kind === "acpx" && provider.agent === "codex"))
|
||||
return { provider: "unknown", biller: "unknown", billingType: "unknown" };
|
||||
return { provider: "openai", biller: env.OPENAI_API_KEY ? inferOpenAiCompatibleBiller(env, "openai") : "unknown", billingType: env.OPENAI_API_KEY ? "metered_api" : "unknown" };
|
||||
const billingType = apiKeyBillingType("openai", env, "codex");
|
||||
return { provider: "openai", biller: billingType === "metered_api" ? inferOpenAiCompatibleBiller(env, "openai") : "unknown", billingType };
|
||||
}
|
||||
|
||||
export function normalizeNativeUsage(usage: Record<string, unknown> | null, options: { inputIncludesCacheReads?: boolean } = {}) {
|
||||
@@ -10131,6 +10145,7 @@ function readRemoteProviderPackIdentity(packRoot: string, verifyControllerFiles:
|
||||
export function assertRemoteRunnerBuildMetadata(
|
||||
value: unknown,
|
||||
requiredMode: "dial_wss" | "listen_ws",
|
||||
requiredExternalProvider?: "openai_dot_mcp",
|
||||
): void {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("runner_remote_artifact_metadata_invalid");
|
||||
@@ -10147,6 +10162,10 @@ export function assertRemoteRunnerBuildMetadata(
|
||||
// Older sandbox images share binary contract v2, but reject the zero
|
||||
// lifetime now used by the controller and cannot renew connection leases.
|
||||
// Reject them before launch so preparation stages the bundled runner instead.
|
||||
if (requiredExternalProvider && (!Array.isArray(metadata.externalProviderCapabilities)
|
||||
|| !metadata.externalProviderCapabilities.includes(requiredExternalProvider))) {
|
||||
throw new Error("runner_remote_dot_capability_missing: install the current Paperclip Runner artifact");
|
||||
}
|
||||
const sessionCapabilities = Array.isArray(metadata.durableSessionCapabilities)
|
||||
? metadata.durableSessionCapabilities
|
||||
: [];
|
||||
@@ -10762,7 +10781,8 @@ async function waitForRemoteRunnerProcessIdentity(input: {
|
||||
identityPath: string;
|
||||
nonce: string;
|
||||
runnerInstanceId: string;
|
||||
}): Promise<{ pid: number; startedAt: string }> {
|
||||
requireProcessStartFingerprint?: boolean;
|
||||
}): Promise<{ pid: number; startedAt: string; processStartFingerprint?: string }> {
|
||||
const deadline = Date.now() + REMOTE_RUNNER_PROCESS_IDENTITY_WAIT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const result = await input.runner
|
||||
@@ -10782,7 +10802,7 @@ async function waitForRemoteRunnerProcessIdentity(input: {
|
||||
result && result.exitCode === 0 && !result.timedOut
|
||||
? parseRemoteRunnerProcessIdentity(result.stdout, input)
|
||||
: null;
|
||||
if (identity) return identity;
|
||||
if (identity && (!input.requireProcessStartFingerprint || identity.processStartFingerprint)) return identity;
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error("runner_remote_process_identity_unavailable");
|
||||
@@ -10821,6 +10841,7 @@ export function createRemoteRunnerProcessLauncher(input: {
|
||||
diagnosticsDirectory: string;
|
||||
runnerInstanceId: string;
|
||||
ensureArtifact?: () => Promise<void>;
|
||||
requireProcessStartFingerprint?: boolean;
|
||||
onSpawn?: (meta: {
|
||||
pid: number;
|
||||
processGroupId: number | null;
|
||||
@@ -10956,6 +10977,7 @@ export function createRemoteRunnerProcessLauncher(input: {
|
||||
identityPath: input.processIdentityPath,
|
||||
nonce: identityNonce,
|
||||
runnerInstanceId: input.runnerInstanceId,
|
||||
requireProcessStartFingerprint: input.requireProcessStartFingerprint,
|
||||
});
|
||||
} catch {
|
||||
const cleanupStartedAtMs = Date.now();
|
||||
@@ -11196,6 +11218,129 @@ export async function createRunnerdBackend(input: {
|
||||
}
|
||||
}
|
||||
|
||||
/** Dot uses the same managed process launcher and authenticated PRP transports.
|
||||
* Its execution contract still exposes no workspace, shell, or provider secrets. */
|
||||
async function prepareRemoteDotRunner({ input, target, runner, root, identity }: {
|
||||
input: Parameters<typeof createRunnerdBackend>[0];
|
||||
target: Extract<AdapterExecutionTarget, { kind: "remote" }>;
|
||||
runner: CommandManagedRuntimeRunner;
|
||||
root: string;
|
||||
identity: NonNullable<NonNullable<Parameters<typeof createNativeSessionBackend>[1]>["dotRunnerOptions"]>["identity"];
|
||||
}) {
|
||||
const runnerBinary = input.runnerRemoteBinaryPath?.trim() || resolvePaperclipRunnerBinary();
|
||||
const remoteRoot = posix.join(target.remoteCwd, ".paperclip-runtime", "paperclip-runner");
|
||||
const remoteBinary = posix.join(remoteRoot, "bin", "paperclip-runnerd");
|
||||
const runnerStateDirectory = posix.join(remoteRoot, "sessions",
|
||||
createHash("sha256").update(identity.normalizedSessionId).digest("hex"), "runner");
|
||||
const requiredMode = resolveRemoteRunnerTransportMode({ target, runnerIngressAuthorized: input.runnerIngressAuthorized === true });
|
||||
const artifact = readRunnerdArtifactBinding(runnerBinary);
|
||||
const prepare = async () => {
|
||||
if (input.restartRecovery?.kind !== "reattach_remote_runner") {
|
||||
if (!input.runnerRemoteBinaryPath?.trim()) {
|
||||
const platform = await runner.execute({ command: "sh", args: ["-c", "uname -s; uname -m"], bypassSession: true, timeoutMs: 10000 });
|
||||
const [os, arch] = platform.stdout.trim().split(/\r?\n/);
|
||||
if (platform.exitCode !== 0 || platform.timedOut || os !== (process.platform === "darwin" ? "Darwin" : "Linux")
|
||||
|| ![process.arch === "x64" ? "x86_64" : "aarch64", process.arch].includes(arch)) {
|
||||
throw new Error("runner_remote_artifact_platform_mismatch: configure PAPERCLIP_RUNNER_REMOTE_BINARY_PATH for the sandbox platform");
|
||||
}
|
||||
}
|
||||
await stageRemoteRunnerFile({ target, runner, sourcePath: runnerBinary, targetPath: remoteBinary, mode: 0o700 });
|
||||
}
|
||||
const metadata = await runner.execute({ command: remoteBinary, args: ["--build-metadata"], bypassSession: true, timeoutMs: 30000 });
|
||||
if (metadata.exitCode !== 0 || metadata.timedOut) throw new Error("runner_remote_artifact_verification_failed");
|
||||
assertRemoteRunnerBuildMetadata(JSON.parse(metadata.stdout), requiredMode, "openai_dot_mcp");
|
||||
const checksum = await runner.execute({ command: "sh", args: ["-c",
|
||||
'if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1"; else shasum -a 256 "$1"; fi', "paperclip-dot-artifact", remoteBinary],
|
||||
bypassSession: true, timeoutMs: 10000 });
|
||||
if (checksum.exitCode !== 0 || checksum.timedOut || `sha256:${checksum.stdout.trim().split(/\s+/)[0]}` !== artifact.digest) {
|
||||
throw new Error("runner_remote_artifact_verification_failed");
|
||||
}
|
||||
};
|
||||
const readProviderState = async () => {
|
||||
const result = await runner.execute({ command: "sh", args: ["-c",
|
||||
'set -eu; if test ! -e "$1" && test ! -L "$1"; then exit 3; fi; test -f "$1" && test ! -L "$1"; test "$(wc -c < "$1")" -le 33554432; base64 < "$1"',
|
||||
"paperclip-dot-checkpoint", posix.join(runnerStateDirectory, "dot-provider-state.json")], bypassSession: true, timeoutMs: 10000 });
|
||||
if (result.exitCode === 3 && !result.timedOut) return null;
|
||||
if (result.exitCode !== 0 || result.timedOut) throw new Error("dot_provider_checkpoint_unavailable");
|
||||
const state = record(JSON.parse(Buffer.from(result.stdout.replace(/\s+/g, ""), "base64").toString("utf8")));
|
||||
if (state.schema !== "paperclip.runner.dot-provider-state.v1" || state.runId !== identity.runId
|
||||
|| state.sessionId !== identity.normalizedSessionId || state.turnId !== identity.turnId) throw new Error("dot_runner_checkpoint_authority_mismatch");
|
||||
// Retain inspectable recovery evidence on the controller without changing
|
||||
// which execution target is authoritative for replay.
|
||||
mkdirSync(join(root, "runner"), { recursive: true, mode: 0o700 });
|
||||
const saved = join(root, "runner", "dot-provider-state.json");
|
||||
const temporary = `${saved}.${randomUUID()}.tmp`;
|
||||
writeFileSync(temporary, JSON.stringify(state), { mode: 0o600 });
|
||||
renameSync(temporary, saved);
|
||||
return state;
|
||||
};
|
||||
let spawned: () => void = () => {};
|
||||
const processStarted = new Promise<void>(resolve => { spawned = resolve; });
|
||||
// A transport handshake alone does not prove a recoverable process owner.
|
||||
// Do not attach the Dot broker until the exact sandbox marker is validated.
|
||||
let identityReady: () => void = () => {};
|
||||
let identityFailed: (error: unknown) => void = () => {};
|
||||
const processIdentityReady = new Promise<void>((resolve, reject) => { identityReady = resolve; identityFailed = reject; });
|
||||
void processIdentityReady.catch(() => {});
|
||||
const adoptExistingRunner = input.restartRecovery?.kind === "reattach_remote_runner"
|
||||
? await verifyRemoteRunnerReattachment({ claim: input.restartRecovery, target, identity: { ...identity },
|
||||
runId: input.execution.binding.runId, normalizedSessionId: nativeSessionKey(input.execution) }) : undefined;
|
||||
if (adoptExistingRunner) { spawned(); identityReady(); }
|
||||
const launch = createRemoteRunnerProcessLauncher({ target, runner, remoteBinary,
|
||||
processIdentityPath: posix.join(runnerStateDirectory, "runner-process.identity"), stateDirectory: runnerStateDirectory,
|
||||
diagnosticsDirectory: posix.join(remoteRoot, "diagnostics"), runnerInstanceId: input.runnerInstanceId,
|
||||
requireProcessStartFingerprint: true,
|
||||
onSpawn: async meta => { await input.onSpawn?.(meta); identityReady(); },
|
||||
onLog: input.onLog, trace: input.trace, onRunnerProcessSpawned: () => spawned() });
|
||||
const runnerProcessLauncher: typeof launch = spec => {
|
||||
const handle = launch(spec);
|
||||
void handle.completion.then(() => identityFailed(new Error("runner_remote_process_identity_unavailable")), identityFailed);
|
||||
return handle;
|
||||
};
|
||||
const controlPlaneRegistration: NonNullable<NonNullable<NonNullable<Parameters<typeof createNativeSessionBackend>[1]>["dotRunnerOptions"]>["controlPlaneRegistration"]> = async authority => {
|
||||
let transport: PaperclipRunnerTransport;
|
||||
// Validate outbound URL before staging; provider ingress needs the staged
|
||||
// listener binary before requesting the provider's private endpoint.
|
||||
if (requiredMode === "dial_wss") {
|
||||
transport = await resolvePaperclipRunnerTransport({ target, runId: input.execution.binding.runId,
|
||||
localConnectUrl: "ws://127.0.0.1/unused", runnerPublicUrl: input.runnerPublicUrl,
|
||||
runnerCaBundlePath: input.runnerCaBundlePath, runnerIngressAuthorized: input.runnerIngressAuthorized === true });
|
||||
await prepare();
|
||||
} else {
|
||||
await prepare();
|
||||
transport = await resolvePaperclipRunnerTransport({ target, runId: input.execution.binding.runId,
|
||||
localConnectUrl: "ws://127.0.0.1/unused", runnerIngressAuthorized: input.runnerIngressAuthorized === true });
|
||||
}
|
||||
await input.onLog?.("stderr", `[paperclip-runner] Dot controller target=remote transport=${transport.mode}\n`);
|
||||
if (transport.mode !== "provider_ingress") {
|
||||
const registration = await registerRunnerPrpAuthority({ companyId: input.execution.binding.companyId,
|
||||
issueId: input.execution.binding.issueId, agentId: input.execution.binding.agentId,
|
||||
runId: input.execution.binding.runId, authority });
|
||||
let caBundlePath = transport.mode === "direct_outbound" ? transport.caBundlePath : undefined;
|
||||
if (caBundlePath) {
|
||||
const remoteCaBundlePath = posix.join(remoteRoot, "bin", "runner-ca-bundle.pem");
|
||||
await stageRemoteRunnerFile({ target, runner, sourcePath: caBundlePath, targetPath: remoteCaBundlePath, mode: 0o600 });
|
||||
caBundlePath = remoteCaBundlePath;
|
||||
}
|
||||
return { ...registration, ready: async () => { await processIdentityReady; }, connection: { mode: "connect" as const, connectUrl: transport.connectUrl,
|
||||
...(caBundlePath ? { caBundlePath } : {}) } };
|
||||
}
|
||||
let outbound: ReturnType<typeof connectRunnerPrpIngress> | null = null;
|
||||
let activation: Promise<void> | null = null;
|
||||
return { connection: { mode: "listen" as const, listenAddress: transport.listenAddress,
|
||||
listenPort: transport.listenPort, listenPath: transport.listenPath },
|
||||
activate: () => { activation = (async () => { await processStarted;
|
||||
outbound = connectRunnerPrpIngress({ authority, endpoint: transport.ingress }); })();
|
||||
void activation.catch(() => {}); },
|
||||
ready: async () => { await activation; if (!outbound) throw new Error("runner_ingress_unavailable"); await outbound.ready; await processIdentityReady; },
|
||||
get failure() { return outbound?.failure; },
|
||||
startupFailureCode: "runner_ingress_unavailable" as const,
|
||||
release: async () => { if (outbound) await outbound.close(); else await transport.ingress.close(); },
|
||||
};
|
||||
};
|
||||
return { runnerBinary, runnerStateDirectory, runnerProcessLauncher, readProviderState, controlPlaneRegistration, adoptExistingRunner };
|
||||
}
|
||||
|
||||
async function createRunnerdBackendWithinSessionClaim(
|
||||
input: Parameters<typeof createRunnerdBackend>[0],
|
||||
sessionScopeId: string,
|
||||
@@ -11321,24 +11466,31 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
input.execution.binding.executionWorkspaceId;
|
||||
mkdirSync(root, { recursive: true, mode: 0o700 });
|
||||
if (input.execution.schema === "paperclip.native-execution-input.v6") {
|
||||
if (target.kind !== "local") throw new Error("dot_runner_requires_local_controller: Dot has no mounted workspace or sandbox process");
|
||||
if (target.kind === "remote" && input.dotWorkspaceRoot) throw new Error("dot_remote_workspace_access_unavailable");
|
||||
const remoteDot = target.kind === "remote"
|
||||
? await prepareRemoteDotRunner({ input, target, runner: remoteCommandRunner!, root,
|
||||
identity: { runnerInstanceId: effectiveRunnerInstanceId, environmentLeaseId: effectiveEnvironmentLeaseId,
|
||||
runId: input.execution.binding.runId, normalizedSessionId: nativeSessionKey(input.execution),
|
||||
turnId: `turn-${input.execution.binding.runId}`, itemId: `item-${input.execution.binding.runId}` } })
|
||||
: null;
|
||||
const recoveredProcess = input.restartRecovery?.kind === "reattach_existing_runner" ? input.restartRecovery.process : null;
|
||||
const backend = createNativeSessionBackend(input.execution, {
|
||||
onSpawn: input.onSpawn, dynamicTools,
|
||||
dynamicToolHandler: async call => { await dotRunnerBroker(input.db).assertRunAuthority(input.execution as import("../../vendor/paperclip-runner/index.js").NativeExecutionInputV6); return authorityEpoch.execute(call); },
|
||||
completionFeedback: async result => { await dotRunnerBroker(input.db).assertRunAuthority(input.execution as import("../../vendor/paperclip-runner/index.js").NativeExecutionInputV6); await authorityEpoch.definitions(); return nativeCompletionFeedback(input.db, input.execution.binding.runId, result); },
|
||||
dotRunnerOptions: {
|
||||
stateDirectory: root, runnerBinary: resolvePaperclipRunnerBinary(),
|
||||
stateDirectory: root, runnerBinary: remoteDot?.runnerBinary ?? resolvePaperclipRunnerBinary(),
|
||||
...(remoteDot ?? {}),
|
||||
identity: { runnerInstanceId: effectiveRunnerInstanceId, environmentLeaseId: effectiveEnvironmentLeaseId,
|
||||
runId: input.execution.binding.runId, normalizedSessionId: nativeSessionKey(input.execution),
|
||||
turnId: `turn-${input.execution.binding.runId}`, itemId: `item-${input.execution.binding.runId}` },
|
||||
port: dotRunnerBroker(input.db).port(input.execution),
|
||||
controlPlaneRegistration: async authority => registerRunnerPrpAuthority({ companyId: input.execution.binding.companyId,
|
||||
controlPlaneRegistration: remoteDot?.controlPlaneRegistration ?? (async authority => registerRunnerPrpAuthority({ companyId: input.execution.binding.companyId,
|
||||
issueId: input.execution.binding.issueId, agentId: input.execution.binding.agentId,
|
||||
runId: input.execution.binding.runId, authority }),
|
||||
adoptExistingRunner: recoveredProcess ? { ...recoveredProcess,
|
||||
runId: input.execution.binding.runId, authority })),
|
||||
adoptExistingRunner: remoteDot?.adoptExistingRunner ?? (recoveredProcess ? { ...recoveredProcess,
|
||||
isAlive: () => verifiedRecoveryProcessIsAlive(recoveredProcess),
|
||||
signal: signal => signalVerifiedRecoveryProcess(recoveredProcess, signal) } : undefined,
|
||||
signal: signal => signalVerifiedRecoveryProcess(recoveredProcess, signal) } : undefined),
|
||||
},
|
||||
});
|
||||
const prior = sessionToolAuthorityEpochs.get(sessionScopeId); if (prior) prior.revoke();
|
||||
@@ -11368,9 +11520,10 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
const configuredProviderPackRoot =
|
||||
input.runnerRemoteProviderPackPath?.trim() || remotePiCompanion?.providerPack || null;
|
||||
let expectedProviderPackManifest: RemoteProviderPackManifest | null = null;
|
||||
const useBundledCursorImageAssets = requiresRemoteProviderPack && !configuredProviderPackRoot &&
|
||||
input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor";
|
||||
if (useBundledCursorImageAssets) {
|
||||
const useBundledRemoteImageAssets = requiresRemoteProviderPack && !configuredProviderPackRoot &&
|
||||
(input.execution.provider.kind === "opencode" ||
|
||||
(input.execution.provider.kind === "acpx" && input.execution.provider.agent === "cursor"));
|
||||
if (useBundledRemoteImageAssets) {
|
||||
expectedProviderPackManifest = readBundledRemoteProviderPackManifest();
|
||||
} else if (requiresRemoteProviderPack) {
|
||||
if (
|
||||
@@ -11399,7 +11552,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
// When an explicit remote artifact is configured, prepareRemoteRunner stages
|
||||
// these exact bytes at remoteBinary before launch.
|
||||
const controllerRunnerBinary = remoteTarget
|
||||
? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledCursorImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
|
||||
? input.runnerRemoteBinaryPath?.trim() || remotePiCompanion?.runnerBinary || (useBundledRemoteImageAssets ? bundledRemoteRunnerBinary() : resolvePaperclipRunnerBinary())
|
||||
: resolvePaperclipRunnerBinary();
|
||||
const explicitRemoteCodex = input.runnerRemoteCodexPath?.trim() || null;
|
||||
const remoteCodexNpmSpec = input.runnerRemoteCodexNpmSpec?.trim() || null;
|
||||
@@ -11579,7 +11732,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
remoteRuntimeRoot, remoteCodexBinary, runnerRemoteBinaryPath: input.runnerRemoteBinaryPath,
|
||||
runnerRemoteCodexPath: input.runnerRemoteCodexPath, runnerRemoteCodexNpmSpec: input.runnerRemoteCodexNpmSpec,
|
||||
model: input.execution.provider.kind === "codex" ? input.execution.provider.model : null,
|
||||
reuseRetainedRunner: sandboxLeaseAcquisition?.outcome === "resumed", targetPlatformArtifactVerified: remotePiCompanion !== null,
|
||||
reuseRetainedRunner: sandboxLeaseAcquisition?.outcome === "resumed", targetPlatformArtifactVerified: remotePiCompanion !== null || useBundledRemoteImageAssets,
|
||||
trace: input.trace, onLog: input.onLog,
|
||||
})
|
||||
: null;
|
||||
@@ -13246,7 +13399,7 @@ export function createRemoteNativeArtifactPreparation(input: {
|
||||
runnerRemoteCodexNpmSpec?: string | null;
|
||||
model?: string | null;
|
||||
reuseRetainedRunner?: boolean;
|
||||
/** Existing operator-imported Pi companion already verifies its target-platform artifact. */
|
||||
/** The selected Pi companion or bundled provider image verifies its target-platform artifact. */
|
||||
targetPlatformArtifactVerified?: boolean;
|
||||
trace?: NativeRunTrace;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
|
||||
@@ -57,6 +57,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
{ companyId, issueId, body: "UNTRUSTED BODY", authorAgentId: agentId, createdAt: new Date(5_000),
|
||||
sourceTrust: { preset: "low_trust_review", disposition: "quarantined", sourceIssueId: issueId } },
|
||||
{ companyId, issueId, body: "FUTURE MESSAGE", authorUserId: "user", createdAt: new Date(30_000) },
|
||||
{ companyId, issueId, body: "I’ll check the review request.", authorAgentId: agentId, origin: "fast_response", createdAt: new Date(4_500) },
|
||||
]);
|
||||
const { eq } = await import("drizzle-orm");
|
||||
await db.update(issues).set({ conversationBoundaryCommentId: boundaryId }).where(eq(issues.id, issueId));
|
||||
@@ -95,6 +96,8 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
it("preserves the original goal and prior answer while excluding reset, deleted and future history", async () => {
|
||||
const result = await buildNativeSessionHandoff({ db, companyId, issueId, agentId, before: new Date(20_000) });
|
||||
expect(result).toContain("Build a GitHub PR review bot");
|
||||
expect(result).toContain('"author":"paperclip_receipt"');
|
||||
expect(result).toContain("[Paperclip acknowledgement; not agent work] I’ll check the review request.");
|
||||
expect(result).toContain("Final constraint: require a team review");
|
||||
expect(result).toContain('"truncated":true');
|
||||
expect(Buffer.byteLength(result!)).toBeLessThanOrEqual(NATIVE_HANDOFF_MAX_BYTES);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { documents, heartbeatRunEvents, heartbeatRuns, issueComments, issueDocum
|
||||
import { createRunSecretRedactionRegistry } from "../run-secret-redaction.js";
|
||||
import { buildLowTrustSourceTrust, redactQuarantinedBodyForHigherTrust, sanitizeQuarantinedCommentForHigherTrust } from "../source-trust.js";
|
||||
import { resolveCoreTrustPreset } from "../trust-preset-resolver.js";
|
||||
import { fastResponseHistoryBody } from "@paperclipai/shared";
|
||||
|
||||
export const NATIVE_HANDOFF_MAX_BYTES = 24_000;
|
||||
const ENTRY_MAX_CHARS = 4_000;
|
||||
@@ -72,7 +73,7 @@ export async function buildNativeSessionHandoff(input: {
|
||||
id: issueComments.id, body: excerpt(sql`${issueComments.body}`),
|
||||
truncated: sql<boolean>`length(${issueComments.body}) > ${ENTRY_MAX_CHARS}`,
|
||||
authorAgentId: issueComments.authorAgentId, sourceTrust: issueComments.sourceTrust,
|
||||
createdAt: issueComments.createdAt,
|
||||
createdAt: issueComments.createdAt, origin: issueComments.origin,
|
||||
};
|
||||
const commentScope = and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId), isNull(issueComments.deletedAt),
|
||||
lte(issueComments.createdAt, input.before),
|
||||
@@ -140,7 +141,9 @@ export async function buildNativeSessionHandoff(input: {
|
||||
};
|
||||
const addComment = (row: typeof recent[number], kind: string) => {
|
||||
if (entries.some(entry => entry.id === row.id)) return;
|
||||
entries.push({ ...sanitizeQuarantinedCommentForHigherTrust(row), kind, author: row.authorAgentId ? "agent" : "user" });
|
||||
const safe = sanitizeQuarantinedCommentForHigherTrust(row);
|
||||
entries.push({ ...safe, body: fastResponseHistoryBody(safe), kind,
|
||||
author: row.origin === "fast_response" ? "paperclip_receipt" : row.authorAgentId ? "agent" : "user" });
|
||||
};
|
||||
if (origin[0]) addComment(origin[0], "original_request");
|
||||
recent.slice(0, LIMIT).forEach(row => addComment(row, "message"));
|
||||
|
||||
@@ -461,7 +461,7 @@ describe("capability-gated connection tool refresh", () => {
|
||||
|
||||
it("wires exact-session recovery and guarded selected identity into heartbeat persistence", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../heartbeat.ts", import.meta.url),
|
||||
new URL("../heartbeat/runtime-selection.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).toContain("await findNativeSessionResumeRun(db,");
|
||||
@@ -1922,6 +1922,18 @@ describe("rebindNativeSessionCheckpoint", () => {
|
||||
retainedFingerprint:
|
||||
"sha256:68a51d34e091c55ee5d0d2b563153454dd727d72db16e6a27c358d342ae489c9",
|
||||
},
|
||||
{
|
||||
contract: "GitHub working-comment tools",
|
||||
// Deployed v14 threads retain the catalog without update_comment.
|
||||
retainedFingerprint:
|
||||
"sha256:134a7dbd526179aff57f91c261bb653e83db51c20492efab5c26a5ce618792c8",
|
||||
},
|
||||
{
|
||||
contract: "GitHub instruction skill selection",
|
||||
// Deployed v15 remote threads omitted configured skills from native turns.
|
||||
retainedFingerprint:
|
||||
"sha256:f12fe3b1bf5d63b7954d8ab16f57764874806ab49acff7c6582c4de007f4ed4d",
|
||||
},
|
||||
{
|
||||
contract: "task-bound human-input description",
|
||||
// Deployed v9 still advertises the generic mock-task question description.
|
||||
|
||||
@@ -33,7 +33,7 @@ export function nativeToolContractFingerprintForTarget(
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
schema: "paperclip.native-tool-contract.v14",
|
||||
schema: "paperclip.native-tool-contract.v16",
|
||||
executionTargetKind,
|
||||
advertisementPolicy: {
|
||||
// Direct provider threads retain declarations from thread/start.
|
||||
@@ -48,6 +48,8 @@ export function nativeToolContractFingerprintForTarget(
|
||||
"always_advertised_run_issue_agent_binding_gated_current_task_description.v2",
|
||||
semanticCompletion: "finish_accessible_deliverable_evidence.v4",
|
||||
connectorTools: "assigned_resources_and_pinned_skill_bundle.v1",
|
||||
githubResponses: "bound_working_comment_progress_and_final.v1",
|
||||
githubInstructionSkills: "trusted_configuration_native_selection.v1",
|
||||
...(executionTargetKind === "remote"
|
||||
? { assignedMcpTools: "codex_server_gateway_prp_relay.v1" }
|
||||
: {}),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { heartbeatRuns, nativeRunFinalizations, nativeRunResults, type Db } from "@paperclipai/db";
|
||||
import { and, eq, exists } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* An accepted result transfers recovery to the workspace finalizer. A dead
|
||||
* provider PID cannot authorize discarding its unfinished workspace suffix,
|
||||
* including the interval before physical copyback acquires its owner receipt.
|
||||
* The heartbeat's display phase and arbitrary profile JSON are not evidence.
|
||||
*/
|
||||
export function pendingNativeWorkspaceFinalizationCondition(db: Db) {
|
||||
return and(
|
||||
eq(heartbeatRuns.runtimeMode, "native"),
|
||||
exists(db.select({ runId: nativeRunFinalizations.runId })
|
||||
.from(nativeRunFinalizations)
|
||||
.innerJoin(nativeRunResults, and(
|
||||
eq(nativeRunResults.id, nativeRunFinalizations.resultId),
|
||||
eq(nativeRunResults.runId, nativeRunFinalizations.runId),
|
||||
eq(nativeRunResults.companyId, nativeRunFinalizations.companyId),
|
||||
eq(nativeRunResults.issueId, nativeRunFinalizations.issueId),
|
||||
eq(nativeRunResults.schemaStatus, "accepted"),
|
||||
))
|
||||
.where(and(
|
||||
eq(nativeRunFinalizations.runId, heartbeatRuns.id),
|
||||
eq(nativeRunFinalizations.companyId, heartbeatRuns.companyId),
|
||||
eq(nativeRunFinalizations.issueId, heartbeatRuns.nativeIssueId),
|
||||
eq(nativeRunFinalizations.phase, "workspace_finalizing"),
|
||||
))),
|
||||
)!;
|
||||
}
|
||||
@@ -13,8 +13,10 @@ const serviceMocks = vi.hoisted(() => ({
|
||||
readCommittedForRuntime: vi.fn(),
|
||||
getEffectiveProfilesForAgent: vi.fn(),
|
||||
githubBotConnectionIdsForRun: vi.fn(),
|
||||
spekoToolsForSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../voice/speko-agent-tools.js", () => ({ spekoToolsForSession: serviceMocks.spekoToolsForSession }));
|
||||
vi.mock("../chat-github-tools.js", () => ({
|
||||
githubBotConnectionIdsForRun: serviceMocks.githubBotConnectionIdsForRun,
|
||||
}));
|
||||
@@ -58,6 +60,7 @@ beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
serviceMocks.readCommittedForRuntime.mockResolvedValue(null);
|
||||
serviceMocks.githubBotConnectionIdsForRun.mockResolvedValue(new Set());
|
||||
serviceMocks.spekoToolsForSession.mockResolvedValue([]);
|
||||
previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||
previousInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paperclip-native-context-"));
|
||||
@@ -406,3 +409,14 @@ it("pins channel tools only when the current task run is bound to that bot", asy
|
||||
expect((await resolveNativeRuntimeMcpSnapshot(input)).bindingId).toBe("native-mcp:run-1");
|
||||
expect(serviceMocks.githubBotConnectionIdsForRun).toHaveBeenLastCalledWith(input.db, "company-1", "agent-1", "run-1");
|
||||
});
|
||||
|
||||
it("pins a voice tool only when the current requester can call through that assigned endpoint", async () => {
|
||||
serviceMocks.getEffectiveProfilesForAgent.mockResolvedValue({ entries: [{ effect: "include", connectionId: "voice-connection" }], installedConnections: [{ id: "voice-connection", status: "active", enabled: true, healthStatus: "healthy", transport: "voice", config: { provider: "speko" }, transportConfig: {} }], allowedTools: [{ id: "phone-tool", connectionId: "voice-connection" }] });
|
||||
const limit = vi.fn().mockResolvedValue([{ contextSnapshot: { issueId: "task-1" }, activeIdentityContextId: "identity-1" }]);
|
||||
const db = { select: () => ({ from: () => ({ where: () => ({ limit }) }) }) } as unknown as Db;
|
||||
const input = { db, agent: { id: "agent-1", companyId: "company-1" }, runId: "run-1" };
|
||||
expect((await resolveNativeRuntimeMcpSnapshot(input)).bindingId).toBeNull();
|
||||
serviceMocks.spekoToolsForSession.mockResolvedValue([{ connectionId: "voice-connection" }]);
|
||||
expect((await resolveNativeRuntimeMcpSnapshot(input)).bindingId).toBe("native-mcp:run-1");
|
||||
expect(serviceMocks.spekoToolsForSession).toHaveBeenLastCalledWith(db, { companyId: "company-1", agentId: "agent-1", runId: "run-1", issueId: "task-1", identityContextId: "identity-1" });
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { spekoToolsForSession } from "../voice/speko-agent-tools.js";
|
||||
import { githubBotConnectionIdsForRun } from "../chat-github-tools.js";
|
||||
import { isBrowserUseConnection } from "../browser-use-client.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
@@ -196,8 +197,9 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
|
||||
: {};
|
||||
return config.sourceTemplateKey === "github" || transportConfig.sourceTemplateKey === "github";
|
||||
});
|
||||
const [runIdentity] = hasGitHubConnection
|
||||
? await input.db.select({ responsibleUserId: heartbeatRuns.responsibleUserId, activeIdentityContextId: heartbeatRuns.activeIdentityContextId })
|
||||
const hasVoiceConnection = effective.installedConnections.some(connection => connection.transport === "voice");
|
||||
const [runIdentity] = hasGitHubConnection || hasVoiceConnection
|
||||
? await input.db.select({ contextSnapshot: heartbeatRuns.contextSnapshot, responsibleUserId: heartbeatRuns.responsibleUserId, activeIdentityContextId: heartbeatRuns.activeIdentityContextId })
|
||||
.from(heartbeatRuns)
|
||||
.where(and(
|
||||
eq(heartbeatRuns.id, input.runId),
|
||||
@@ -219,6 +221,7 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
|
||||
// App access is optional runtime context. Keep usable assignments pinned, but
|
||||
// do not stop unrelated work because an assigned app needs attention.
|
||||
const githubBotConnectionIds = await githubBotConnectionIdsForRun(input.db, input.agent.companyId, input.agent.id, input.runId);
|
||||
const spekoConnectionIds = hasVoiceConnection ? new Set((await spekoToolsForSession(input.db, { companyId: input.agent.companyId, agentId: input.agent.id, runId: input.runId, issueId: typeof runIdentity?.contextSnapshot?.issueId === "string" ? runIdentity.contextSnapshot.issueId : typeof runIdentity?.contextSnapshot?.taskId === "string" ? runIdentity.contextSnapshot.taskId : null, identityContextId: runIdentity?.activeIdentityContextId })).map(tool => tool.connectionId)) : new Set<string>();
|
||||
const availableConnectionIds = new Set(resolvedInstalledConnections.filter((connection) =>
|
||||
permitted.has(connection.id)
|
||||
&& connection.status === "active"
|
||||
@@ -226,7 +229,7 @@ export async function resolveNativeRuntimeMcpSnapshot(input: { db: Db; agent: Pi
|
||||
&& (Boolean(runIdentity?.activeIdentityContextId) && (connection.config?.sourceTemplateKey === "github" || connection.transportConfig?.sourceTemplateKey === "github")
|
||||
|| connection.credentialPolicy === "per_user"
|
||||
|| !isToolConnectionAttentionHealth(connection.healthStatus))
|
||||
&& (["mcp_remote", "local_stdio"].includes(connection.transport) || isBrowserUseConnection(connection) || githubBotConnectionIds.has(connection.id))
|
||||
&& (["mcp_remote", "local_stdio"].includes(connection.transport) || isBrowserUseConnection(connection) || githubBotConnectionIds.has(connection.id) || spekoConnectionIds.has(connection.id))
|
||||
).map((connection) => connection.id));
|
||||
const assignment = {
|
||||
version: 1,
|
||||
|
||||
Reference in new issue
Block a user