@bermudi/pi-delegate 0.1.13 → 0.1.14
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 +14 -17
- package/delegate.ts +0 -8
- package/dispatch.ts +24 -2
- package/extension.ts +14 -6
- package/file-tracking.ts +286 -15
- package/format.ts +79 -20
- package/isolated-workspace.ts +550 -62
- package/key-hints.ts +38 -0
- package/lifecycle.ts +555 -80
- package/manual.ts +1 -1
- package/model.ts +6 -1
- package/package.json +13 -9
- package/pool.ts +37 -6
- package/quiescence.ts +19 -6
- package/render-branches.ts +232 -85
- package/render-result.ts +116 -16
- package/runner.ts +361 -82
- package/session-quarantine.ts +266 -0
- package/spill.ts +93 -22
- package/task-resolution.ts +47 -48
- package/telemetry.ts +1 -1
- package/ticket-format.ts +8 -3
- package/tickets.ts +33 -13
- package/tools.ts +5 -4
- package/types.ts +77 -8
- package/utils.ts +123 -2
- package/workspace.ts +133 -0
- package/settings.ts +0 -418
package/tickets.ts
CHANGED
|
@@ -94,8 +94,13 @@ export const ticketRegistry = new TicketRegistry();
|
|
|
94
94
|
|
|
95
95
|
/** Generate a short human-copyable identifier for an async ticket. */
|
|
96
96
|
export function generateTicketId(): string {
|
|
97
|
-
//
|
|
98
|
-
|
|
97
|
+
// Retry on the extremely unlikely collision rather than allowing Map.set()
|
|
98
|
+
// in dispatch to replace a still-retained ticket.
|
|
99
|
+
let id: string;
|
|
100
|
+
do {
|
|
101
|
+
id = Math.random().toString(36).slice(2, 10);
|
|
102
|
+
} while (!id || ticketRegistry.has(id));
|
|
103
|
+
return id;
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
/** Remove completed tickets after their retention TTL. Running tickets have no
|
|
@@ -220,13 +225,19 @@ export function resolveFinalTicketStatus(
|
|
|
220
225
|
export function formatCompletedTicket(
|
|
221
226
|
ticket: AsyncTicket,
|
|
222
227
|
): AgentToolResult<DelegateDetails> {
|
|
228
|
+
// Shutdown can make a ticket terminal while its workers are still unwinding.
|
|
229
|
+
// Do not freeze that partial projection: late TaskResults must appear in a
|
|
230
|
+
// later poll once the worker-settled barrier has resolved. Tickets created
|
|
231
|
+
// before this marker existed (including simple fixtures) are already safe to
|
|
232
|
+
// memoize because only live async dispatches explicitly set it false.
|
|
233
|
+
const canMemoize = ticket.workersSettled !== false;
|
|
234
|
+
if (canMemoize && ticket.formattedResult) return ticket.formattedResult;
|
|
235
|
+
|
|
223
236
|
const parts: string[] = [];
|
|
224
237
|
const succeeded = ticket.results.filter(
|
|
225
238
|
(r) => r && !("error" in r && r.error),
|
|
226
239
|
).length;
|
|
227
|
-
const elapsedTotal = ticket.completedAt
|
|
228
|
-
? ticket.completedAt - ticket.created
|
|
229
|
-
: 0;
|
|
240
|
+
const elapsedTotal = (ticket.completedAt ?? Date.now()) - ticket.created;
|
|
230
241
|
// Surface the overall ticket status so a failed/cancelled batch is not
|
|
231
242
|
// mistaken for success. "done" tickets keep the original header; others
|
|
232
243
|
// get an explicit status tag up front.
|
|
@@ -280,7 +291,7 @@ export function formatCompletedTicket(
|
|
|
280
291
|
);
|
|
281
292
|
}
|
|
282
293
|
|
|
283
|
-
|
|
294
|
+
const formatted: AgentToolResult<DelegateDetails> = {
|
|
284
295
|
content: [{ type: "text", text: parts.join("\n\n") }],
|
|
285
296
|
details: {
|
|
286
297
|
tasks: ticket.tasks,
|
|
@@ -297,10 +308,13 @@ export function formatCompletedTicket(
|
|
|
297
308
|
// the human sees which ticket they polled, even in the rich tree path.
|
|
298
309
|
ticketId: ticket.id,
|
|
299
310
|
status: ticket.status,
|
|
311
|
+
elapsedMs: elapsedTotal,
|
|
300
312
|
overlapWarning: overlapWarning || undefined,
|
|
301
313
|
dispatchWarning: ticket.dispatchWarning,
|
|
302
314
|
},
|
|
303
315
|
};
|
|
316
|
+
if (canMemoize) ticket.formattedResult = formatted;
|
|
317
|
+
return formatted;
|
|
304
318
|
}
|
|
305
319
|
|
|
306
320
|
// ── Waiter helpers ─────────────────────────────────────────────────────────
|
|
@@ -340,6 +354,7 @@ function buildWaitDetails(ticket: AsyncTicket): DelegateDetails {
|
|
|
340
354
|
parentModel: ticket.parentModelId,
|
|
341
355
|
ticketId: ticket.id,
|
|
342
356
|
status: ticket.status,
|
|
357
|
+
elapsedMs: (ticket.completedAt ?? Date.now()) - ticket.created,
|
|
343
358
|
overlapWarning: overlapWarning || undefined,
|
|
344
359
|
dispatchWarning: ticket.dispatchWarning,
|
|
345
360
|
};
|
|
@@ -427,6 +442,9 @@ function settleWaiter(
|
|
|
427
442
|
if (w.settled) return;
|
|
428
443
|
w.settled = true;
|
|
429
444
|
w.clearDeadline?.();
|
|
445
|
+
w.clearDeadline = undefined;
|
|
446
|
+
w.removeAbortListener?.();
|
|
447
|
+
w.removeAbortListener = undefined;
|
|
430
448
|
w.resolve(result);
|
|
431
449
|
}
|
|
432
450
|
|
|
@@ -573,6 +591,7 @@ export function deliverTicketResults(
|
|
|
573
591
|
...formatted.details,
|
|
574
592
|
ticketId: ticket.id,
|
|
575
593
|
status: ticket.status,
|
|
594
|
+
crossLeafDelivery: crossLeaf,
|
|
576
595
|
},
|
|
577
596
|
},
|
|
578
597
|
crossLeaf
|
|
@@ -626,6 +645,7 @@ export function handlePoll(
|
|
|
626
645
|
// (friction #2). The LLM-facing content still names the ticket id too.
|
|
627
646
|
ticketId: ticket.id,
|
|
628
647
|
status: ticket.status,
|
|
648
|
+
elapsedMs: Date.now() - ticket.created,
|
|
629
649
|
overlapWarning: snapshot.overlapWarning || undefined,
|
|
630
650
|
dispatchWarning: ticket.dispatchWarning,
|
|
631
651
|
},
|
|
@@ -747,13 +767,13 @@ export function handleWait(
|
|
|
747
767
|
};
|
|
748
768
|
|
|
749
769
|
if (signal) {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
770
|
+
const onAbort = () => {
|
|
771
|
+
abortWaiter(waiter, ticket);
|
|
772
|
+
};
|
|
773
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
774
|
+
waiter.removeAbortListener = () => {
|
|
775
|
+
signal.removeEventListener("abort", onAbort);
|
|
776
|
+
};
|
|
757
777
|
}
|
|
758
778
|
|
|
759
779
|
if (
|
package/tools.ts
CHANGED
|
@@ -35,10 +35,11 @@ const PROVIDER_TOOLS: Readonly<Record<string, readonly string[]>> = {
|
|
|
35
35
|
};
|
|
36
36
|
|
|
37
37
|
export function availableToolNames(modelProvider?: string): string[] {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
const providerTools =
|
|
39
|
+
modelProvider && Object.hasOwn(PROVIDER_TOOLS, modelProvider)
|
|
40
|
+
? PROVIDER_TOOLS[modelProvider]
|
|
41
|
+
: undefined;
|
|
42
|
+
return [...Object.keys(TOOL_FACTORIES), ...(providerTools ?? [])];
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/** Expand tool-group shorthands (`*`, `ro`) into concrete tool lists.
|
package/types.ts
CHANGED
|
@@ -70,6 +70,7 @@ export interface TicketWaiter {
|
|
|
70
70
|
resolve: (result: AgentToolResult<DelegateDetails>) => void;
|
|
71
71
|
reject: (reason: unknown) => void;
|
|
72
72
|
clearDeadline?: () => void;
|
|
73
|
+
removeAbortListener?: () => void;
|
|
73
74
|
settled: boolean;
|
|
74
75
|
}
|
|
75
76
|
|
|
@@ -112,6 +113,10 @@ export interface AsyncTicket {
|
|
|
112
113
|
/** Immutable dispatch-scoped delegate.json snapshot used by async workers and
|
|
113
114
|
* later result formatting. */
|
|
114
115
|
config?: import("./config.ts").DelegateConfig;
|
|
116
|
+
/** Memoized terminal projection, populated only after workers settle. Besides
|
|
117
|
+
* avoiding repeated work, this keeps repeated poll/wait calls from creating
|
|
118
|
+
* duplicate output spill files without freezing a shutdown-time partial result. */
|
|
119
|
+
formattedResult?: AgentToolResult<DelegateDetails>;
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
/** Live parent settings captured when a delegate call starts. The built-in
|
|
@@ -156,10 +161,45 @@ export interface ResolvedTask {
|
|
|
156
161
|
providerExtensionSources?: string;
|
|
157
162
|
}
|
|
158
163
|
|
|
164
|
+
export interface FileAttributionPathSignature {
|
|
165
|
+
/** Absolute component inspected while resolving the pre-execution target. */
|
|
166
|
+
path: string;
|
|
167
|
+
/** Filesystem identity. Strings avoid precision loss on platforms whose
|
|
168
|
+
* inode/device values exceed JavaScript's safe integer range. */
|
|
169
|
+
dev: string;
|
|
170
|
+
ino: string;
|
|
171
|
+
birthtimeMs: number;
|
|
172
|
+
kind: "directory" | "symlink" | "other";
|
|
173
|
+
/** Exact link text, present only for symlinks. */
|
|
174
|
+
symlinkTarget?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface FileAttribution {
|
|
178
|
+
/** Absolute path spelled by the tool call. This remains associated with the
|
|
179
|
+
* physical snapshot so a later symlink cannot change what the call targeted. */
|
|
180
|
+
lexicalPath: string;
|
|
181
|
+
/** Physical target captured synchronously at tool_execution_start. Consumers
|
|
182
|
+
* must not resolve this path again against the mutable post-execution tree. */
|
|
183
|
+
preExecutionPhysicalPath?: string;
|
|
184
|
+
/** Identity/signature chain used to obtain the physical snapshot. It covers
|
|
185
|
+
* every existing resolved component, including the leaf, plus the exact text
|
|
186
|
+
* of each followed symlink. In-place mutation preserves the leaf identity;
|
|
187
|
+
* replacement makes attribution uncertain. */
|
|
188
|
+
preExecutionPathSignatures?: FileAttributionPathSignature[];
|
|
189
|
+
/** Which explicit native tool supplied this evidence. */
|
|
190
|
+
provenance: "edit" | "write";
|
|
191
|
+
/** Canonicalization was incomplete, ambiguous, or its signature chain changed
|
|
192
|
+
* after execution. Such evidence is retained conservatively even when its
|
|
193
|
+
* lexical path is in a disposable workspace. */
|
|
194
|
+
uncertain: boolean;
|
|
195
|
+
}
|
|
196
|
+
|
|
159
197
|
export interface ToolActivity {
|
|
160
198
|
id: string;
|
|
161
199
|
name: string;
|
|
162
200
|
args: Record<string, unknown>;
|
|
201
|
+
/** Structured edit/write attribution captured before execution. */
|
|
202
|
+
fileAttribution?: FileAttribution;
|
|
163
203
|
result?: {
|
|
164
204
|
content: Array<{ type: string; text?: string }>;
|
|
165
205
|
isError: boolean;
|
|
@@ -171,6 +211,8 @@ export interface ToolActivity {
|
|
|
171
211
|
}
|
|
172
212
|
|
|
173
213
|
/** Stable machine-readable reason for a task failure.
|
|
214
|
+
* - `cancelled`: the caller or async-ticket controller requested cancellation.
|
|
215
|
+
* This must not be inferred from provider error text such as "Aborted".
|
|
174
216
|
* - `stalled`: inactivity watchdog fired; the prompt was cooperatively aborted.
|
|
175
217
|
* - `model_error`: the failure is attributable to the resolved model/provider
|
|
176
218
|
* (account usage limit, quota exhausted, auth lost) — not transient for that
|
|
@@ -179,7 +221,8 @@ export interface ToolActivity {
|
|
|
179
221
|
* - `deadline_exceeded`: the task's `deadlineMs` wall-clock budget expired
|
|
180
222
|
* (measured from when the task left the concurrency queue). The prompt was
|
|
181
223
|
* cooperatively aborted; completed side effects are not rolled back. */
|
|
182
|
-
export type TaskFailureKind =
|
|
224
|
+
export type TaskFailureKind =
|
|
225
|
+
"cancelled" | "stalled" | "model_error" | "deadline_exceeded";
|
|
183
226
|
|
|
184
227
|
export interface TaskProgress {
|
|
185
228
|
id?: string;
|
|
@@ -192,6 +235,8 @@ export interface TaskProgress {
|
|
|
192
235
|
toolUses: number;
|
|
193
236
|
error?: string;
|
|
194
237
|
failureKind?: TaskFailureKind;
|
|
238
|
+
/** Terminal lower-bound result returned after quiescence abandonment. */
|
|
239
|
+
incomplete?: "quiescence_abandoned";
|
|
195
240
|
model?: string;
|
|
196
241
|
lastActivityAt?: number;
|
|
197
242
|
activities: ToolActivity[];
|
|
@@ -208,6 +253,10 @@ export interface DelegateDetails {
|
|
|
208
253
|
ticketId?: string;
|
|
209
254
|
/** Terminal/live ticket status when this result comes from an async ticket. */
|
|
210
255
|
status?: AsyncTicket["status"];
|
|
256
|
+
/** Actual batch wall time for stable rendering outside the live tool context. */
|
|
257
|
+
elapsedMs?: number;
|
|
258
|
+
/** Async result arrived on a different session-tree leaf than it was spawned on. */
|
|
259
|
+
crossLeafDelivery?: boolean;
|
|
211
260
|
/** Global overlap warning derived from result.attributedFiles, surfaced in both
|
|
212
261
|
* the textual content and the custom TUI. */
|
|
213
262
|
overlapWarning?: string;
|
|
@@ -222,19 +271,24 @@ export interface TaskResult {
|
|
|
222
271
|
error?: string;
|
|
223
272
|
/** Stable machine-readable failure reason; error remains human-facing. */
|
|
224
273
|
failureKind?: TaskFailureKind;
|
|
274
|
+
/** The task returned while its quarantined AgentSession could still run.
|
|
275
|
+
* Output, file evidence, token usage, and cost are lower bounds rather than
|
|
276
|
+
* final accounting. */
|
|
277
|
+
incomplete?: "quiescence_abandoned";
|
|
225
278
|
durationMs: number;
|
|
226
279
|
/** Display token count for the task, derived from the compaction-inclusive
|
|
227
280
|
* session-stat delta. This matches `usage.totalTokens`; the usage object
|
|
228
281
|
* additionally preserves the provider breakdown and cost. */
|
|
229
282
|
tokens: number;
|
|
230
|
-
/** Full provider Usage
|
|
231
|
-
* history. Always present (`emptyUsage()` on no-op/early-failure paths)
|
|
232
|
-
* sync delegate
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
283
|
+
/** Full provider Usage observed for this task, including compacted-away
|
|
284
|
+
* history. Always present (`emptyUsage()` on no-op/early-failure paths).
|
|
285
|
+
* When `incomplete` is absent, sync delegate can fold it into the parent and
|
|
286
|
+
* aggregate `cost.total` is final. For an abandoned task it is only a lower
|
|
287
|
+
* bound and dispatch omits top-level nested usage. Per-component cost fields
|
|
288
|
+
* stay 0 because `getSessionStats()` exposes only aggregate cost. */
|
|
236
289
|
usage: Usage;
|
|
237
|
-
/** Scratch
|
|
290
|
+
/** Scratch is never resumable. Its certain internal writes are excluded from
|
|
291
|
+
* shared-file conflict detection; external or uncertain evidence is retained. */
|
|
238
292
|
workspace?: WorkspaceMode;
|
|
239
293
|
sessionFile?: string;
|
|
240
294
|
/** All files the subagent is known to have touched, including bash mutations
|
|
@@ -245,6 +299,9 @@ export interface TaskResult {
|
|
|
245
299
|
* for overlap detection so concurrent tasks in the same repo do not
|
|
246
300
|
* fabricate false conflicts from shared git snapshots. */
|
|
247
301
|
attributedFiles?: string[];
|
|
302
|
+
/** Provenance-bearing evidence behind attributedFiles. Optional for legacy
|
|
303
|
+
* callers/tests that only provide the projected string list. */
|
|
304
|
+
fileAttributions?: FileAttribution[];
|
|
248
305
|
/** Git-native proposal/reconciliation outcome for workspace:"isolated". */
|
|
249
306
|
integration?: TaskIntegration;
|
|
250
307
|
}
|
|
@@ -259,6 +316,15 @@ export type TaskIntegrationStatus =
|
|
|
259
316
|
interface TaskIntegrationFiles {
|
|
260
317
|
proposedFiles: string[];
|
|
261
318
|
appliedFiles: string[];
|
|
319
|
+
/** Cleanup is separate from the integration outcome: a proposal can remain
|
|
320
|
+
* successfully applied even when a disposable worktree cannot be removed. */
|
|
321
|
+
cleanupIssue?: {
|
|
322
|
+
status: "deferred" | "failed";
|
|
323
|
+
reason: string;
|
|
324
|
+
/** Stable recovery marker. It may disappear after a deferred cleanup
|
|
325
|
+
* succeeds, but is retained when cleanup fails. */
|
|
326
|
+
recoveryPath?: string;
|
|
327
|
+
};
|
|
262
328
|
}
|
|
263
329
|
|
|
264
330
|
interface TaskIntegrationWithoutRecovery extends TaskIntegrationFiles {
|
|
@@ -278,6 +344,9 @@ export type TaskIntegration =
|
|
|
278
344
|
})
|
|
279
345
|
| (TaskIntegrationWithoutRecovery & {
|
|
280
346
|
status: "discarded";
|
|
347
|
+
/** Reporting-only issues encountered while classifying a failed task's
|
|
348
|
+
* paths. They do not turn the discarded proposal into an apply failure. */
|
|
349
|
+
classificationIssues?: Array<{ path: string; reason: string }>;
|
|
281
350
|
})
|
|
282
351
|
| (TaskIntegrationFiles & {
|
|
283
352
|
status: "conflict";
|
package/utils.ts
CHANGED
|
@@ -58,8 +58,129 @@ export function extractTextFromPartialResult(
|
|
|
58
58
|
|
|
59
59
|
/** Strip ANSI escape sequences from text. */
|
|
60
60
|
export function stripAnsi(text: string): string {
|
|
61
|
-
//
|
|
62
|
-
|
|
61
|
+
// A scanner keeps malformed, unterminated control strings linear-time.
|
|
62
|
+
// Regexes with lazy "anything until ST" branches become quadratic on input
|
|
63
|
+
// containing many unterminated OSC/DCS introducers.
|
|
64
|
+
let clean = "";
|
|
65
|
+
for (let index = 0; index < text.length;) {
|
|
66
|
+
const code = text.charCodeAt(index);
|
|
67
|
+
|
|
68
|
+
if (code === 0x1b) {
|
|
69
|
+
const next = text.charCodeAt(index + 1);
|
|
70
|
+
if (next === 0x5d) {
|
|
71
|
+
index = skipControlString(text, index + 2, true);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
|
|
75
|
+
index = skipControlString(text, index + 2, false);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (next === 0x5b) {
|
|
79
|
+
index = skipCsi(text, index + 2);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (next === 0x5c) {
|
|
83
|
+
// Preserve a boundary for a standalone 7-bit ST just as for C1 ST,
|
|
84
|
+
// so removing it cannot concatenate attacker-controlled words.
|
|
85
|
+
clean += " ";
|
|
86
|
+
index += 2;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Generic ESC sequence: intermediates followed by one final byte.
|
|
91
|
+
index++;
|
|
92
|
+
while (index < text.length) {
|
|
93
|
+
const value = text.charCodeAt(index);
|
|
94
|
+
if (value < 0x20 || value > 0x2f) break;
|
|
95
|
+
index++;
|
|
96
|
+
}
|
|
97
|
+
if (index < text.length) {
|
|
98
|
+
const final = text.charCodeAt(index);
|
|
99
|
+
if (final >= 0x30 && final <= 0x7e) index++;
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (code === 0x9d) {
|
|
105
|
+
index = skipControlString(text, index + 1, true);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (code === 0x90 || code === 0x98 || code === 0x9e || code === 0x9f) {
|
|
109
|
+
index = skipControlString(text, index + 1, false);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (code === 0x9b) {
|
|
113
|
+
index = skipCsi(text, index + 1);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (code === 0x9c) {
|
|
117
|
+
// Preserve a boundary for a stray terminator so sanitization cannot
|
|
118
|
+
// concatenate attacker-controlled words around the removed control.
|
|
119
|
+
clean += " ";
|
|
120
|
+
index++;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
clean += text[index]!;
|
|
125
|
+
index++;
|
|
126
|
+
}
|
|
127
|
+
return clean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function skipControlString(
|
|
131
|
+
text: string,
|
|
132
|
+
index: number,
|
|
133
|
+
bellTerminates: boolean,
|
|
134
|
+
): number {
|
|
135
|
+
while (index < text.length) {
|
|
136
|
+
const code = text.charCodeAt(index);
|
|
137
|
+
if ((bellTerminates && code === 0x07) || code === 0x9c) return index + 1;
|
|
138
|
+
if (
|
|
139
|
+
code === 0x1b &&
|
|
140
|
+
index + 1 < text.length &&
|
|
141
|
+
text.charCodeAt(index + 1) === 0x5c
|
|
142
|
+
) {
|
|
143
|
+
return index + 2;
|
|
144
|
+
}
|
|
145
|
+
index++;
|
|
146
|
+
}
|
|
147
|
+
return index;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function skipCsi(text: string, index: number): number {
|
|
151
|
+
while (index < text.length) {
|
|
152
|
+
const code = text.charCodeAt(index);
|
|
153
|
+
// A malformed CSI must not consume diagnostic layout while searching for
|
|
154
|
+
// a final byte. Leave common layout controls for the outer sanitizer.
|
|
155
|
+
if (code === 0x09 || code === 0x0a || code === 0x0d) return index;
|
|
156
|
+
index++;
|
|
157
|
+
if (code >= 0x40 && code <= 0x7e) break;
|
|
158
|
+
}
|
|
159
|
+
return index;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Remove terminal controls from untrusted multiline text while preserving its
|
|
164
|
+
* line and ordinary whitespace structure for markdown/plain-text rendering.
|
|
165
|
+
*/
|
|
166
|
+
export function sanitizeTerminalText(text: string): string {
|
|
167
|
+
return (
|
|
168
|
+
stripAnsi(text)
|
|
169
|
+
.replace(/\r\n?|\u2028|\u2029/g, "\n")
|
|
170
|
+
.replace(/[\u0000-\u0009\u000b-\u001f\u007f-\u009f]+/g, " ")
|
|
171
|
+
// Invisible bidi marks, embeddings/overrides, and isolates can reorder
|
|
172
|
+
// attacker-controlled terminal text without changing its stored spelling.
|
|
173
|
+
.replace(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "")
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Flatten untrusted text for one terminal row. ANSI sequences and terminal
|
|
179
|
+
* controls are removed before whitespace is normalized, so callers can safely
|
|
180
|
+
* truncate and store the result without preserving a partial escape sequence.
|
|
181
|
+
*/
|
|
182
|
+
export function sanitizeTerminalLine(text: string): string {
|
|
183
|
+
return sanitizeTerminalText(text).replace(/\s+/g, " ").trim();
|
|
63
184
|
}
|
|
64
185
|
|
|
65
186
|
/** Resolve carriage-return progress bars to their final line state. */
|
package/workspace.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { revalidateFileAttribution } from "./file-tracking.ts";
|
|
4
5
|
import { scheduleDeadline } from "./timer.ts";
|
|
6
|
+
import type { FileAttribution } from "./types.ts";
|
|
5
7
|
|
|
6
8
|
const SCRATCH_CONTAINER_NAME = ".pi-delegate-scratch";
|
|
7
9
|
const SCRATCH_LEASE_PREFIX = "lease-";
|
|
@@ -29,6 +31,17 @@ export interface ScratchWorkspace {
|
|
|
29
31
|
* compare with the host path they actually touched.
|
|
30
32
|
*/
|
|
31
33
|
resolveAttributedPath(candidate: string): Promise<string | undefined>;
|
|
34
|
+
/** Project structured attribution without re-resolving its execution-time
|
|
35
|
+
* physical snapshot. Uncertain internal evidence is retained. */
|
|
36
|
+
resolveFileAttribution?(
|
|
37
|
+
attribution: FileAttribution,
|
|
38
|
+
): Promise<FileAttribution | undefined>;
|
|
39
|
+
/** Project the lexical touched-path half of structured attribution. A
|
|
40
|
+
* certainly attributed copied symlink is suppressed only when both link
|
|
41
|
+
* identities remain stable while their matching targets are read. */
|
|
42
|
+
resolveAttributedLexicalTouch?(
|
|
43
|
+
attribution: FileAttribution,
|
|
44
|
+
): Promise<string | undefined>;
|
|
32
45
|
/** True when the existing path resolves inside the disposable tree. */
|
|
33
46
|
isDisposablePath(candidate: string): Promise<boolean>;
|
|
34
47
|
cleanup(): Promise<void>;
|
|
@@ -186,6 +199,30 @@ function isWithin(root: string, candidate: string): boolean {
|
|
|
186
199
|
);
|
|
187
200
|
}
|
|
188
201
|
|
|
202
|
+
function errnoOf(error: unknown): string {
|
|
203
|
+
return error instanceof Error && "code" in error
|
|
204
|
+
? String((error as NodeJS.ErrnoException).code ?? "UNKNOWN")
|
|
205
|
+
: "UNKNOWN";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function logScratchProjectionFailure(
|
|
209
|
+
operation: "lstat" | "readlink",
|
|
210
|
+
candidate: string,
|
|
211
|
+
error: unknown,
|
|
212
|
+
): void {
|
|
213
|
+
const safeCandidate = JSON.stringify(candidate)
|
|
214
|
+
.replace(/\u2028/g, "\\u2028")
|
|
215
|
+
.replace(/\u2029/g, "\\u2029");
|
|
216
|
+
console.error(
|
|
217
|
+
`[delegate] scratch attribution ${operation} failed for ${safeCandidate} (errno=${errnoOf(error)}); retaining lexical source evidence`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isExpectedPathRace(error: unknown): boolean {
|
|
222
|
+
const code = errnoOf(error);
|
|
223
|
+
return code === "ENOENT" || code === "ENOTDIR" || code === "EINVAL";
|
|
224
|
+
}
|
|
225
|
+
|
|
189
226
|
function isProcessAlive(pid: number): boolean {
|
|
190
227
|
try {
|
|
191
228
|
process.kill(pid, 0);
|
|
@@ -794,6 +831,100 @@ export async function createScratchWorkspace(
|
|
|
794
831
|
return isWithin(completedRoot, absolute) ? undefined : absolute;
|
|
795
832
|
}
|
|
796
833
|
};
|
|
834
|
+
const mapDisposableLexically = (candidate: string): string => {
|
|
835
|
+
const absolute = path.resolve(candidate);
|
|
836
|
+
return isWithin(completedRoot, absolute)
|
|
837
|
+
? path.join(sourceRoot!, path.relative(completedRoot, absolute))
|
|
838
|
+
: absolute;
|
|
839
|
+
};
|
|
840
|
+
const resolveFileAttribution = async (
|
|
841
|
+
original: FileAttribution,
|
|
842
|
+
): Promise<FileAttribution | undefined> => {
|
|
843
|
+
const attribution = revalidateFileAttribution(original);
|
|
844
|
+
const physical = attribution.preExecutionPhysicalPath;
|
|
845
|
+
// A certain physical snapshot inside scratch is disposable. Crucially, do
|
|
846
|
+
// not realpath it now: the tool may have replaced that node with a symlink.
|
|
847
|
+
if (
|
|
848
|
+
physical &&
|
|
849
|
+
!attribution.uncertain &&
|
|
850
|
+
isWithin(completedRoot, physical)
|
|
851
|
+
) {
|
|
852
|
+
return undefined;
|
|
853
|
+
}
|
|
854
|
+
return {
|
|
855
|
+
...attribution,
|
|
856
|
+
lexicalPath: mapDisposableLexically(attribution.lexicalPath),
|
|
857
|
+
preExecutionPhysicalPath: physical
|
|
858
|
+
? mapDisposableLexically(physical)
|
|
859
|
+
: undefined,
|
|
860
|
+
};
|
|
861
|
+
};
|
|
862
|
+
const resolveAttributedLexicalTouch = async (
|
|
863
|
+
original: FileAttribution,
|
|
864
|
+
): Promise<string | undefined> => {
|
|
865
|
+
const attribution = revalidateFileAttribution(original);
|
|
866
|
+
const lexical = path.resolve(attribution.lexicalPath);
|
|
867
|
+
if (!isWithin(completedRoot, lexical)) return lexical;
|
|
868
|
+
const source = path.join(
|
|
869
|
+
sourceRoot!,
|
|
870
|
+
path.relative(completedRoot, lexical),
|
|
871
|
+
);
|
|
872
|
+
// Suppression is an optimization for a copied, unchanged symlink node. An
|
|
873
|
+
// uncertain attribution cannot prove that the lexical node was harmless.
|
|
874
|
+
if (attribution.uncertain) return source;
|
|
875
|
+
|
|
876
|
+
const inspect = async (
|
|
877
|
+
candidate: string,
|
|
878
|
+
): Promise<fs.Stats | undefined> => {
|
|
879
|
+
try {
|
|
880
|
+
return await fs.promises.lstat(candidate);
|
|
881
|
+
} catch (error) {
|
|
882
|
+
if (!isExpectedPathRace(error)) {
|
|
883
|
+
logScratchProjectionFailure("lstat", candidate, error);
|
|
884
|
+
}
|
|
885
|
+
return undefined;
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
const [scratchStat, sourceStat] = await Promise.all([
|
|
889
|
+
inspect(lexical),
|
|
890
|
+
inspect(source),
|
|
891
|
+
]);
|
|
892
|
+
if (scratchStat?.isSymbolicLink() && sourceStat?.isSymbolicLink()) {
|
|
893
|
+
const readStableLink = async (
|
|
894
|
+
candidate: string,
|
|
895
|
+
before: fs.Stats,
|
|
896
|
+
): Promise<string | undefined> => {
|
|
897
|
+
let target: string;
|
|
898
|
+
try {
|
|
899
|
+
target = await fs.promises.readlink(candidate);
|
|
900
|
+
} catch (error) {
|
|
901
|
+
if (!isExpectedPathRace(error)) {
|
|
902
|
+
logScratchProjectionFailure("readlink", candidate, error);
|
|
903
|
+
}
|
|
904
|
+
return undefined;
|
|
905
|
+
}
|
|
906
|
+
const after = await inspect(candidate);
|
|
907
|
+
return after?.isSymbolicLink() && sameFileIdentity(before, after)
|
|
908
|
+
? target
|
|
909
|
+
: undefined;
|
|
910
|
+
};
|
|
911
|
+
const [scratchTarget, sourceTarget] = await Promise.all([
|
|
912
|
+
readStableLink(lexical, scratchStat),
|
|
913
|
+
readStableLink(source, sourceStat),
|
|
914
|
+
]);
|
|
915
|
+
// A readlink or identity race is not evidence that the nodes matched.
|
|
916
|
+
// Keep the lexical source path rather than dropping it.
|
|
917
|
+
if (
|
|
918
|
+
scratchTarget !== undefined &&
|
|
919
|
+
sourceTarget !== undefined &&
|
|
920
|
+
scratchTarget === sourceTarget
|
|
921
|
+
) {
|
|
922
|
+
return undefined;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
// This is evidence about the lexical node, not its current target.
|
|
926
|
+
return source;
|
|
927
|
+
};
|
|
797
928
|
return {
|
|
798
929
|
sourceRoot: sourceRoot!,
|
|
799
930
|
sourceCwd: sourceCwd!,
|
|
@@ -806,6 +937,8 @@ export async function createScratchWorkspace(
|
|
|
806
937
|
},
|
|
807
938
|
resolveReportedPath,
|
|
808
939
|
resolveAttributedPath,
|
|
940
|
+
resolveFileAttribution,
|
|
941
|
+
resolveAttributedLexicalTouch,
|
|
809
942
|
async isDisposablePath(candidate: string): Promise<boolean> {
|
|
810
943
|
return (await resolveAttributedPath(candidate)) === undefined;
|
|
811
944
|
},
|