From 3949855ca1327dc6eb240b0611f6ba3eeb05dfb6 Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:25:25 +0800 Subject: [PATCH] feat(shared): add owner-bound resource references --- README.md | 6 + extensions/background-terminals/index.ts | 49 ++- extensions/shared/resource-reference.ts | 307 ++++++++++++++++++ extensions/subagents/index.ts | 44 ++- extensions/workflows/artifacts.ts | 57 ++++ extensions/workflows/dashboard.ts | 4 + extensions/workflows/model.ts | 3 + skills/background-terminals/SKILL.md | 5 + skills/subagents/REFERENCE.md | 5 + skills/workflows/REFERENCE.md | 5 + .../background-terminals/index.test.ts | 43 ++- .../shared/resource-reference.test.ts | 154 +++++++++ tests/extensions/subagents/index.test.ts | 53 +++ tests/extensions/workflows/artifacts.test.ts | 42 +++ 14 files changed, 767 insertions(+), 10 deletions(-) create mode 100644 extensions/shared/resource-reference.ts create mode 100644 tests/extensions/shared/resource-reference.test.ts diff --git a/README.md b/README.md index 70ac3a23..1360a56b 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,12 @@ Workflow 在清理隔离 checkout 前原子保存有界 Handoff Manifest:track 设计细节见 [Workflow invocation graph](https://github.com/openpi-dev/openpi/blob/main/docs/design/WORKFLOW_INVOCATION_GRAPH.md)。 +### Owner-bound resource references + +当 Direct Subagent 的有界 manager final、Workflow 的 result/transcript/agent result,或 Background Terminal 的完整 spill 已真实落盘时,结果 details 可附带 versioned resource reference。引用明确记录 producer owner、generation、revision、media type、byte length、相对于哪个 owner value 的完整性以及 owner-specific lifetime;它只是恢复 metadata,不是读取授权,也不会延长制品生命周期。 + +解析仍由对应 extension 在自己的 root、generation 与 Pi Trust/tool boundary 内完成。owner mismatch、stale generation、owner lost、unauthorized、目录穿越、symlink substitution、missing 与 revision drift 是可区分的 fail-closed 结果。OpenPI 不增加全局 URI/router、统一 artifact store 或新的常驻 read/search tool;Pi 原生 `read` 仍是实际读取机制。 + --- ## 连续工作,而不是堆 Context diff --git a/extensions/background-terminals/index.ts b/extensions/background-terminals/index.ts index 5d38a846..906a6791 100644 --- a/extensions/background-terminals/index.ts +++ b/extensions/background-terminals/index.ts @@ -37,6 +37,7 @@ import { registerWebCapability, } from "../shared/web-observer-registry.ts"; import type { TerminalSnapshot } from "./src/domain.ts"; +import { createOwnerFileResourceRef } from "../shared/resource-reference.ts"; import { MAX_RUNNING, TerminalManager, @@ -89,6 +90,35 @@ import { const WIDGET_KEY = "background-terminals"; const IDLE_RESULT_BATCH_MS = 200; +export function terminalResourceRefs(snap: TerminalSnapshot) { + if (snap.status === "running") return []; + return (["stdout", "stderr"] as const).flatMap((stream) => { + const view = snap[stream]; + if (!view.spillPath) return []; + try { + return [ + createOwnerFileResourceRef({ + owner: { + kind: "background", + id: snap.id, + generation: String(snap.createdAt), + }, + resourceId: stream, + root: path.dirname(view.spillPath), + file: view.spillPath, + mediaType: "text/plain; charset=utf-8", + completeness: "complete-owner-value", + sourceCoverage: "process-stream", + lifetime: "session-temporary", + expectedByteLength: view.totalBytes, + }), + ]; + } catch { + return []; + } + }); +} + interface WatchToolDetails { id: string; pattern: string; @@ -214,6 +244,7 @@ export default function (pi: ExtensionAPI) { status: snaps[0]!.status, exitCode: snaps[0]!.exitCode, signal: snaps[0]!.signal, + resources: terminalResourceRefs(snaps[0]!), } : { count: snaps.length, @@ -223,6 +254,7 @@ export default function (pi: ExtensionAPI) { status: snap.status, exitCode: snap.exitCode, signal: snap.signal, + resources: terminalResourceRefs(snap), })), }, }, @@ -463,6 +495,7 @@ export default function (pi: ExtensionAPI) { exitCode: snap.exitCode, signal: snap.signal, timeoutAt: snap.timeoutAt, + resources: terminalResourceRefs(snap), }, }; }, @@ -542,12 +575,16 @@ export default function (pi: ExtensionAPI) { return { content: [{ type: "text", text: buildKillReport(report) }], details: { - results: report.map((entry) => ({ - id: entry.id, - title: entry.title, - status: entry.status, - killed: entry.killed, - })), + results: report.map((entry) => { + const snap = manager.view.get(entry.id); + return { + id: entry.id, + title: entry.title, + status: entry.status, + killed: entry.killed, + resources: snap ? terminalResourceRefs(snap) : [], + }; + }), }, }; }, diff --git a/extensions/shared/resource-reference.ts b/extensions/shared/resource-reference.ts new file mode 100644 index 00000000..2791a032 --- /dev/null +++ b/extensions/shared/resource-reference.ts @@ -0,0 +1,307 @@ +import { createHash } from "node:crypto"; +import { lstatSync } from "node:fs"; +import path from "node:path"; + +export const OPENPI_RESOURCE_REF_VERSION = 1 as const; + +export type OpenPiResourceOwner = "subagent" | "workflow" | "background"; +export type OpenPiResourceCompleteness = + | "complete-owner-value" + | "partial-owner-value"; +export type OpenPiResourceLifetime = + | "session-cache" + | "workflow-run" + | "session-temporary"; + +/** Metadata only: possession never grants read authority or extends lifetime. */ +export interface OpenPiResourceRef { + readonly version: typeof OPENPI_RESOURCE_REF_VERSION; + readonly owner: { + readonly kind: OpenPiResourceOwner; + readonly id: string; + readonly generation: string; + }; + readonly resource: { + readonly id: string; + readonly revision: string; + readonly path: string; + readonly mediaType: string; + readonly byteLength: number; + readonly completeness: OpenPiResourceCompleteness; + readonly sourceCoverage: string; + }; + readonly lifetime: OpenPiResourceLifetime; +} + +export type OpenPiResourceFailure = + | "invalid-reference" + | "owner-mismatch" + | "stale-generation" + | "owner-lost" + | "unauthorized" + | "unsafe-path" + | "symlink-substitution" + | "missing" + | "stale-resource"; + +export type OpenPiResourceResolution = + | { readonly ok: true; readonly path: string } + | { + readonly ok: false; + readonly failure: OpenPiResourceFailure; + readonly message: string; + }; + +const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/u; +const MAX_PATH_BYTES = 16 * 1024; + +function safeIdentity(value: string) { + return ID_PATTERN.test(value); +} + +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function isOpenPiResourceRef( + value: unknown, +): value is OpenPiResourceRef { + if (!record(value) || !record(value.owner) || !record(value.resource)) { + return false; + } + return ( + value.version === OPENPI_RESOURCE_REF_VERSION && + (value.owner.kind === "subagent" || + value.owner.kind === "workflow" || + value.owner.kind === "background") && + typeof value.owner.id === "string" && + safeIdentity(value.owner.id) && + typeof value.owner.generation === "string" && + safeIdentity(value.owner.generation) && + typeof value.resource.id === "string" && + safeIdentity(value.resource.id) && + typeof value.resource.revision === "string" && + /^[a-f0-9]{64}$/u.test(value.resource.revision) && + typeof value.resource.path === "string" && + Buffer.byteLength(value.resource.path, "utf8") <= MAX_PATH_BYTES && + typeof value.resource.mediaType === "string" && + Buffer.byteLength(value.resource.mediaType, "utf8") <= 256 && + typeof value.resource.byteLength === "number" && + Number.isSafeInteger(value.resource.byteLength) && + value.resource.byteLength >= 0 && + (value.resource.completeness === "complete-owner-value" || + value.resource.completeness === "partial-owner-value") && + typeof value.resource.sourceCoverage === "string" && + Buffer.byteLength(value.resource.sourceCoverage, "utf8") <= 256 && + (value.lifetime === "session-cache" || + value.lifetime === "workflow-run" || + value.lifetime === "session-temporary") + ); +} + +function containedPath(root: string, candidate: string) { + const relative = path.relative(root, candidate); + return ( + relative.length > 0 && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +function inspectOwnedFile(rootValue: string, fileValue: string) { + const root = path.resolve(rootValue); + const file = path.resolve(fileValue); + if ( + Buffer.byteLength(root, "utf8") > MAX_PATH_BYTES || + Buffer.byteLength(file, "utf8") > MAX_PATH_BYTES || + !containedPath(root, file) + ) { + return { ok: false as const, failure: "unsafe-path" as const }; + } + + try { + const rootStat = lstatSync(root); + if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) { + return { + ok: false as const, + failure: "symlink-substitution" as const, + }; + } + const relative = path.relative(root, file); + let cursor = root; + for (const segment of relative.split(path.sep)) { + cursor = path.join(cursor, segment); + const stat = lstatSync(cursor); + if (stat.isSymbolicLink()) { + return { + ok: false as const, + failure: "symlink-substitution" as const, + }; + } + if (cursor !== file && !stat.isDirectory()) { + return { ok: false as const, failure: "unsafe-path" as const }; + } + if (cursor === file && !stat.isFile()) { + return { ok: false as const, failure: "unsafe-path" as const }; + } + } + return { ok: true as const, root, file, stat: lstatSync(file) }; + } catch (error) { + return { + ok: false as const, + failure: + (error as NodeJS.ErrnoException).code === "ENOENT" + ? ("missing" as const) + : ("unsafe-path" as const), + }; + } +} + +function resourceRevision( + owner: OpenPiResourceRef["owner"], + relative: string, + size: number, + mtimeMs: number, +) { + return createHash("sha256") + .update( + `${owner.kind}\0${owner.id}\0${owner.generation}\0${relative}\0${size}\0${mtimeMs}`, + ) + .digest("hex"); +} + +export function createOwnerFileResourceRef(options: { + readonly owner: OpenPiResourceRef["owner"]; + readonly resourceId: string; + readonly root: string; + readonly file: string; + readonly mediaType: string; + readonly completeness: OpenPiResourceCompleteness; + readonly sourceCoverage: string; + readonly lifetime: OpenPiResourceLifetime; + readonly expectedByteLength?: number; +}) { + if ( + !safeIdentity(options.owner.id) || + !safeIdentity(options.owner.generation) || + !safeIdentity(options.resourceId) || + !options.mediaType || + Buffer.byteLength(options.mediaType, "utf8") > 256 || + !options.sourceCoverage || + Buffer.byteLength(options.sourceCoverage, "utf8") > 256 + ) { + throw new Error("Invalid owner-bound resource identity"); + } + const inspected = inspectOwnedFile(options.root, options.file); + if (!inspected.ok) { + throw new Error(`Cannot publish resource reference: ${inspected.failure}`); + } + if ( + options.expectedByteLength !== undefined && + inspected.stat.size !== options.expectedByteLength + ) { + throw new Error("Cannot publish resource reference: stale-resource"); + } + const relative = path.relative(inspected.root, inspected.file); + const revision = resourceRevision( + options.owner, + relative, + inspected.stat.size, + inspected.stat.mtimeMs, + ); + return { + version: OPENPI_RESOURCE_REF_VERSION, + owner: { ...options.owner }, + resource: { + id: options.resourceId, + revision, + path: inspected.file, + mediaType: options.mediaType, + byteLength: inspected.stat.size, + completeness: options.completeness, + sourceCoverage: options.sourceCoverage, + }, + lifetime: options.lifetime, + } satisfies OpenPiResourceRef; +} + +/** Resolve through the owning extension's root and authority decision only. */ +export function resolveOwnerFileResourceRef( + value: unknown, + options: { + readonly owner: OpenPiResourceRef["owner"]; + readonly root: string; + readonly ownerAlive: boolean; + readonly authorized: boolean; + }, +): OpenPiResourceResolution { + if (!isOpenPiResourceRef(value)) { + return { + ok: false, + failure: "invalid-reference", + message: "Resource reference shape is invalid", + }; + } + const ref = value; + if ( + ref.owner.kind !== options.owner.kind || + ref.owner.id !== options.owner.id + ) { + return { + ok: false, + failure: "owner-mismatch", + message: "Resource reference belongs to another owner", + }; + } + if (ref.owner.generation !== options.owner.generation) { + return { + ok: false, + failure: "stale-generation", + message: "Resource reference belongs to a stale owner generation", + }; + } + if (!options.ownerAlive) { + return { + ok: false, + failure: "owner-lost", + message: "Resource owner is no longer live", + }; + } + if (!options.authorized) { + return { + ok: false, + failure: "unauthorized", + message: "Current Pi trust/tool boundary does not authorize this read", + }; + } + const inspected = inspectOwnedFile(options.root, ref.resource.path); + if (!inspected.ok) { + return { + ok: false, + failure: inspected.failure, + message: `Resource cannot be resolved: ${inspected.failure}`, + }; + } + if (inspected.stat.size !== ref.resource.byteLength) { + return { + ok: false, + failure: "stale-resource", + message: "Resource bytes no longer match the published reference", + }; + } + const revision = resourceRevision( + ref.owner, + path.relative(inspected.root, inspected.file), + inspected.stat.size, + inspected.stat.mtimeMs, + ); + if (revision !== ref.resource.revision) { + return { + ok: false, + failure: "stale-resource", + message: "Resource revision no longer matches the published reference", + }; + } + return { ok: true, path: inspected.file }; +} diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts index 7bcb0340..5bb9dc0a 100644 --- a/extensions/subagents/index.ts +++ b/extensions/subagents/index.ts @@ -74,6 +74,10 @@ import { allocateResultBudgets, type ParentContextUsage, } from "../shared/result-budget.ts"; +import { + createOwnerFileResourceRef, + type OpenPiResourceRef, +} from "../shared/resource-reference.ts"; import { type DetailDisplay, loadSetupConfig } from "../shared/setup-config.ts"; import { OPENPI_TOOL_SURFACE, @@ -187,6 +191,7 @@ interface SubagentResultDetails { readonly elapsed?: string; readonly artifactSaveFailed?: boolean; readonly fullResultSaved?: boolean; + readonly resource?: OpenPiResourceRef; readonly count?: number; readonly results?: ReadonlyArray<{ readonly id: string; @@ -197,6 +202,7 @@ interface SubagentResultDetails { readonly elapsed?: string; readonly artifactSaveFailed?: boolean; readonly fullResultSaved?: boolean; + readonly resource?: OpenPiResourceRef; }>; /** Display-only projection for the custom message renderer. */ readonly displayContent?: string; @@ -244,19 +250,42 @@ export function truncatedOutput( function projectSubagentOutput( snap: SubagentSnapshot, maxBytes: number, -): ResultProjection { +): ResultProjection & { readonly resource?: OpenPiResourceRef } { const output = snap.finalText || "(no output)"; - return projectResult(output, { + const projection = projectResult(output, { maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES), maxLines: Math.min(600, DEFAULT_MAX_LINES), writeArtifact: (content) => persistResultArtifact(getAgentDir(), content), }); + if (!projection.artifactPath) return projection; + try { + return { + ...projection, + resource: createOwnerFileResourceRef({ + owner: { + kind: "subagent", + id: snap.id, + generation: String(snap.settledAt ?? snap.createdAt), + }, + resourceId: "final-result", + root: path.dirname(projection.artifactPath), + file: projection.artifactPath, + mediaType: "text/plain; charset=utf-8", + completeness: "complete-owner-value", + sourceCoverage: "manager-bounded-final", + lifetime: "session-cache", + expectedByteLength: Buffer.byteLength(output, "utf8"), + }), + }; + } catch { + return projection; + } } type OutputProjection = Pick< ResultProjection, "text" | "artifactPath" | "artifactSaveFailed" ->; +> & { readonly resource?: OpenPiResourceRef }; function normalizeProjection( output: string | OutputProjection, @@ -367,6 +396,9 @@ export function createSubagentResultDispatcher( : {}), elapsed: formatElapsed(snaps[0]!), ...(projections[0]!.artifactPath ? { fullResultSaved: true } : {}), + ...(projections[0]!.resource + ? { resource: projections[0]!.resource } + : {}), ...(projections[0]!.artifactSaveFailed ? { artifactSaveFailed: true } : {}), @@ -385,6 +417,9 @@ export function createSubagentResultDispatcher( ...(projections[index]!.artifactPath ? { fullResultSaved: true } : {}), + ...(projections[index]!.resource + ? { resource: projections[index]!.resource } + : {}), ...(projections[index]!.artifactSaveFailed ? { artifactSaveFailed: true } : {}), @@ -1147,12 +1182,14 @@ export default function ( let resultIndex = 0; const artifactSaveFailures = new Set(); const fullResultsSaved = new Set(); + const resources = new Map(); const sections = entries.map((entry) => { if ("section" in entry) return entry.section; const outputBudget = allocation.budgets[resultIndex++]!; const projection = projectSubagentOutput(entry.snap, outputBudget); if (projection.artifactSaveFailed) artifactSaveFailures.add(entry.id); if (projection.artifactPath) fullResultsSaved.add(entry.id); + if (projection.resource) resources.set(entry.id, projection.resource); return `${entry.header}\n\n${projection.text}`; }); @@ -1179,6 +1216,7 @@ export default function ( : {}), ...(snap ? { elapsed: formatElapsed(snap) } : {}), ...(fullResultsSaved.has(id) ? { fullResultSaved: true } : {}), + ...(resources.has(id) ? { resource: resources.get(id) } : {}), ...(artifactSaveFailures.has(id) ? { artifactSaveFailed: true } : {}), diff --git a/extensions/workflows/artifacts.ts b/extensions/workflows/artifacts.ts index 005e43db..465f07c7 100644 --- a/extensions/workflows/artifacts.ts +++ b/extensions/workflows/artifacts.ts @@ -20,6 +20,7 @@ import { truncateUtf8, writeFileAtomic, } from "./serialization.ts"; +import { createOwnerFileResourceRef } from "../shared/resource-reference.ts"; export const JOURNAL_FILE = "journal.json"; @@ -210,12 +211,68 @@ export function persistWorkflowJson( safeStringify(details.result, { maxBytes: 1024 * 1024 }), ); } + const resourceCandidates = + details.status === "running" + ? [] + : [ + ...(details.result !== undefined + ? [ + { + resourceId: "final-result", + file: "result.json", + completeness: "partial-owner-value" as const, + sourceCoverage: "bounded-workflow-result-projection", + }, + ] + : []), + { + resourceId: "transcripts", + file: "transcripts.json", + completeness: "partial-owner-value" as const, + sourceCoverage: "bounded-transcript-copy", + }, + ...details.agents.flatMap((agent) => + agent.resultArtifact + ? [ + { + resourceId: `agent-${agent.index}-result`, + file: agent.resultArtifact, + completeness: "complete-owner-value" as const, + sourceCoverage: "coordinator-result", + }, + ] + : [], + ), + ]; + const resourceRefs = resourceCandidates.flatMap((resource) => { + try { + return [ + createOwnerFileResourceRef({ + owner: { + kind: "workflow", + id: details.runId, + generation: String(details.startedAt), + }, + resourceId: resource.resourceId, + root: runDir, + file: path.join(runDir, resource.file), + mediaType: "application/json", + completeness: resource.completeness, + sourceCoverage: resource.sourceCoverage, + lifetime: "workflow-run", + }), + ]; + } catch { + return []; + } + }); const compact: WorkflowDetails = { ...details, ...(details.result !== undefined ? { result: "[stored in result.json]", resultArtifact: "result.json" } : {}), transcriptArtifact: "transcripts.json", + ...(resourceRefs.length > 0 ? { resourceRefs } : {}), agents: details.agents.map((agent) => ({ ...agent, transcript: [] })), }; writeRunFile( diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index 2a9b34ef..7ac2ce3e 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -62,6 +62,7 @@ import { type WorkflowLogEntry, workflowGraphRecords, } from "./model.ts"; +import { isOpenPiResourceRef } from "../shared/resource-reference.ts"; import { writeFileAtomic } from "./serialization.ts"; import { WorkflowTranscriptAdapter } from "./transcript.ts"; @@ -492,6 +493,9 @@ export function normalizePersistedWorkflowDetails( typeof record.transcriptArtifact === "string" ? record.transcriptArtifact : undefined, + resourceRefs: Array.isArray(record.resourceRefs) + ? record.resourceRefs.filter(isOpenPiResourceRef).slice(0, 256) + : undefined, resumedFrom: typeof record.resumedFrom === "string" ? record.resumedFrom : undefined, resumeNote: diff --git a/extensions/workflows/model.ts b/extensions/workflows/model.ts index dcb4f102..395b70a2 100644 --- a/extensions/workflows/model.ts +++ b/extensions/workflows/model.ts @@ -13,6 +13,7 @@ import { spinnerFrame } from "../shared/spinner.ts"; import { sanitizeTerminalText } from "../shared/terminal-text.ts"; import type { WorktreeCleanup } from "../shared/worktree.ts"; import type { AcceptanceLedger } from "./acceptance.ts"; +import type { OpenPiResourceRef } from "../shared/resource-reference.ts"; import { projectWorkflowGraph, type WorkflowGraphProjection, @@ -182,6 +183,8 @@ export interface WorkflowDetails { result?: unknown; resultArtifact?: string; transcriptArtifact?: string; + /** Owner-bound recovery metadata; storage and authorization remain here. */ + resourceRefs?: OpenPiResourceRef[]; /** Run this one replayed cached agent results from, when resuming. */ resumedFrom?: string; /** Why a requested resume produced no cache, for an honest result message. */ diff --git a/skills/background-terminals/SKILL.md b/skills/background-terminals/SKILL.md index 07d070de..66d5edde 100644 --- a/skills/background-terminals/SKILL.md +++ b/skills/background-terminals/SKILL.md @@ -28,3 +28,8 @@ After starting, continue useful work instead of polling. The terminal sends one - Tell the user they can open `/ps` to inspect live output and kill terminals interactively. Prefer meaningful titles and avoid starting duplicate servers or watchers. Full output is captured to spill files; tool and completion output shows a concise tail. Terminals are session-scoped and are stopped during shutdown or reload. + +Settled spill metadata may include owner-bound resource references for stdout +and stderr. They are complete only relative to the captured process stream and +remain session-temporary; cleanup, pruning, or shutdown can make them stale. +Possession of a reference does not bypass Pi's normal read/trust boundary. diff --git a/skills/subagents/REFERENCE.md b/skills/subagents/REFERENCE.md index 7d44b306..c0638f0e 100644 --- a/skills/subagents/REFERENCE.md +++ b/skills/subagents/REFERENCE.md @@ -153,6 +153,11 @@ So `tools: [read, grep, find, ls]` yields a child that genuinely has no `write`, `edit`, or `bash` tool to call — not one that has been asked not to. Parent-only names are removed before the generated roster and spawn result are shown, so a type that lists `subagent_spawn` never advertises it as usable. + +When a long manager-bounded final is durably written, result details may carry +an owner-bound resource reference beside the existing Pi-readable path. The +reference names the `manager-bounded-final` coverage and Session-cache lifetime; +it neither proves uncaptured backend bytes nor grants a child extra read access. A structured Workflow child additionally receives only its terminating `structured_output` tool; this does not restore any denied repository tool. diff --git a/skills/workflows/REFERENCE.md b/skills/workflows/REFERENCE.md index 96979a88..15c103c6 100644 --- a/skills/workflows/REFERENCE.md +++ b/skills/workflows/REFERENCE.md @@ -38,6 +38,11 @@ Workflow concurrency defaults to the configured package value and has a hard max Each call persists intent, admission, and execution state. Interrupted nonterminal calls become `uncertain`, never guessed failed. Artifacts contain results, bounded transcripts, and a read-only graph projection for explicit result refs. +Terminal workflow details may expose owner-bound resource references. Agent +result refs are complete only for the coordinator value; final `result.json` +and transcript refs explicitly remain partial bounded projections. References +are run-owned recovery metadata, not restart-stable handoff handles or authority. + ## Lifecycle and replay Interactive TUI runs return an accepted run id immediately by default, release the parent turn, and later deliver a terminal completion with a stable delivery id. Delivery is at least once: normal retries do not duplicate a run, but a process loss after Pi accepts the message and before the receipt is persisted can replay the same id. `wait: true` explicitly waits inline; interrupting that wait releases only the waiter and the run continues. Print/automation defaults to waiting because it has no later delivery channel. diff --git a/tests/extensions/background-terminals/index.test.ts b/tests/extensions/background-terminals/index.test.ts index ce5e77b2..912e7ded 100644 --- a/tests/extensions/background-terminals/index.test.ts +++ b/tests/extensions/background-terminals/index.test.ts @@ -1,10 +1,15 @@ import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; import test from "node:test"; import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; -import backgroundTerminals from "../../../extensions/background-terminals/index.ts"; +import backgroundTerminals, { + terminalResourceRefs, +} from "../../../extensions/background-terminals/index.ts"; type CapturedTool = { name: string; @@ -17,6 +22,42 @@ type CapturedTool = { ) => Promise; }; +test("a settled full-log spill publishes a session-temporary owner ref", () => { + const directory = mkdtempSync(path.join(tmpdir(), "openpi-terminal-ref-")); + const spillPath = path.join(directory, "stdout.log"); + writeFileSync(spillPath, "complete stream"); + try { + const resources = terminalResourceRefs({ + id: "bt-1", + command: "printf test", + title: "test", + cwd: directory, + status: "done", + createdAt: 1, + settledAt: 2, + exitCode: 0, + stdout: { + text: "complete stream", + modelSafeText: "complete stream", + totalBytes: 15, + truncatedBytes: 0, + spillPath, + }, + stderr: { + text: "", + modelSafeText: "", + totalBytes: 0, + truncatedBytes: 0, + }, + }); + assert.equal(resources.length, 1); + assert.equal(resources[0]?.owner.kind, "background"); + assert.equal(resources[0]?.lifetime, "session-temporary"); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + test("session start keeps only the background entry tool active", () => { let active = ["read", "third_party_tool"]; const registered: string[] = []; diff --git a/tests/extensions/shared/resource-reference.test.ts b/tests/extensions/shared/resource-reference.test.ts new file mode 100644 index 00000000..adc92c39 --- /dev/null +++ b/tests/extensions/shared/resource-reference.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { + mkdtempSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { + createOwnerFileResourceRef, + isOpenPiResourceRef, + resolveOwnerFileResourceRef, +} from "../../../extensions/shared/resource-reference.ts"; + +function fixture() { + const root = mkdtempSync(path.join(tmpdir(), "openpi-resource-ref-")); + const file = path.join(root, "result.txt"); + writeFileSync(file, "complete owner value"); + const owner = { + kind: "subagent" as const, + id: "subagent-1", + generation: "123", + }; + const ref = createOwnerFileResourceRef({ + owner, + resourceId: "final-result", + root, + file, + mediaType: "text/plain", + completeness: "complete-owner-value", + sourceCoverage: "manager-bounded-final", + lifetime: "session-cache", + }); + return { root, file, owner, ref }; +} + +function failure(result: ReturnType) { + assert.equal(result.ok, false); + return result.ok ? undefined : result.failure; +} + +test("an owner-bound file reference resolves only through its owner adapter", () => { + const item = fixture(); + try { + assert.equal(isOpenPiResourceRef(item.ref), true); + assert.deepEqual( + resolveOwnerFileResourceRef(item.ref, { + owner: item.owner, + root: item.root, + ownerAlive: true, + authorized: true, + }), + { ok: true, path: item.file }, + ); + } finally { + rmSync(item.root, { recursive: true, force: true }); + } +}); + +test("owner, generation, liveness, and authorization failures stay distinct", () => { + const item = fixture(); + try { + const resolve = ( + patch: Partial[1]>, + ) => + resolveOwnerFileResourceRef(item.ref, { + owner: item.owner, + root: item.root, + ownerAlive: true, + authorized: true, + ...patch, + }); + assert.equal( + failure(resolve({ owner: { ...item.owner, id: "subagent-2" } })), + "owner-mismatch", + ); + assert.equal( + failure(resolve({ owner: { ...item.owner, generation: "124" } })), + "stale-generation", + ); + assert.equal(failure(resolve({ ownerAlive: false })), "owner-lost"); + assert.equal(failure(resolve({ authorized: false })), "unauthorized"); + } finally { + rmSync(item.root, { recursive: true, force: true }); + } +}); + +test("traversal, symlink substitution, missing bytes, and revision drift fail closed", () => { + const item = fixture(); + const outside = mkdtempSync(path.join(tmpdir(), "openpi-resource-outside-")); + const outsideFile = path.join(outside, "outside.txt"); + writeFileSync(outsideFile, "outside"); + const resolve = (value: unknown) => + resolveOwnerFileResourceRef(value, { + owner: item.owner, + root: item.root, + ownerAlive: true, + authorized: true, + }); + try { + assert.equal( + failure( + resolve({ + ...item.ref, + resource: { ...item.ref.resource, path: outsideFile }, + }), + ), + "unsafe-path", + ); + + unlinkSync(item.file); + symlinkSync(outsideFile, item.file); + assert.equal(failure(resolve(item.ref)), "symlink-substitution"); + + unlinkSync(item.file); + assert.equal(failure(resolve(item.ref)), "missing"); + + writeFileSync(item.file, "changed owner value with another length"); + assert.equal(failure(resolve(item.ref)), "stale-resource"); + assert.equal(failure(resolve({})), "invalid-reference"); + } finally { + rmSync(item.root, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + } +}); + +test("publication refuses an artifact outside the owner root", () => { + const item = fixture(); + const outside = mkdtempSync(path.join(tmpdir(), "openpi-resource-outside-")); + const outsideFile = path.join(outside, "outside.txt"); + writeFileSync(outsideFile, "outside"); + try { + assert.throws( + () => + createOwnerFileResourceRef({ + owner: item.owner, + resourceId: "escape", + root: item.root, + file: outsideFile, + mediaType: "text/plain", + completeness: "complete-owner-value", + sourceCoverage: "fixture", + lifetime: "session-cache", + }), + /unsafe-path/, + ); + } finally { + rmSync(item.root, { recursive: true, force: true }); + rmSync(outside, { recursive: true, force: true }); + } +}); diff --git a/tests/extensions/subagents/index.test.ts b/tests/extensions/subagents/index.test.ts index a183ec3a..5d70358b 100644 --- a/tests/extensions/subagents/index.test.ts +++ b/tests/extensions/subagents/index.test.ts @@ -261,6 +261,59 @@ test("automatic delivery reports real artifact save failures", async () => { } }); +test("automatic delivery publishes an owner-bound ref after the artifact exists", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "openpi-artifact-ref-")); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = directory; + try { + let entryDetails: Record | undefined; + const pi = { + appendEntry( + _customType: string, + data: { details: Record }, + ) { + entryDetails = data.details; + }, + sendMessage() {}, + } as unknown as ExtensionAPI; + createSubagentResultDispatcher(pi)([ + { + id: "sa-resource", + origin: "model", + backend: "pi", + title: "resource test", + prompt: "inspect", + cwd: process.cwd(), + status: "done", + createdAt: 1, + settledAt: 2, + meta: { backend: "pi" }, + usage: {}, + transcriptVersion: 0, + transcript: [], + liveTools: [], + queued: [], + finalText: "x".repeat(40 * 1024), + turns: 1, + }, + ]); + + const resource = entryDetails?.resource as + | { + owner?: { kind?: unknown; id?: unknown }; + resource?: { path?: unknown }; + } + | undefined; + assert.equal(resource?.owner?.kind, "subagent"); + assert.equal(resource?.owner?.id, "sa-resource"); + assert.equal(typeof resource?.resource?.path, "string"); + } finally { + if (previousAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + await rm(directory, { recursive: true, force: true }); + } +}); + test("automatic result delivery shrinks a batch against authoritative parent headroom", () => { const budgets: number[] = []; const pi = { diff --git a/tests/extensions/workflows/artifacts.test.ts b/tests/extensions/workflows/artifacts.test.ts index 249b185c..6baf8711 100644 --- a/tests/extensions/workflows/artifacts.test.ts +++ b/tests/extensions/workflows/artifacts.test.ts @@ -164,6 +164,48 @@ test("agent result artifacts are complete or fail without leaving a file", () => } }); +test("terminal workflow persistence publishes refs with honest completeness", () => { + const directory = mkdtempSync(join(tmpdir(), "pi-workflow-refs-")); + try { + const details = workflowDetails(); + details.status = "completed"; + details.finishedAt = 2; + details.result = { answer: "bounded result" }; + details.agents.push({ + index: 1, + label: "fixture", + state: "done", + startedAt: 1, + finishedAt: 2, + preview: "done", + usage: emptyUsage(), + resultArtifact: persistWorkflowAgentResult(directory, 1, { + output: "coordinator value", + }), + transcript: [], + }); + + persistWorkflowJson(directory, details); + const persisted = JSON.parse( + readFileSync(join(directory, "workflow.json"), "utf8"), + ) as WorkflowDetails; + assert.equal(persisted.resourceRefs?.length, 3); + assert.equal( + persisted.resourceRefs?.find( + (ref) => ref.resource.id === "agent-1-result", + )?.resource.completeness, + "complete-owner-value", + ); + assert.equal( + persisted.resourceRefs?.find((ref) => ref.resource.id === "final-result") + ?.resource.completeness, + "partial-owner-value", + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + test("oversized replay journals fail closed before parsing", () => { const directory = mkdtempSync(join(tmpdir(), "pi-workflow-journal-read-")); try {