pi-background-tasks 2.5.0 → 2.6.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/BACKGROUND-TASKS-INSTRUCTIONS.md +1 -1
- package/README.md +70 -25
- package/TESTING.md +42 -2
- package/TEST_PLAN.md +7 -4
- package/dist/extensions/anthropic-attribution-child.js +4 -0
- package/dist/extensions/anthropic-attribution-child.js.map +1 -0
- package/dist/extensions/anthropic-attribution.js +107 -0
- package/dist/extensions/anthropic-attribution.js.map +1 -0
- package/dist/extensions/background-tasks.js +2 -0
- package/dist/extensions/background-tasks.js.map +1 -0
- package/dist/extensions/delegate-child.js +2 -0
- package/dist/extensions/delegate-child.js.map +1 -0
- package/dist/extensions/fusion-child.js +2 -0
- package/dist/extensions/fusion-child.js.map +1 -0
- package/dist/package.json +5 -0
- package/dist/src/core/anthropic-attribution-path.js +24 -0
- package/dist/src/core/anthropic-attribution-path.js.map +1 -0
- package/dist/src/core/anthropic-attribution.js +2247 -0
- package/dist/src/core/anthropic-attribution.js.map +1 -0
- package/dist/src/core/attested-pi-contract.js +5 -0
- package/dist/src/core/attested-pi-contract.js.map +1 -0
- package/dist/src/core/attested-pi-run.js +749 -0
- package/dist/src/core/attested-pi-run.js.map +1 -0
- package/dist/src/core/canonical-json.js +19 -0
- package/dist/src/core/canonical-json.js.map +1 -0
- package/dist/src/core/common.js +787 -0
- package/dist/src/core/common.js.map +1 -0
- package/dist/src/core/config.js +78 -0
- package/dist/src/core/config.js.map +1 -0
- package/dist/src/core/context/parent-snapshot.js +75 -0
- package/dist/src/core/context/parent-snapshot.js.map +1 -0
- package/dist/src/core/context/token-budget.js +631 -0
- package/dist/src/core/context/token-budget.js.map +1 -0
- package/dist/src/core/context/visible-conversation-v2.js +390 -0
- package/dist/src/core/context/visible-conversation-v2.js.map +1 -0
- package/dist/src/core/delegate/artifacts.js +357 -0
- package/dist/src/core/delegate/artifacts.js.map +1 -0
- package/dist/src/core/delegate/budget.js +266 -0
- package/dist/src/core/delegate/budget.js.map +1 -0
- package/dist/src/core/delegate/facade-contract.js +7 -0
- package/dist/src/core/delegate/facade-contract.js.map +1 -0
- package/dist/src/core/delegate/hook-contract-evidence.json +18 -0
- package/dist/src/core/delegate/hook-contract.js +124 -0
- package/dist/src/core/delegate/hook-contract.js.map +1 -0
- package/dist/src/core/delegate/launch.js +366 -0
- package/dist/src/core/delegate/launch.js.map +1 -0
- package/dist/src/core/delegate/result-package.js +293 -0
- package/dist/src/core/delegate/result-package.js.map +1 -0
- package/dist/src/core/delegate/runner.js +398 -0
- package/dist/src/core/delegate/runner.js.map +1 -0
- package/dist/src/core/delegate/seed.js +334 -0
- package/dist/src/core/delegate/seed.js.map +1 -0
- package/dist/src/core/delegate/types.js +103 -0
- package/dist/src/core/delegate/types.js.map +1 -0
- package/dist/src/core/durable-fs.js +372 -0
- package/dist/src/core/durable-fs.js.map +1 -0
- package/dist/src/core/extension-api.js +382 -0
- package/dist/src/core/extension-api.js.map +1 -0
- package/dist/src/core/fusion/artifacts.js +654 -0
- package/dist/src/core/fusion/artifacts.js.map +1 -0
- package/dist/src/core/fusion/budget.js +949 -0
- package/dist/src/core/fusion/budget.js.map +1 -0
- package/dist/src/core/fusion/child-protocol.js +176 -0
- package/dist/src/core/fusion/child-protocol.js.map +1 -0
- package/dist/src/core/fusion/claude-cache.js +156 -0
- package/dist/src/core/fusion/claude-cache.js.map +1 -0
- package/dist/src/core/fusion/clean-context.js +64 -0
- package/dist/src/core/fusion/clean-context.js.map +1 -0
- package/dist/src/core/fusion/config.js +349 -0
- package/dist/src/core/fusion/config.js.map +1 -0
- package/dist/src/core/fusion/context.js +184 -0
- package/dist/src/core/fusion/context.js.map +1 -0
- package/dist/src/core/fusion/evaluation.js +696 -0
- package/dist/src/core/fusion/evaluation.js.map +1 -0
- package/dist/src/core/fusion/facade-contract.js +3 -0
- package/dist/src/core/fusion/facade-contract.js.map +1 -0
- package/dist/src/core/fusion/orchestrator.js +867 -0
- package/dist/src/core/fusion/orchestrator.js.map +1 -0
- package/dist/src/core/fusion/output-contract.js +27 -0
- package/dist/src/core/fusion/output-contract.js.map +1 -0
- package/dist/src/core/fusion/pi-child.js +1741 -0
- package/dist/src/core/fusion/pi-child.js.map +1 -0
- package/dist/src/core/fusion/prompts.js +260 -0
- package/dist/src/core/fusion/prompts.js.map +1 -0
- package/dist/src/core/fusion/result-package.js +857 -0
- package/dist/src/core/fusion/result-package.js.map +1 -0
- package/dist/src/core/fusion/source-policy.js +259 -0
- package/dist/src/core/fusion/source-policy.js.map +1 -0
- package/dist/src/core/fusion/types.js +207 -0
- package/dist/src/core/fusion/types.js.map +1 -0
- package/dist/src/core/fusion/web-fetch.js +777 -0
- package/dist/src/core/fusion/web-fetch.js.map +1 -0
- package/dist/src/core/fusion/workflows.js +126 -0
- package/dist/src/core/fusion/workflows.js.map +1 -0
- package/dist/src/core/lazy-module.js +181 -0
- package/dist/src/core/lazy-module.js.map +1 -0
- package/dist/src/core/pi-launch.js +431 -0
- package/dist/src/core/pi-launch.js.map +1 -0
- package/dist/src/core/registry.js +3331 -0
- package/dist/src/core/registry.js.map +1 -0
- package/dist/src/core/reload-shell-owner.js +1396 -0
- package/dist/src/core/reload-shell-owner.js.map +1 -0
- package/dist/src/core/shell-policy.js +80 -0
- package/dist/src/core/shell-policy.js.map +1 -0
- package/dist/src/core/task-durable.js +51 -0
- package/dist/src/core/task-durable.js.map +1 -0
- package/dist/src/core/update-check.js +92 -0
- package/dist/src/core/update-check.js.map +1 -0
- package/dist/src/core/windows-taskkill.js +185 -0
- package/dist/src/core/windows-taskkill.js.map +1 -0
- package/dist/src/delegate-child-extension.js +776 -0
- package/dist/src/delegate-child-extension.js.map +1 -0
- package/dist/src/delegate-extension.js +746 -0
- package/dist/src/delegate-extension.js.map +1 -0
- package/dist/src/extension.js +953 -0
- package/dist/src/extension.js.map +1 -0
- package/dist/src/fusion-child-extension.js +760 -0
- package/dist/src/fusion-child-extension.js.map +1 -0
- package/dist/src/fusion-extension.js +1030 -0
- package/dist/src/fusion-extension.js.map +1 -0
- package/dist/src/ui/background-tasks-manager.js +689 -0
- package/dist/src/ui/background-tasks-manager.js.map +1 -0
- package/dist/src/ui/fusion-model-selector.js +277 -0
- package/dist/src/ui/fusion-model-selector.js.map +1 -0
- package/docs/INDEX.md +35 -33
- package/docs/api/eventbus-v1.md +21 -5
- package/docs/choose-a-workflow.md +2 -0
- package/docs/commands/bg-clear.md +3 -3
- package/docs/commands/bg-update.md +3 -3
- package/docs/commands/bg.md +18 -6
- package/docs/commands/claude-cache.md +4 -4
- package/docs/commands/fusion-models.md +49 -4
- package/docs/commands/fusion.md +3 -3
- package/docs/commands/jobs.md +3 -3
- package/docs/commands/kill.md +3 -3
- package/docs/commands/logs.md +3 -3
- package/docs/commands/task-manager.md +5 -5
- package/docs/concepts/completion-delivery.md +6 -2
- package/docs/getting-started.md +7 -1
- package/docs/manifest.json +228 -42
- package/docs/operations/configuration.md +58 -5
- package/docs/operations/releasing.md +4 -2
- package/docs/operations/testing.md +22 -0
- package/docs/operations/troubleshooting.md +6 -2
- package/docs/read-before-edit.md +44 -31
- package/docs/reference/runtime-contracts.md +87 -51
- package/docs/reference/shortcuts-and-dock.md +28 -14
- package/docs/subsystems/anthropic-attribution.md +23 -11
- package/docs/subsystems/attested-pi-runs.md +12 -7
- package/docs/subsystems/background-task-runtime.md +53 -11
- package/docs/subsystems/child-launch-durability-and-safety.md +28 -9
- package/docs/subsystems/delegation.md +16 -3
- package/docs/subsystems/docs-freshness-gate.md +14 -7
- package/docs/subsystems/fusion.md +36 -11
- package/docs/subsystems/host-ui-and-telemetry.md +22 -9
- package/docs/tools/bg_delegate.md +3 -1
- package/docs/tools/bg_kill.md +3 -1
- package/docs/tools/bg_logs.md +3 -1
- package/docs/tools/bg_result.md +5 -3
- package/docs/tools/bg_run.md +31 -5
- package/docs/tools/bg_run_pi_attested.md +3 -1
- package/docs/tools/bg_status.md +3 -1
- package/docs/tools/fusion_investigate.md +3 -1
- package/docs/tools/fusion_reason.md +3 -1
- package/docs/tools/fusion_research.md +3 -1
- package/docs/tools/fusion_validate.md +3 -1
- package/extensions/anthropic-attribution-child.ts +3 -0
- package/extensions/anthropic-attribution.ts +166 -1
- package/package.json +12 -10
- package/src/core/anthropic-attribution-path.ts +5 -4
- package/src/core/anthropic-attribution.ts +293 -113
- package/src/core/attested-pi-contract.ts +4 -0
- package/src/core/attested-pi-run.ts +468 -81
- package/src/core/canonical-json.ts +21 -0
- package/src/core/common.ts +450 -40
- package/src/core/config.ts +121 -0
- package/src/core/context/visible-conversation-v2.ts +3 -6
- package/src/core/delegate/artifacts.ts +13 -9
- package/src/core/delegate/budget.ts +13 -6
- package/src/core/delegate/facade-contract.ts +6 -0
- package/src/core/delegate/result-package.ts +32 -14
- package/src/core/delegate/runner.ts +129 -58
- package/src/core/delegate/seed.ts +5 -7
- package/src/core/durable-fs.ts +178 -30
- package/src/core/extension-api.ts +36 -8
- package/src/core/fusion/artifacts.ts +17 -16
- package/src/core/fusion/clean-context.ts +8 -5
- package/src/core/fusion/config.ts +2 -1
- package/src/core/fusion/context.ts +2 -6
- package/src/core/fusion/facade-contract.ts +2 -0
- package/src/core/fusion/orchestrator.ts +3 -3
- package/src/core/fusion/prompts.ts +1 -1
- package/src/core/fusion/result-package.ts +259 -72
- package/src/core/fusion/source-policy.ts +43 -17
- package/src/core/lazy-module.ts +215 -0
- package/src/core/pi-launch.ts +390 -65
- package/src/core/registry.ts +2010 -381
- package/src/core/reload-shell-owner.ts +1662 -0
- package/src/core/shell-policy.ts +134 -0
- package/src/core/task-durable.ts +67 -0
- package/src/delegate-extension.ts +404 -144
- package/src/extension.ts +387 -114
- package/src/fusion-extension.ts +202 -96
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn as nodeSpawn, type SpawnOptions } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
5
|
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
6
6
|
import type {
|
|
7
7
|
BackgroundTaskChildProcess,
|
|
@@ -9,7 +9,26 @@ import type {
|
|
|
9
9
|
BackgroundTaskSpawn,
|
|
10
10
|
} from './registry.js';
|
|
11
11
|
import { isJsonObject, parseJsonText, type BgTaskSnapshot, type JsonObject } from './common.js';
|
|
12
|
-
import {
|
|
12
|
+
import { canonicalJson, sha256Buffer } from './canonical-json.js';
|
|
13
|
+
import {
|
|
14
|
+
ATTESTED_GIT_KILL_GRACE_MS,
|
|
15
|
+
ATTESTED_GIT_MAX_OUTPUT_BYTES,
|
|
16
|
+
ATTESTED_GIT_TIMEOUT_MS,
|
|
17
|
+
} from './attested-pi-contract.js';
|
|
18
|
+
export { canonicalJson, sha256Buffer } from './canonical-json.js';
|
|
19
|
+
export {
|
|
20
|
+
ATTESTED_GIT_KILL_GRACE_MS,
|
|
21
|
+
ATTESTED_GIT_MAX_OUTPUT_BYTES,
|
|
22
|
+
ATTESTED_GIT_TIMEOUT_MS,
|
|
23
|
+
ATTESTED_TASK_ID_PATTERN,
|
|
24
|
+
} from './attested-pi-contract.js';
|
|
25
|
+
export { closeAndFsyncOutputStream, writeFileFsynced, writeJsonAtomic } from './task-durable.js';
|
|
26
|
+
import {
|
|
27
|
+
runWindowsTaskkill,
|
|
28
|
+
type TaskkillOutcome,
|
|
29
|
+
type WindowsKillPhase,
|
|
30
|
+
type WindowsTaskkillOptions,
|
|
31
|
+
} from './windows-taskkill.js';
|
|
13
32
|
import {
|
|
14
33
|
assertWindowsCommandLineWithinLimit,
|
|
15
34
|
piLaunchArgv,
|
|
@@ -18,7 +37,6 @@ import {
|
|
|
18
37
|
} from './pi-launch.js';
|
|
19
38
|
|
|
20
39
|
export const PI_TASK_ATTESTATION_SCHEMA_VERSION = 'phase2.pi_task_attestation.v1';
|
|
21
|
-
export const ATTESTED_TASK_ID_PATTERN = /^b[0-9a-f]{32}$/;
|
|
22
40
|
|
|
23
41
|
export interface StructuredPiLaunchRequest {
|
|
24
42
|
name: string;
|
|
@@ -52,6 +70,101 @@ export interface GitAuthoritySnapshot {
|
|
|
52
70
|
clean: boolean;
|
|
53
71
|
}
|
|
54
72
|
|
|
73
|
+
interface AttestedGitOutputStream {
|
|
74
|
+
on(event: 'data', listener: (data: Buffer | string) => void): unknown;
|
|
75
|
+
off(event: 'data', listener: (data: Buffer | string) => void): unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface AttestedGitChildProcess {
|
|
79
|
+
readonly pid?: number | undefined;
|
|
80
|
+
readonly stdout?: AttestedGitOutputStream | null | undefined;
|
|
81
|
+
readonly stderr?: AttestedGitOutputStream | null | undefined;
|
|
82
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
83
|
+
on(event: 'error', listener: (error: Error) => void): unknown;
|
|
84
|
+
on(
|
|
85
|
+
event: 'close',
|
|
86
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
87
|
+
): unknown;
|
|
88
|
+
off(event: 'error', listener: (error: Error) => void): unknown;
|
|
89
|
+
off(
|
|
90
|
+
event: 'close',
|
|
91
|
+
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
|
92
|
+
): unknown;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type AttestedGitSpawn = (
|
|
96
|
+
command: string,
|
|
97
|
+
args: string[],
|
|
98
|
+
options: SpawnOptions,
|
|
99
|
+
) => AttestedGitChildProcess;
|
|
100
|
+
|
|
101
|
+
export type AttestedGitKillProcess = (pid: number, signal?: NodeJS.Signals | number) => boolean;
|
|
102
|
+
|
|
103
|
+
export type AttestedGitKillTree = (
|
|
104
|
+
pid: number,
|
|
105
|
+
phase: WindowsKillPhase,
|
|
106
|
+
signal?: AbortSignal,
|
|
107
|
+
) => Promise<TaskkillOutcome>;
|
|
108
|
+
|
|
109
|
+
export interface GitCommandOptions {
|
|
110
|
+
readonly signal?: AbortSignal | undefined;
|
|
111
|
+
readonly deadlineAt?: number | undefined;
|
|
112
|
+
readonly spawn?: AttestedGitSpawn | undefined;
|
|
113
|
+
readonly killProcess?: AttestedGitKillProcess | undefined;
|
|
114
|
+
readonly killTree?: AttestedGitKillTree | undefined;
|
|
115
|
+
readonly platform?: NodeJS.Platform | undefined;
|
|
116
|
+
readonly env?: NodeJS.ProcessEnv | undefined;
|
|
117
|
+
readonly killGraceMs?: number | undefined;
|
|
118
|
+
readonly maxOutputBytes?: number | undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface GitCloseRecord {
|
|
122
|
+
readonly code: number | null;
|
|
123
|
+
readonly signal: NodeJS.Signals | null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class AttestedGitCommandError extends Error {
|
|
127
|
+
readonly code = 'attested_git_failed';
|
|
128
|
+
|
|
129
|
+
constructor(message: string) {
|
|
130
|
+
super(message);
|
|
131
|
+
this.name = 'AttestedGitCommandError';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export class AttestedGitTimeoutError extends Error {
|
|
136
|
+
readonly code = 'attested_git_timeout';
|
|
137
|
+
|
|
138
|
+
constructor(args: readonly string[]) {
|
|
139
|
+
super(`git ${args.join(' ')} timed out during attested Pi preflight`);
|
|
140
|
+
this.name = 'AttestedGitTimeoutError';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class AttestedGitOutputLimitError extends Error {
|
|
145
|
+
readonly code = 'attested_git_output_limit';
|
|
146
|
+
|
|
147
|
+
constructor(args: readonly string[], maxBytes: number) {
|
|
148
|
+
super(
|
|
149
|
+
`git ${args.join(' ')} output exceeded the explicit ${String(maxBytes)} bytes per-stream limit`,
|
|
150
|
+
);
|
|
151
|
+
this.name = 'AttestedGitOutputLimitError';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
class AttestedGitCleanupError extends Error {
|
|
156
|
+
readonly code = 'attested_git_cleanup_failed';
|
|
157
|
+
readonly primaryError: Error;
|
|
158
|
+
readonly cleanupErrors: readonly string[];
|
|
159
|
+
|
|
160
|
+
constructor(primaryError: Error, cleanupErrors: readonly string[]) {
|
|
161
|
+
super(`${primaryError.message}; Git process-tree cleanup failed: ${cleanupErrors.join('; ')}`);
|
|
162
|
+
this.name = 'AttestedGitCleanupError';
|
|
163
|
+
this.primaryError = primaryError;
|
|
164
|
+
this.cleanupErrors = [...cleanupErrors];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
55
168
|
export interface ParsedPiEvents {
|
|
56
169
|
piSessionId: string;
|
|
57
170
|
piCwd: string;
|
|
@@ -182,35 +295,363 @@ export async function resolveReportPath(cwd: string, reportPath: string): Promis
|
|
|
182
295
|
return resolved;
|
|
183
296
|
}
|
|
184
297
|
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
298
|
+
function positiveInteger(value: number | undefined, fallback: number, label: string): number {
|
|
299
|
+
const candidate = value ?? fallback;
|
|
300
|
+
if (!Number.isFinite(candidate) || candidate <= 0) {
|
|
301
|
+
throw new Error(`${label} must be a positive finite number`);
|
|
302
|
+
}
|
|
303
|
+
return Math.max(1, Math.floor(candidate));
|
|
190
304
|
}
|
|
191
305
|
|
|
192
|
-
|
|
193
|
-
|
|
306
|
+
function errorCode(error: unknown): string | undefined {
|
|
307
|
+
if (typeof error !== 'object' || error === null) return undefined;
|
|
308
|
+
const code = Reflect.get(error, 'code');
|
|
309
|
+
return typeof code === 'string' ? code : undefined;
|
|
194
310
|
}
|
|
195
311
|
|
|
196
|
-
function
|
|
197
|
-
return
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (code === 0) {
|
|
206
|
-
resolvePromise(Buffer.concat(out).toString('utf8').trim());
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
reject(
|
|
210
|
-
new Error(`git ${args.join(' ')} failed: ${Buffer.concat(err).toString('utf8').trim()}`),
|
|
312
|
+
function errorMessage(error: unknown): string {
|
|
313
|
+
return error instanceof Error ? error.message : String(error);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function signalError(signal: AbortSignal): Error {
|
|
317
|
+
return signal.reason instanceof Error
|
|
318
|
+
? signal.reason
|
|
319
|
+
: new Error(
|
|
320
|
+
`Attested Git preflight cancelled${signal.reason === undefined ? '' : `: ${String(signal.reason)}`}`,
|
|
211
321
|
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function gitDeadline(options: GitCommandOptions): number {
|
|
325
|
+
const configured = options.deadlineAt;
|
|
326
|
+
return typeof configured === 'number' && Number.isFinite(configured)
|
|
327
|
+
? configured
|
|
328
|
+
: Date.now() + ATTESTED_GIT_TIMEOUT_MS;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function assertGitBoundary(options: GitCommandOptions, args: readonly string[]): void {
|
|
332
|
+
if (options.signal?.aborted === true) throw signalError(options.signal);
|
|
333
|
+
if (Date.now() >= gitDeadline(options)) throw new AttestedGitTimeoutError(args);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
class BoundedGitCapture {
|
|
337
|
+
private readonly chunks: Buffer[] = [];
|
|
338
|
+
private bytes = 0;
|
|
339
|
+
private exceeded = false;
|
|
340
|
+
|
|
341
|
+
constructor(private readonly maxBytes: number) {}
|
|
342
|
+
|
|
343
|
+
append(data: Buffer | string): boolean {
|
|
344
|
+
const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
|
|
345
|
+
const remaining = this.maxBytes - this.bytes;
|
|
346
|
+
if (remaining > 0) {
|
|
347
|
+
const kept = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
|
|
348
|
+
this.chunks.push(kept);
|
|
349
|
+
this.bytes += kept.length;
|
|
350
|
+
}
|
|
351
|
+
if (buffer.length > Math.max(0, remaining)) this.exceeded = true;
|
|
352
|
+
return this.exceeded;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
text(): string {
|
|
356
|
+
return Buffer.concat(this.chunks, this.bytes).toString('utf8');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
diagnostic(): string {
|
|
360
|
+
const text = this.text().trim();
|
|
361
|
+
return this.exceeded
|
|
362
|
+
? `${text}${text.length > 0 ? ' ' : ''}[output exceeded ${String(this.maxBytes)} bytes]`
|
|
363
|
+
: text;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function delay(ms: number): Promise<void> {
|
|
368
|
+
return new Promise((resolvePromise) => {
|
|
369
|
+
setTimeout(resolvePromise, ms);
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function posixTreeSignal(
|
|
374
|
+
child: AttestedGitChildProcess,
|
|
375
|
+
pid: number | undefined,
|
|
376
|
+
signal: NodeJS.Signals,
|
|
377
|
+
killProcess: AttestedGitKillProcess,
|
|
378
|
+
): string[] {
|
|
379
|
+
const errors: string[] = [];
|
|
380
|
+
if (pid !== undefined) {
|
|
381
|
+
try {
|
|
382
|
+
if (killProcess(-pid, signal)) return errors;
|
|
383
|
+
errors.push(`process group ${signal} returned false`);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (errorCode(error) === 'ESRCH') return [];
|
|
386
|
+
errors.push(`process group ${signal} failed: ${errorMessage(error)}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
if (child.kill(signal)) return errors;
|
|
391
|
+
errors.push(`child ${signal} returned false`);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (errorCode(error) === 'ESRCH') return errors;
|
|
394
|
+
errors.push(`child ${signal} failed: ${errorMessage(error)}`);
|
|
395
|
+
}
|
|
396
|
+
return errors;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function windowsTaskkillSucceeded(outcome: TaskkillOutcome): boolean {
|
|
400
|
+
return outcome.exitCode === 0 || outcome.exitCode === 128;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function describeTaskkill(phase: WindowsKillPhase, outcome: TaskkillOutcome): string {
|
|
404
|
+
const stderr = outcome.stderr.trim();
|
|
405
|
+
const stdout = outcome.stdout.trim();
|
|
406
|
+
const detail = stderr || stdout;
|
|
407
|
+
const truncated = outcome.stderrTruncated || outcome.stdoutTruncated ? ' (output truncated)' : '';
|
|
408
|
+
return `taskkill ${phase} exited ${String(outcome.exitCode)}${detail ? `: ${detail}` : ''}${truncated}`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
interface GitTerminationOptions {
|
|
412
|
+
readonly platform: NodeJS.Platform;
|
|
413
|
+
readonly killProcess: AttestedGitKillProcess;
|
|
414
|
+
readonly killTree: AttestedGitKillTree;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function terminateGitProcessTree(
|
|
418
|
+
child: AttestedGitChildProcess,
|
|
419
|
+
options: GitTerminationOptions,
|
|
420
|
+
killGraceMs: number,
|
|
421
|
+
isClosed: () => boolean,
|
|
422
|
+
): Promise<string[]> {
|
|
423
|
+
const pid = child.pid;
|
|
424
|
+
if (options.platform !== 'win32') {
|
|
425
|
+
const errors = posixTreeSignal(child, pid, 'SIGTERM', options.killProcess);
|
|
426
|
+
// Keep the grace timer referenced and always probe/force the process group
|
|
427
|
+
// once. The direct Git process can exit before a descendant that ignored
|
|
428
|
+
// TERM; forcing the detached group closes that tree-shaped race.
|
|
429
|
+
await delay(killGraceMs);
|
|
430
|
+
errors.push(...posixTreeSignal(child, pid, 'SIGKILL', options.killProcess));
|
|
431
|
+
return errors;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (pid === undefined) return ['Git process has no pid for Windows tree termination'];
|
|
435
|
+
const softController = new AbortController();
|
|
436
|
+
let softOutcome: TaskkillOutcome | undefined;
|
|
437
|
+
let softError: unknown;
|
|
438
|
+
let softSettled = false;
|
|
439
|
+
const soft = Promise.resolve()
|
|
440
|
+
.then(() => options.killTree(pid, 'terminate', softController.signal))
|
|
441
|
+
.then(
|
|
442
|
+
(outcome) => {
|
|
443
|
+
softOutcome = outcome;
|
|
444
|
+
softSettled = true;
|
|
445
|
+
},
|
|
446
|
+
(error: unknown) => {
|
|
447
|
+
softError = error;
|
|
448
|
+
softSettled = true;
|
|
449
|
+
},
|
|
450
|
+
);
|
|
451
|
+
await delay(killGraceMs);
|
|
452
|
+
const softSucceeded = softOutcome !== undefined && windowsTaskkillSucceeded(softOutcome);
|
|
453
|
+
const needsForce = !softSettled || !softSucceeded || !isClosed();
|
|
454
|
+
if (!softSettled) softController.abort();
|
|
455
|
+
await soft;
|
|
456
|
+
|
|
457
|
+
const errors: string[] = [];
|
|
458
|
+
if (softError !== undefined) errors.push(`taskkill terminate failed: ${errorMessage(softError)}`);
|
|
459
|
+
else if (softOutcome !== undefined && !windowsTaskkillSucceeded(softOutcome))
|
|
460
|
+
errors.push(describeTaskkill('terminate', softOutcome));
|
|
461
|
+
if (!needsForce) return errors;
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
const forceOutcome = await options.killTree(pid, 'force');
|
|
465
|
+
if (!windowsTaskkillSucceeded(forceOutcome))
|
|
466
|
+
errors.push(describeTaskkill('force', forceOutcome));
|
|
467
|
+
} catch (error) {
|
|
468
|
+
errors.push(`taskkill force failed: ${errorMessage(error)}`);
|
|
469
|
+
}
|
|
470
|
+
return errors;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function defaultGitSpawn(
|
|
474
|
+
command: string,
|
|
475
|
+
args: string[],
|
|
476
|
+
options: SpawnOptions,
|
|
477
|
+
): AttestedGitChildProcess {
|
|
478
|
+
return nodeSpawn(command, args, options);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export async function runGitCommand(
|
|
482
|
+
cwd: string,
|
|
483
|
+
args: string[],
|
|
484
|
+
options: GitCommandOptions = {},
|
|
485
|
+
): Promise<string> {
|
|
486
|
+
const deadlineAt = gitDeadline(options);
|
|
487
|
+
const boundedOptions: GitCommandOptions = { ...options, deadlineAt };
|
|
488
|
+
assertGitBoundary(boundedOptions, args);
|
|
489
|
+
const platform = options.platform ?? process.platform;
|
|
490
|
+
const env = options.env ?? process.env;
|
|
491
|
+
const spawn = options.spawn ?? defaultGitSpawn;
|
|
492
|
+
const killProcess = options.killProcess ?? process.kill.bind(process);
|
|
493
|
+
const killTree: AttestedGitKillTree =
|
|
494
|
+
options.killTree ??
|
|
495
|
+
((pid, phase, signal) => {
|
|
496
|
+
const taskkillOptions: WindowsTaskkillOptions =
|
|
497
|
+
signal === undefined ? { env } : { env, signal };
|
|
498
|
+
return runWindowsTaskkill(pid, phase, taskkillOptions);
|
|
212
499
|
});
|
|
500
|
+
const killGraceMs = positiveInteger(
|
|
501
|
+
options.killGraceMs,
|
|
502
|
+
ATTESTED_GIT_KILL_GRACE_MS,
|
|
503
|
+
'killGraceMs',
|
|
504
|
+
);
|
|
505
|
+
const maxOutputBytes = positiveInteger(
|
|
506
|
+
options.maxOutputBytes,
|
|
507
|
+
ATTESTED_GIT_MAX_OUTPUT_BYTES,
|
|
508
|
+
'maxOutputBytes',
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
let child: AttestedGitChildProcess;
|
|
512
|
+
try {
|
|
513
|
+
child = spawn('git', args, {
|
|
514
|
+
cwd,
|
|
515
|
+
detached: platform !== 'win32',
|
|
516
|
+
env,
|
|
517
|
+
shell: false,
|
|
518
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
519
|
+
windowsHide: true,
|
|
520
|
+
});
|
|
521
|
+
} catch (error) {
|
|
522
|
+
throw new AttestedGitCommandError(
|
|
523
|
+
`git ${args.join(' ')} failed to spawn: ${errorMessage(error)}`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const admissionSignal = options.signal;
|
|
528
|
+
const stdout = new BoundedGitCapture(maxOutputBytes);
|
|
529
|
+
const stderr = new BoundedGitCapture(maxOutputBytes);
|
|
530
|
+
let closed = false;
|
|
531
|
+
let closeRecord: GitCloseRecord | undefined;
|
|
532
|
+
let resolveClose: (() => void) | undefined;
|
|
533
|
+
const closePromise = new Promise<void>((resolvePromise) => {
|
|
534
|
+
resolveClose = resolvePromise;
|
|
213
535
|
});
|
|
536
|
+
let primaryError: Error | undefined;
|
|
537
|
+
let processError: Error | undefined;
|
|
538
|
+
let termination: Promise<string[]> | undefined;
|
|
539
|
+
let deadlineTimer: NodeJS.Timeout | undefined;
|
|
540
|
+
|
|
541
|
+
const requestTermination = (error: Error): void => {
|
|
542
|
+
if (closed) return;
|
|
543
|
+
primaryError ??= error;
|
|
544
|
+
termination ??= terminateGitProcessTree(
|
|
545
|
+
child,
|
|
546
|
+
{ platform, killProcess, killTree },
|
|
547
|
+
killGraceMs,
|
|
548
|
+
() => closed,
|
|
549
|
+
);
|
|
550
|
+
};
|
|
551
|
+
const stdoutListener = (data: Buffer | string): void => {
|
|
552
|
+
if (stdout.append(data))
|
|
553
|
+
requestTermination(new AttestedGitOutputLimitError(args, maxOutputBytes));
|
|
554
|
+
};
|
|
555
|
+
const stderrListener = (data: Buffer | string): void => {
|
|
556
|
+
if (stderr.append(data))
|
|
557
|
+
requestTermination(new AttestedGitOutputLimitError(args, maxOutputBytes));
|
|
558
|
+
};
|
|
559
|
+
const errorListener = (error: Error): void => {
|
|
560
|
+
processError = error;
|
|
561
|
+
const wrapped = new AttestedGitCommandError(
|
|
562
|
+
`git ${args.join(' ')} process error: ${error.message}`,
|
|
563
|
+
);
|
|
564
|
+
if (child.pid === undefined) {
|
|
565
|
+
primaryError ??= wrapped;
|
|
566
|
+
closed = true;
|
|
567
|
+
closeRecord = { code: null, signal: null };
|
|
568
|
+
resolveClose?.();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
requestTermination(wrapped);
|
|
572
|
+
};
|
|
573
|
+
const closeListener = (code: number | null, signal: NodeJS.Signals | null): void => {
|
|
574
|
+
if (closed) return;
|
|
575
|
+
closed = true;
|
|
576
|
+
closeRecord = { code, signal };
|
|
577
|
+
if (deadlineTimer !== undefined) {
|
|
578
|
+
clearTimeout(deadlineTimer);
|
|
579
|
+
deadlineTimer = undefined;
|
|
580
|
+
}
|
|
581
|
+
resolveClose?.();
|
|
582
|
+
};
|
|
583
|
+
const abortListener = (): void => {
|
|
584
|
+
if (admissionSignal !== undefined) requestTermination(signalError(admissionSignal));
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
child.stdout?.on('data', stdoutListener);
|
|
588
|
+
child.stderr?.on('data', stderrListener);
|
|
589
|
+
child.on('error', errorListener);
|
|
590
|
+
child.on('close', closeListener);
|
|
591
|
+
admissionSignal?.addEventListener('abort', abortListener, { once: true });
|
|
592
|
+
const remainingMs = Math.max(0, deadlineAt - Date.now());
|
|
593
|
+
deadlineTimer = setTimeout(() => {
|
|
594
|
+
requestTermination(new AttestedGitTimeoutError(args));
|
|
595
|
+
}, remainingMs);
|
|
596
|
+
if (admissionSignal?.aborted === true) abortListener();
|
|
597
|
+
|
|
598
|
+
try {
|
|
599
|
+
await closePromise;
|
|
600
|
+
const cleanupErrors = termination === undefined ? [] : await termination;
|
|
601
|
+
if (primaryError !== undefined) {
|
|
602
|
+
if (cleanupErrors.length > 0) throw new AttestedGitCleanupError(primaryError, cleanupErrors);
|
|
603
|
+
throw primaryError;
|
|
604
|
+
}
|
|
605
|
+
if (processError !== undefined) {
|
|
606
|
+
throw new AttestedGitCommandError(
|
|
607
|
+
`git ${args.join(' ')} process error: ${processError.message}`,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
const close = closeRecord;
|
|
611
|
+
if (close?.code === 0) return stdout.text().trim();
|
|
612
|
+
const exit = close?.code === null || close === undefined ? 'null' : String(close.code);
|
|
613
|
+
const signal = close?.signal ? ` (${close.signal})` : '';
|
|
614
|
+
throw new AttestedGitCommandError(
|
|
615
|
+
`git ${args.join(' ')} failed with exit ${exit}${signal}: ${stderr.diagnostic()}`,
|
|
616
|
+
);
|
|
617
|
+
} finally {
|
|
618
|
+
if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
|
|
619
|
+
admissionSignal?.removeEventListener('abort', abortListener);
|
|
620
|
+
child.stdout?.off('data', stdoutListener);
|
|
621
|
+
child.stderr?.off('data', stderrListener);
|
|
622
|
+
child.off('error', errorListener);
|
|
623
|
+
child.off('close', closeListener);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function withDefaultGitDeadline(options: GitCommandOptions): GitCommandOptions {
|
|
628
|
+
return options.deadlineAt === undefined
|
|
629
|
+
? { ...options, deadlineAt: Date.now() + ATTESTED_GIT_TIMEOUT_MS }
|
|
630
|
+
: options;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export async function gitAuthoritySnapshot(
|
|
634
|
+
cwd: string,
|
|
635
|
+
options: GitCommandOptions = {},
|
|
636
|
+
): Promise<GitAuthoritySnapshot> {
|
|
637
|
+
const bounded = withDefaultGitDeadline(options);
|
|
638
|
+
const commit = await runGitCommand(cwd, ['rev-parse', 'HEAD'], bounded);
|
|
639
|
+
const tree = await runGitCommand(cwd, ['rev-parse', 'HEAD^{tree}'], bounded);
|
|
640
|
+
const status = await runGitCommand(
|
|
641
|
+
cwd,
|
|
642
|
+
['status', '--porcelain=v1', '--untracked-files=all'],
|
|
643
|
+
bounded,
|
|
644
|
+
);
|
|
645
|
+
return { commit, tree, clean: status.length === 0 };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export async function gitRepoRoot(cwd: string, options: GitCommandOptions = {}): Promise<string> {
|
|
649
|
+
const bounded = withDefaultGitDeadline(options);
|
|
650
|
+
const root = await runGitCommand(cwd, ['rev-parse', '--show-toplevel'], bounded);
|
|
651
|
+
assertGitBoundary(bounded, ['rev-parse', '--show-toplevel']);
|
|
652
|
+
const resolved = await realpath(root);
|
|
653
|
+
assertGitBoundary(bounded, ['rev-parse', '--show-toplevel']);
|
|
654
|
+
return resolved;
|
|
214
655
|
}
|
|
215
656
|
|
|
216
657
|
export function observePiOAuth(
|
|
@@ -406,65 +847,11 @@ export function parsePiJsonEvents(raw: Buffer): ParsedPiEvents {
|
|
|
406
847
|
};
|
|
407
848
|
}
|
|
408
849
|
|
|
409
|
-
export function sha256Buffer(buffer: Buffer): string {
|
|
410
|
-
return `sha256:${createHash('sha256').update(buffer).digest('hex')}`;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
850
|
export async function sha256File(path: string): Promise<{ byteLength: number; sha256: string }> {
|
|
414
851
|
const bytes = await readFile(path);
|
|
415
852
|
return { byteLength: bytes.length, sha256: sha256Buffer(bytes) };
|
|
416
853
|
}
|
|
417
854
|
|
|
418
|
-
export function canonicalJson(value: unknown): string {
|
|
419
|
-
return JSON.stringify(sortJson(value));
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
function sortJson(value: unknown): unknown {
|
|
423
|
-
if (Array.isArray(value)) return value.map(sortJson);
|
|
424
|
-
if (!isJsonObject(value)) return value;
|
|
425
|
-
return Object.fromEntries(
|
|
426
|
-
Object.keys(value)
|
|
427
|
-
.sort()
|
|
428
|
-
.map((key) => [key, sortJson(value[key])]),
|
|
429
|
-
);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
export async function writeFileFsynced(path: string, data: Buffer | string): Promise<void> {
|
|
433
|
-
await mkdir(dirname(path), { recursive: true });
|
|
434
|
-
await writeFileDurable(path, data);
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
export async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
|
|
438
|
-
await replaceFileDurable(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
export async function closeAndFsyncOutputStream(
|
|
442
|
-
stream: NodeJS.WritableStream | undefined,
|
|
443
|
-
): Promise<void> {
|
|
444
|
-
if (!stream) return;
|
|
445
|
-
await new Promise<void>((resolvePromise, reject) => {
|
|
446
|
-
let settled = false;
|
|
447
|
-
const finish = () => {
|
|
448
|
-
if (settled) return;
|
|
449
|
-
settled = true;
|
|
450
|
-
stream.off('error', fail);
|
|
451
|
-
stream.off('close', finish);
|
|
452
|
-
stream.off('finish', finish);
|
|
453
|
-
resolvePromise();
|
|
454
|
-
};
|
|
455
|
-
const fail = (error: Error) => {
|
|
456
|
-
if (settled) return;
|
|
457
|
-
settled = true;
|
|
458
|
-
stream.off('close', finish);
|
|
459
|
-
reject(error);
|
|
460
|
-
};
|
|
461
|
-
stream.once('close', finish);
|
|
462
|
-
stream.once('finish', finish);
|
|
463
|
-
stream.once('error', fail);
|
|
464
|
-
stream.end();
|
|
465
|
-
});
|
|
466
|
-
}
|
|
467
|
-
|
|
468
855
|
export function spawnAndCapturePi(
|
|
469
856
|
spawnImpl: BackgroundTaskSpawn,
|
|
470
857
|
argv: string[],
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/** Stable JSON bytes used by every persisted hash-bound package artifact. */
|
|
4
|
+
export function canonicalJson(value: unknown): string {
|
|
5
|
+
return JSON.stringify(sortJson(value));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** SHA-256 label used by persisted package artifacts and attestations. */
|
|
9
|
+
export function sha256Buffer(buffer: Buffer): string {
|
|
10
|
+
return `sha256:${createHash('sha256').update(buffer).digest('hex')}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sortJson(value: unknown): unknown {
|
|
14
|
+
if (Array.isArray(value)) return value.map(sortJson);
|
|
15
|
+
if (typeof value !== 'object' || value === null) return value;
|
|
16
|
+
return Object.fromEntries(
|
|
17
|
+
Object.keys(value)
|
|
18
|
+
.sort()
|
|
19
|
+
.map((key) => [key, sortJson(Reflect.get(value, key))]),
|
|
20
|
+
);
|
|
21
|
+
}
|