Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,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
Expand Down
49 changes: 43 additions & 6 deletions extensions/background-terminals/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -223,6 +254,7 @@ export default function (pi: ExtensionAPI) {
status: snap.status,
exitCode: snap.exitCode,
signal: snap.signal,
resources: terminalResourceRefs(snap),
})),
},
},
Expand Down Expand Up @@ -463,6 +495,7 @@ export default function (pi: ExtensionAPI) {
exitCode: snap.exitCode,
signal: snap.signal,
timeoutAt: snap.timeoutAt,
resources: terminalResourceRefs(snap),
},
};
},
Expand Down Expand Up @@ -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) : [],
};
}),
},
};
},
Expand Down
287 changes: 287 additions & 0 deletions extensions/shared/resource-reference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
import { createHash } from "node:crypto";
import { lstatSync, readFileSync } 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<string, unknown> {
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(file: string) {
return createHash("sha256").update(readFileSync(file)).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 revision = resourceRevision(inspected.file);
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(inspected.file);
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 };
}
Loading
Loading