@tt-a1i/openpi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import type {
|
|
8
|
+
DefaultResourceLoader,
|
|
9
|
+
ToolDefinition,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { effectiveChildToolAllowlist } from "../shared/child-session.ts";
|
|
12
|
+
|
|
13
|
+
const REPLAY_FILESYSTEM_TOOL_NAMES = new Set([
|
|
14
|
+
"read",
|
|
15
|
+
"grep",
|
|
16
|
+
"find",
|
|
17
|
+
"ls",
|
|
18
|
+
"fd",
|
|
19
|
+
"rg",
|
|
20
|
+
]);
|
|
21
|
+
const REPLAY_BUILTIN_FILESYSTEM_TOOL_NAMES = new Set([
|
|
22
|
+
"read",
|
|
23
|
+
"grep",
|
|
24
|
+
"find",
|
|
25
|
+
"ls",
|
|
26
|
+
]);
|
|
27
|
+
const REPLAY_PACKAGE_FILESYSTEM_TOOL_NAMES = new Set(["fd", "rg"]);
|
|
28
|
+
const GIT_OUTPUT_LIMIT = 32 * 1024 * 1024;
|
|
29
|
+
const REPLAY_IDENTITY_TIMEOUT_MS = 5_000;
|
|
30
|
+
const REPLAY_RESOURCE_FILE_LIMIT = 8 * 1024 * 1024;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Replay is an allowlist, not a best guess. An omitted tool list inherits the
|
|
34
|
+
* normal write-capable child tools, and an unfamiliar custom tool has unknown
|
|
35
|
+
* effects, so both are non-replayable.
|
|
36
|
+
*/
|
|
37
|
+
export function isReplaySafeAgentCall(options: {
|
|
38
|
+
tools?: readonly string[];
|
|
39
|
+
isolation?: unknown;
|
|
40
|
+
}) {
|
|
41
|
+
if (options.isolation !== undefined || options.tools === undefined) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
return (
|
|
45
|
+
effectiveChildToolAllowlist(options.tools)?.every((tool) =>
|
|
46
|
+
REPLAY_FILESYSTEM_TOOL_NAMES.has(tool),
|
|
47
|
+
) === true
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ReplayFilesystemToolRegistry {
|
|
52
|
+
getAllTools(): Array<{
|
|
53
|
+
name: string;
|
|
54
|
+
sourceInfo?: {
|
|
55
|
+
path: string;
|
|
56
|
+
source: string;
|
|
57
|
+
origin: string;
|
|
58
|
+
};
|
|
59
|
+
}>;
|
|
60
|
+
getToolDefinition(name: string): ToolDefinition | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class ReplayFilesystemBoundaryViolation extends Error {}
|
|
64
|
+
|
|
65
|
+
interface ReplayFilesystemObservation {
|
|
66
|
+
lexicalPath: string;
|
|
67
|
+
canonicalPath: string;
|
|
68
|
+
revision: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pathIsWithin(root: string, candidate: string) {
|
|
72
|
+
const relative = path.relative(root, candidate);
|
|
73
|
+
return (
|
|
74
|
+
relative === "" ||
|
|
75
|
+
(relative !== ".." &&
|
|
76
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
77
|
+
!path.isAbsolute(relative))
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function filesystemRevision(pathname: string) {
|
|
82
|
+
try {
|
|
83
|
+
const stat = fs.statSync(pathname, { bigint: true });
|
|
84
|
+
return [
|
|
85
|
+
stat.dev,
|
|
86
|
+
stat.ino,
|
|
87
|
+
stat.mode,
|
|
88
|
+
stat.nlink,
|
|
89
|
+
stat.size,
|
|
90
|
+
stat.mtimeNs,
|
|
91
|
+
stat.ctimeNs,
|
|
92
|
+
].join(":");
|
|
93
|
+
} catch {
|
|
94
|
+
throw new ReplayFilesystemBoundaryViolation();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function observeReplayFilesystemPath(
|
|
99
|
+
repositoryRoot: string,
|
|
100
|
+
lexicalPath: string,
|
|
101
|
+
): ReplayFilesystemObservation {
|
|
102
|
+
let canonical: string;
|
|
103
|
+
try {
|
|
104
|
+
canonical = canonicalPath(lexicalPath);
|
|
105
|
+
} catch {
|
|
106
|
+
throw new ReplayFilesystemBoundaryViolation();
|
|
107
|
+
}
|
|
108
|
+
if (!pathIsWithin(repositoryRoot, canonical)) {
|
|
109
|
+
throw new ReplayFilesystemBoundaryViolation();
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
lexicalPath,
|
|
113
|
+
canonicalPath: canonical,
|
|
114
|
+
revision: filesystemRevision(canonical),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function assertReplayFilesystemObservationStable(
|
|
119
|
+
repositoryRoot: string,
|
|
120
|
+
previous: ReplayFilesystemObservation,
|
|
121
|
+
) {
|
|
122
|
+
const current = observeReplayFilesystemPath(
|
|
123
|
+
repositoryRoot,
|
|
124
|
+
previous.lexicalPath,
|
|
125
|
+
);
|
|
126
|
+
if (
|
|
127
|
+
current.canonicalPath !== previous.canonicalPath ||
|
|
128
|
+
current.revision !== previous.revision
|
|
129
|
+
) {
|
|
130
|
+
throw new ReplayFilesystemBoundaryViolation();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizedObservationPath(raw: string) {
|
|
135
|
+
let normalized = raw.trim();
|
|
136
|
+
if (normalized.startsWith("@")) normalized = normalized.slice(1);
|
|
137
|
+
normalized = normalized.replace(
|
|
138
|
+
/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g,
|
|
139
|
+
" ",
|
|
140
|
+
);
|
|
141
|
+
if (normalized === "~") return homedir();
|
|
142
|
+
if (
|
|
143
|
+
normalized.startsWith("~/") ||
|
|
144
|
+
(process.platform === "win32" && normalized.startsWith("~\\"))
|
|
145
|
+
) {
|
|
146
|
+
return path.join(homedir(), normalized.slice(2));
|
|
147
|
+
}
|
|
148
|
+
if (normalized.startsWith("file://")) {
|
|
149
|
+
try {
|
|
150
|
+
return fileURLToPath(normalized);
|
|
151
|
+
} catch {
|
|
152
|
+
throw new ReplayFilesystemBoundaryViolation();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return normalized;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function hasPathParameter(definition: ToolDefinition) {
|
|
159
|
+
if (!definition.parameters || typeof definition.parameters !== "object") {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
const properties = (definition.parameters as { properties?: unknown })
|
|
163
|
+
.properties;
|
|
164
|
+
return (
|
|
165
|
+
properties !== null &&
|
|
166
|
+
typeof properties === "object" &&
|
|
167
|
+
Object.hasOwn(properties, "path")
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function hasKnownReplayFilesystemImplementation(tool: {
|
|
172
|
+
name: string;
|
|
173
|
+
sourceInfo?: { path: string; source: string; origin: string };
|
|
174
|
+
}) {
|
|
175
|
+
if (REPLAY_BUILTIN_FILESYSTEM_TOOL_NAMES.has(tool.name)) {
|
|
176
|
+
return (
|
|
177
|
+
tool.sourceInfo?.source === "builtin" &&
|
|
178
|
+
tool.sourceInfo.path === `<builtin:${tool.name}>`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (!REPLAY_PACKAGE_FILESYSTEM_TOOL_NAMES.has(tool.name)) return false;
|
|
182
|
+
return (
|
|
183
|
+
tool.sourceInfo?.origin === "package" &&
|
|
184
|
+
tool.sourceInfo.path
|
|
185
|
+
.split(path.sep)
|
|
186
|
+
.join("/")
|
|
187
|
+
.endsWith("/extensions/file-search/index.ts")
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface ReplayFilesystemBoundaryOptions {
|
|
192
|
+
repositoryRoot: string;
|
|
193
|
+
cwd: string;
|
|
194
|
+
onViolation?: () => void;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Replayable filesystem tools are confined to existing canonical paths in the
|
|
199
|
+
* repository. Lexical containment rejects absolute, ~, and ../ escapes before
|
|
200
|
+
* they can observe the external filesystem; realpath containment rejects
|
|
201
|
+
* in-repository symlinks that escape. Unknown tool implementations and paths
|
|
202
|
+
* that cannot be canonicalized are denied rather than guessed safe.
|
|
203
|
+
*
|
|
204
|
+
* This is not an OS-atomic filesystem sandbox. The process-wide Workflow lease
|
|
205
|
+
* excludes known in-process writers, and the before/after path revision check
|
|
206
|
+
* rejects endpoint changes during execution. An unrelated external process can
|
|
207
|
+
* still perform an undetectable ABA change between those checks.
|
|
208
|
+
*/
|
|
209
|
+
export function createReplayFilesystemBoundary(
|
|
210
|
+
options: ReplayFilesystemBoundaryOptions,
|
|
211
|
+
) {
|
|
212
|
+
const repositoryRoot = canonicalPath(options.repositoryRoot);
|
|
213
|
+
const cwd = canonicalPath(options.cwd);
|
|
214
|
+
if (!pathIsWithin(repositoryRoot, cwd)) {
|
|
215
|
+
throw new Error("Replay cwd is outside the canonical repository");
|
|
216
|
+
}
|
|
217
|
+
const wrapped = new WeakSet<ToolDefinition>();
|
|
218
|
+
|
|
219
|
+
const deny = () => {
|
|
220
|
+
options.onViolation?.();
|
|
221
|
+
throw new Error(
|
|
222
|
+
"Replay filesystem boundary blocked an external, changing, symlink-escaping, or unverifiable path.",
|
|
223
|
+
);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const boundedParams = (toolName: string, params: unknown) => {
|
|
227
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
|
228
|
+
throw new ReplayFilesystemBoundaryViolation();
|
|
229
|
+
}
|
|
230
|
+
const input = params as Record<string, unknown>;
|
|
231
|
+
const rawPath = input.path;
|
|
232
|
+
if (rawPath === undefined) {
|
|
233
|
+
if (toolName === "read") {
|
|
234
|
+
throw new ReplayFilesystemBoundaryViolation();
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
params,
|
|
238
|
+
observation: observeReplayFilesystemPath(repositoryRoot, cwd),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (typeof rawPath !== "string") {
|
|
242
|
+
throw new ReplayFilesystemBoundaryViolation();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const normalized = normalizedObservationPath(rawPath);
|
|
246
|
+
const lexicalPath = path.resolve(cwd, normalized);
|
|
247
|
+
if (!pathIsWithin(repositoryRoot, lexicalPath)) {
|
|
248
|
+
throw new ReplayFilesystemBoundaryViolation();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const observation = observeReplayFilesystemPath(
|
|
252
|
+
repositoryRoot,
|
|
253
|
+
lexicalPath,
|
|
254
|
+
);
|
|
255
|
+
return {
|
|
256
|
+
params: {
|
|
257
|
+
...input,
|
|
258
|
+
path: path.isAbsolute(normalized)
|
|
259
|
+
? observation.canonicalPath
|
|
260
|
+
: path.relative(cwd, observation.canonicalPath) || ".",
|
|
261
|
+
},
|
|
262
|
+
observation,
|
|
263
|
+
};
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const wrap = (
|
|
267
|
+
tool: ReturnType<ReplayFilesystemToolRegistry["getAllTools"]>[number],
|
|
268
|
+
definition: ToolDefinition,
|
|
269
|
+
) => {
|
|
270
|
+
if (wrapped.has(definition)) return;
|
|
271
|
+
wrapped.add(definition);
|
|
272
|
+
const knownImplementation =
|
|
273
|
+
definition.name === tool.name &&
|
|
274
|
+
hasPathParameter(definition) &&
|
|
275
|
+
hasKnownReplayFilesystemImplementation(tool);
|
|
276
|
+
const execute = definition.execute;
|
|
277
|
+
definition.execute = async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
278
|
+
try {
|
|
279
|
+
if (!knownImplementation) {
|
|
280
|
+
throw new ReplayFilesystemBoundaryViolation();
|
|
281
|
+
}
|
|
282
|
+
const bounded = boundedParams(tool.name, params);
|
|
283
|
+
try {
|
|
284
|
+
return await execute.call(
|
|
285
|
+
definition,
|
|
286
|
+
toolCallId,
|
|
287
|
+
bounded.params,
|
|
288
|
+
signal,
|
|
289
|
+
onUpdate,
|
|
290
|
+
ctx,
|
|
291
|
+
);
|
|
292
|
+
} finally {
|
|
293
|
+
assertReplayFilesystemObservationStable(
|
|
294
|
+
repositoryRoot,
|
|
295
|
+
bounded.observation,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error instanceof ReplayFilesystemBoundaryViolation) return deny();
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
apply(registry: ReplayFilesystemToolRegistry) {
|
|
307
|
+
for (const tool of registry.getAllTools()) {
|
|
308
|
+
if (!REPLAY_FILESYSTEM_TOOL_NAMES.has(tool.name)) continue;
|
|
309
|
+
const definition = registry.getToolDefinition(tool.name);
|
|
310
|
+
if (definition) wrap(tool, definition);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface ReplayWorkspaceLease {
|
|
317
|
+
/** A cache lookup is safe at this instant. */
|
|
318
|
+
readonly canReplay: boolean;
|
|
319
|
+
/** True only if no unsafe call overlapped this call's full execution. */
|
|
320
|
+
canJournal(): boolean;
|
|
321
|
+
end(): void;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Endpoint fingerprints cannot detect an ABA write (change, observe, restore)
|
|
326
|
+
* from any Workflow agent in this process. Track every non-replay-safe call
|
|
327
|
+
* across its whole scheduled lifetime and refuse hits/journaling across overlap.
|
|
328
|
+
*/
|
|
329
|
+
export function createReplayWorkspaceGuard() {
|
|
330
|
+
let epoch = 0;
|
|
331
|
+
let activeUnsafe = 0;
|
|
332
|
+
return {
|
|
333
|
+
begin(replaySafe: boolean): ReplayWorkspaceLease {
|
|
334
|
+
const startEpoch = epoch;
|
|
335
|
+
const canReplay = replaySafe && activeUnsafe === 0;
|
|
336
|
+
let ended = false;
|
|
337
|
+
if (!replaySafe) {
|
|
338
|
+
activeUnsafe += 1;
|
|
339
|
+
epoch += 1;
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
canReplay,
|
|
343
|
+
canJournal: () =>
|
|
344
|
+
!ended &&
|
|
345
|
+
replaySafe &&
|
|
346
|
+
canReplay &&
|
|
347
|
+
activeUnsafe === 0 &&
|
|
348
|
+
epoch === startEpoch,
|
|
349
|
+
end: () => {
|
|
350
|
+
if (ended) return;
|
|
351
|
+
ended = true;
|
|
352
|
+
if (!replaySafe) {
|
|
353
|
+
activeUnsafe -= 1;
|
|
354
|
+
epoch += 1;
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// One process-global coordinator also survives duplicate extension module
|
|
363
|
+
// instances and hot reloads. It is deliberately conservative across unrelated
|
|
364
|
+
// repositories: correctness beats a replay hit, and this avoids another
|
|
365
|
+
// lifecycle-sensitive workspace registry.
|
|
366
|
+
const PROCESS_REPLAY_WORKSPACE_GUARD_KEY =
|
|
367
|
+
"__ttA1iMyPiSetupReplayWorkspaceGuard" as const;
|
|
368
|
+
type ReplayProcessGlobal = typeof globalThis & {
|
|
369
|
+
[PROCESS_REPLAY_WORKSPACE_GUARD_KEY]?: ReturnType<
|
|
370
|
+
typeof createReplayWorkspaceGuard
|
|
371
|
+
>;
|
|
372
|
+
};
|
|
373
|
+
const replayProcessGlobal = globalThis as ReplayProcessGlobal;
|
|
374
|
+
const processReplayWorkspaceGuard = (replayProcessGlobal[
|
|
375
|
+
PROCESS_REPLAY_WORKSPACE_GUARD_KEY
|
|
376
|
+
] ??= createReplayWorkspaceGuard());
|
|
377
|
+
|
|
378
|
+
export function beginProcessReplayWorkspaceLease(replaySafe: boolean) {
|
|
379
|
+
return processReplayWorkspaceGuard.begin(replaySafe);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
interface ReplayResourceLoader extends Pick<
|
|
383
|
+
DefaultResourceLoader,
|
|
384
|
+
| "getAgentsFiles"
|
|
385
|
+
| "getAppendSystemPrompt"
|
|
386
|
+
| "getExtensions"
|
|
387
|
+
| "getSkills"
|
|
388
|
+
| "getSystemPrompt"
|
|
389
|
+
> {}
|
|
390
|
+
|
|
391
|
+
function digest(value: string | Buffer) {
|
|
392
|
+
return createHash("sha256").update(value).digest("hex");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function boundedGit(cwd: string, args: readonly string[], deadline: number) {
|
|
396
|
+
const remaining = deadline - Date.now();
|
|
397
|
+
if (remaining <= 0) throw new Error("Replay identity deadline exceeded");
|
|
398
|
+
const output = execFileSync("git", ["-c", "core.fsmonitor=false", ...args], {
|
|
399
|
+
cwd,
|
|
400
|
+
encoding: "utf8",
|
|
401
|
+
maxBuffer: GIT_OUTPUT_LIMIT,
|
|
402
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
403
|
+
timeout: Math.min(remaining, REPLAY_IDENTITY_TIMEOUT_MS),
|
|
404
|
+
});
|
|
405
|
+
if (output.includes("\uFFFD")) {
|
|
406
|
+
throw new Error("Git output contains a path that is not valid UTF-8");
|
|
407
|
+
}
|
|
408
|
+
return output;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function canonicalPath(value: string) {
|
|
412
|
+
return fs.realpathSync.native(value);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function repositoryFingerprint(cwd: string) {
|
|
416
|
+
const deadline = Date.now() + REPLAY_IDENTITY_TIMEOUT_MS;
|
|
417
|
+
const git = (gitCwd: string, args: readonly string[]) =>
|
|
418
|
+
boundedGit(gitCwd, args, deadline);
|
|
419
|
+
const root = canonicalPath(git(cwd, ["rev-parse", "--show-toplevel"]).trim());
|
|
420
|
+
const head = git(root, ["rev-parse", "--verify", "HEAD"]).trim();
|
|
421
|
+
const diff = git(root, [
|
|
422
|
+
"diff",
|
|
423
|
+
"--no-ext-diff",
|
|
424
|
+
"--no-textconv",
|
|
425
|
+
"--binary",
|
|
426
|
+
"HEAD",
|
|
427
|
+
"--",
|
|
428
|
+
]);
|
|
429
|
+
const trackedModes = git(root, ["ls-files", "-s", "-z"]);
|
|
430
|
+
// Tracked symlinks can expose changing content outside Git, while gitlinks
|
|
431
|
+
// can expose submodule state absent from the parent diff. Neither has a
|
|
432
|
+
// complete bounded identity here, so execute instead of replaying.
|
|
433
|
+
if (
|
|
434
|
+
trackedModes
|
|
435
|
+
.split("\0")
|
|
436
|
+
.some(
|
|
437
|
+
(entry) => entry.startsWith("120000 ") || entry.startsWith("160000 "),
|
|
438
|
+
)
|
|
439
|
+
) {
|
|
440
|
+
throw new Error("symlinks and gitlinks make replay identity incomplete");
|
|
441
|
+
}
|
|
442
|
+
// Ignored files are observable to a read-capable child but are commonly too
|
|
443
|
+
// large or secret-bearing to hash (node_modules, .env, build output). A
|
|
444
|
+
// complete identity cannot pretend they do not exist, so disable replay.
|
|
445
|
+
const ignoredOutput = git(root, [
|
|
446
|
+
"ls-files",
|
|
447
|
+
"--others",
|
|
448
|
+
"--ignored",
|
|
449
|
+
"--exclude-standard",
|
|
450
|
+
"--directory",
|
|
451
|
+
"-z",
|
|
452
|
+
]);
|
|
453
|
+
if (ignoredOutput.length > 0) {
|
|
454
|
+
throw new Error("ignored files make the replay identity incomplete");
|
|
455
|
+
}
|
|
456
|
+
const untrackedOutput = git(root, [
|
|
457
|
+
"ls-files",
|
|
458
|
+
"--others",
|
|
459
|
+
"--exclude-standard",
|
|
460
|
+
"-z",
|
|
461
|
+
]);
|
|
462
|
+
if (untrackedOutput.includes("\uFFFD")) {
|
|
463
|
+
throw new Error("untracked paths are not valid UTF-8");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
let untrackedBytes = 0;
|
|
467
|
+
const untracked = untrackedOutput
|
|
468
|
+
.split("\0")
|
|
469
|
+
.filter(Boolean)
|
|
470
|
+
.sort()
|
|
471
|
+
.map((relativePath) => {
|
|
472
|
+
const filePath = path.join(root, relativePath);
|
|
473
|
+
const stat = fs.lstatSync(filePath);
|
|
474
|
+
untrackedBytes += stat.size;
|
|
475
|
+
if (untrackedBytes > GIT_OUTPUT_LIMIT) {
|
|
476
|
+
throw new Error("untracked files exceed replay fingerprint limit");
|
|
477
|
+
}
|
|
478
|
+
if (stat.isSymbolicLink()) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
`untracked symlink makes replay identity incomplete: ${relativePath}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
if (!stat.isFile()) {
|
|
484
|
+
throw new Error(`unsupported untracked resource: ${relativePath}`);
|
|
485
|
+
}
|
|
486
|
+
return [relativePath, "file", digest(fs.readFileSync(filePath))] as const;
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
root,
|
|
491
|
+
fingerprint: digest(
|
|
492
|
+
JSON.stringify({ root, head, diff: digest(diff), untracked }),
|
|
493
|
+
),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function resourceFile(pathname: string) {
|
|
498
|
+
const canonical = canonicalPath(pathname);
|
|
499
|
+
const stat = fs.statSync(canonical);
|
|
500
|
+
if (!stat.isFile()) throw new Error(`resource is not a file: ${canonical}`);
|
|
501
|
+
if (stat.size > REPLAY_RESOURCE_FILE_LIMIT) {
|
|
502
|
+
throw new Error(`resource exceeds replay fingerprint limit: ${canonical}`);
|
|
503
|
+
}
|
|
504
|
+
return { path: canonical, content: digest(fs.readFileSync(canonical)) };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function resourceFingerprint(loader: ReplayResourceLoader) {
|
|
508
|
+
const agentsFiles = loader
|
|
509
|
+
.getAgentsFiles()
|
|
510
|
+
.agentsFiles.map((file) => {
|
|
511
|
+
if (
|
|
512
|
+
Buffer.byteLength(file.content, "utf8") > REPLAY_RESOURCE_FILE_LIMIT
|
|
513
|
+
) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
`agent resource exceeds replay fingerprint limit: ${file.path}`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
path: canonicalPath(file.path),
|
|
520
|
+
content: digest(file.content),
|
|
521
|
+
};
|
|
522
|
+
})
|
|
523
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
524
|
+
const skills = loader
|
|
525
|
+
.getSkills()
|
|
526
|
+
.skills.map((skill) => ({
|
|
527
|
+
name: skill.name,
|
|
528
|
+
description: skill.description,
|
|
529
|
+
disabled: skill.disableModelInvocation,
|
|
530
|
+
file: resourceFile(skill.filePath),
|
|
531
|
+
}))
|
|
532
|
+
.sort((left, right) =>
|
|
533
|
+
`${left.name}\0${left.file.path}`.localeCompare(
|
|
534
|
+
`${right.name}\0${right.file.path}`,
|
|
535
|
+
),
|
|
536
|
+
);
|
|
537
|
+
const extensions = loader
|
|
538
|
+
.getExtensions()
|
|
539
|
+
.extensions.map((extension) => resourceFile(extension.resolvedPath))
|
|
540
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
541
|
+
|
|
542
|
+
return digest(
|
|
543
|
+
JSON.stringify({
|
|
544
|
+
systemPrompt: loader.getSystemPrompt(),
|
|
545
|
+
appendSystemPrompt: loader.getAppendSystemPrompt(),
|
|
546
|
+
agentsFiles,
|
|
547
|
+
skills,
|
|
548
|
+
extensions,
|
|
549
|
+
}),
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Bind a replayable result to the actual project and prompt/resource inputs a
|
|
555
|
+
* read-only child can observe. Any inability to prove that identity disables
|
|
556
|
+
* replay and journaling for the call; execution itself still proceeds.
|
|
557
|
+
*/
|
|
558
|
+
export function createReplayIdentity(
|
|
559
|
+
cwd: string,
|
|
560
|
+
loader: ReplayResourceLoader,
|
|
561
|
+
projectTrusted: boolean,
|
|
562
|
+
) {
|
|
563
|
+
try {
|
|
564
|
+
const canonicalCwd = canonicalPath(cwd);
|
|
565
|
+
const repository = repositoryFingerprint(canonicalCwd);
|
|
566
|
+
return {
|
|
567
|
+
version: 3,
|
|
568
|
+
cwd: canonicalCwd,
|
|
569
|
+
repositoryRoot: repository.root,
|
|
570
|
+
repository: repository.fingerprint,
|
|
571
|
+
resources: resourceFingerprint(loader),
|
|
572
|
+
projectTrusted,
|
|
573
|
+
};
|
|
574
|
+
} catch {
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
}
|