specpi 0.18.0 → 0.19.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/CHANGELOG.md +12 -0
- package/README.md +11 -2
- package/SECURITY_MODEL.md +18 -4
- package/extensions/background-tasks/core.d.mts +90 -0
- package/extensions/background-tasks/core.mjs +413 -0
- package/extensions/background-tasks/index.ts +262 -0
- package/extensions/background-tasks/smoke.mjs +33 -0
- package/extensions/background-tasks/supervisor.mjs +33 -0
- package/extensions/command-guard/core.d.mts +12 -0
- package/extensions/command-guard/index.ts +74 -0
- package/extensions/delegation/native.mjs +1 -1
- package/extensions/delegation/provider.mjs +3 -2
- package/package.json +1 -1
- package/scripts/specpi.mjs +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.0 - 2026-09-07
|
|
4
|
+
|
|
5
|
+
- Add session-owned background commands with exact interactive approvals, shared Command Guard admission, bounded logs, and best-effort process cleanup.
|
|
6
|
+
- Install and verify the background tools with an offline doctor smoke; document shell, environment, output-retention, and process-tree limits.
|
|
7
|
+
- Clarify repository guidance for concise commits, pull requests, and necessary risk-focused validation.
|
|
8
|
+
|
|
9
|
+
## 0.18.1 - 2026-09-07
|
|
10
|
+
|
|
11
|
+
- Restore Pi's cached model catalogs during delegation setup so switching to catalog-added or updated models resumes workers without a manual toggle or restart. Catalog network refresh stays disabled; exact-model checks, safety revocations and spent quotas remain enforced.
|
|
12
|
+
|
|
13
|
+
- SpecPi Chat 0.4.1 replaces the persistent Delegates panel with a compact live-only activity strip and opens workspace image links in the validated image viewer instead of the text editor. Chat is packaged separately; update the harness for the model-switch fix and Chat for the UI fixes.
|
|
14
|
+
|
|
3
15
|
## 0.18.0 - 2026-09-07
|
|
4
16
|
|
|
5
17
|
- Start Command Guard off in RPC sessions, including new SpecPi Chat conversations. Explicit `/guard guard` and `/guard strict` still enable protection for the current session; the terminal startup chooser is unchanged.
|
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ SpecPi extends Pi with chat, review tools, and focused delegation. Its principle
|
|
|
30
30
|
## What it adds
|
|
31
31
|
|
|
32
32
|
- **Focused delegation.** One agent makes changes. Up to two read-only subagents help investigate and review. [Research and design](https://tannermidd.github.io/SpecPi/single-agent/)
|
|
33
|
+
- **Background tasks.** Start an approved dev server, test suite, or watch build, inspect bounded output, and stop it without blocking other work.
|
|
33
34
|
- **Review as you work.** Track changed files, inspect diffs, check pages in a browser, and review risky commands.
|
|
34
35
|
- **Improvements you choose.** Record recurring problems, select one with `/harness-improvement`, and test the change before calling it done.
|
|
35
36
|
|
|
@@ -48,12 +49,20 @@ specpi doctor
|
|
|
48
49
|
|
|
49
50
|
Preview the changes, confirm the install, then check the setup. Restart Pi when finished.
|
|
50
51
|
|
|
51
|
-
To pin this release, use `npm install --global specpi@0.
|
|
52
|
+
To pin this release, use `npm install --global specpi@0.19.0`.
|
|
52
53
|
|
|
53
54
|
Delegation is enabled at startup. Use `/delegate off` to turn it off.
|
|
54
55
|
|
|
55
56
|
[Setup, updates & removal](https://tannermidd.github.io/SpecPi/wiki/#getting-started) · [Delegation settings](docs/delegation/README.md)
|
|
56
57
|
|
|
58
|
+
## Background tasks
|
|
59
|
+
|
|
60
|
+
Ask Pi to start a long-running command with `background_start`, then use `background_list`, `background_logs`, and `background_stop` to observe and clean up tasks. `background_start` accepts `command`, optional `cwd`/`label`, and `timeoutSeconds` (1–28,800; default 1,800). It requires interactive approval even with Guard off; Guard denials and locks still apply. Headless starts are denied.
|
|
61
|
+
|
|
62
|
+
Commands use `/bin/sh` on POSIX or system `cmd.exe` on Windows, not Pi's configured Bash. They inherit the process environment except `NODE_OPTIONS` and `NODE_PATH`, which are cleared to keep the supervisor's startup predictable. No PTY or interactive stdin is provided. Four active tasks are allowed; output is capped at 256 KiB per task, reads at 64 KiB, and completed records at 32. Log offsets are absolute bytes in the UTF-8 stream including stdout/stderr markers, before terminal-control escaping; responses report the next cursor and lost bytes.
|
|
63
|
+
|
|
64
|
+
Stop tasks when finished. Session replacement, reload, tree navigation, and shutdown attempt bounded cleanup. `cleanup: confirmed` means the owned root/group termination was observed, not that escaped descendants are contained; unconfirmed cleanup retains its slot and may need manual process inspection. Spawn success is not service readiness. Output is memory-only in the extension, but returned text may enter Pi conversation/provider retention. See the [security boundary](SECURITY_MODEL.md#background-task-execution).
|
|
65
|
+
|
|
57
66
|
## SpecPi Chat for VS Code
|
|
58
67
|
|
|
59
68
|
Chat beside your code, attach files, and switch conversations.
|
|
@@ -62,7 +71,7 @@ Chat beside your code, attach files, and switch conversations.
|
|
|
62
71
|
|
|
63
72
|
In VS Code, find **SpecPi Chat** by **tannermidd** in Extensions and install it. Open **SpecPi** in the Activity Bar, then **Connect Pi**.
|
|
64
73
|
|
|
65
|
-
Chat 0.4.
|
|
74
|
+
Chat 0.4.1 shows a compact delegation strip only while workers run or settle, and opens workspace image links in the image viewer. SpecPi 0.19.0 includes the delegation fix for switching models in the same chat. Update both packages, reload VS Code for the Chat update, and use **Restart Pi** to load the harness update. Later model switches do not require a restart. New Chat sessions start with Guard off; use `/guard guard` or `/guard strict` to enable it.
|
|
66
75
|
|
|
67
76
|
## Go further
|
|
68
77
|
|
package/SECURITY_MODEL.md
CHANGED
|
@@ -35,7 +35,7 @@ not a new provider-disclosure boundary.
|
|
|
35
35
|
|
|
36
36
|
Delegation is a native extension discovered through the ordinary Pi package and SpecPi lifecycle. Start `pi` normally; delegation is enabled by default at the first session start of each Pi process, including TUI, RPC, print and JSON modes, after settings, host and Guard checks. Startup launches no workers or model inference, but Pi-owned authentication/OAuth preparation may occur. The model-facing schema is present only while dispatch is enabled. It adds no launcher, separate host process, service or trust override. Parent Pi retains normal resource discovery, trust decisions and proxy policy. Delegation checks required public SDK capabilities, not exact version identifiers. Missing session/runtime/settings/thinking APIs prevent activation; session construction and every request still enforce the tool, model and resource policy. API presence is not proof of every future SDK behavior or a passing integration receipt. Normal installation's minimum Pi version and 0.84.4 bootstrap pin are separate contracts.
|
|
37
37
|
|
|
38
|
-
Each worker is an SDK `createAgentSession` instance with in-memory session storage. Pi owns its model/tool loop. A fresh Pi `ModelRuntime` uses standard authentication, environment and `models.json` resolution. Child transport and thinking budgets come from configured global settings; project settings are not loaded. Parent model/thinking are explicit with Pi's supported-level clamping. SpecPi does not extract credentials, copy authentication state or inspect private runtime fields. Preflight rejects runtime-only authentication, selected extension-registered provider overrides, model-specific headers, startup proxy configuration and mismatched safe model descriptors because those routes cannot be faithfully reconstructed. These rejections leave parent configuration unchanged.
|
|
38
|
+
Each worker is an SDK `createAgentSession` instance with in-memory session storage. Pi owns its model/tool loop. A fresh Pi `ModelRuntime` uses standard authentication, environment and `models.json` resolution, restoring Pi's persisted model catalogs with catalog network refresh disabled. Child transport and thinking budgets come from configured global settings; project settings are not loaded. Parent model/thinking are explicit with Pi's supported-level clamping. SpecPi does not extract credentials, copy authentication state or inspect private runtime fields. Preflight rejects runtime-only authentication, selected extension-registered provider overrides, model-specific headers, startup proxy configuration and mismatched safe model descriptors because those routes cannot be faithfully reconstructed. These rejections leave parent configuration unchanged.
|
|
39
39
|
|
|
40
40
|
This is not full parent inference parity: parent request hooks, ephemeral runtime settings and session affinity are not automatically inherited. Keep delegation disabled if those inherited controls are required for every request. Children load no ambient extensions, skills, AGENTS files or parent transcript. Their only tools are selected-source list/read/literal-search; shell, write, arbitrary plugin, live-web and nested delegation tools are absent. Trusted parent extensions remain privileged in the same process. This is an application boundary, not an OS sandbox against malicious extensions.
|
|
41
41
|
|
|
@@ -111,7 +111,7 @@ Global package installations, upstream package-manager effects, downloaded cache
|
|
|
111
111
|
|
|
112
112
|
## Command Guard
|
|
113
113
|
|
|
114
|
-
Command Guard is a pre-execution policy layer for model-initiated Pi tool calls. It covers Pi's documented `bash`, `powershell`, `read`, `write`, and `edit` tools. Other extensions, custom or MCP tools, direct user `!command` and `!!command` escapes, approved scripts, and process execution outside these seams are not contained.
|
|
114
|
+
Command Guard is a pre-execution policy layer for model-initiated Pi tool calls. It covers Pi's documented `bash`, `powershell`, `read`, `write`, and `edit` tools. Other extensions, custom or MCP tools, direct user `!command` and `!!command` escapes, approved scripts, and process execution outside these seams are not contained. SpecPi's background tools are an explicit reviewed integration: starts request Guard's shared admission policy again inside execution, while observation and owned-task cleanup remain available under locks.
|
|
115
115
|
|
|
116
116
|
Interactive top-level sessions choose one session-only mode:
|
|
117
117
|
|
|
@@ -129,6 +129,20 @@ The guard keeps mode, lock, counters, parser cache, and approval hashes only in
|
|
|
129
129
|
|
|
130
130
|
Static analysis cannot resolve every alias, generated command, script body, plugin, encoding, runtime expansion, symlink race, or interpreter behavior. Recognized private-path reads receive narrow protection, but arbitrary scripts or shell syntax can still read credentials. An allowed or approved command runs with the user's full permissions. `specpi doctor` verifies installed checksums and deterministic policy smoke behavior, not universal command safety.
|
|
131
131
|
|
|
132
|
+
## Background task execution
|
|
133
|
+
|
|
134
|
+
`background_start` executes an approved noninteractive command under the user's permissions, using `/bin/sh` on POSIX or system `cmd.exe /d /s /c` on Windows. It does not inherit Pi's Bash configuration or spawn hooks. POSIX commands are classified by Guard's Bash parser but executed by `/bin/sh` (often dash); the dialect mismatch can change parsing or runtime behavior. Classification is defense in depth, not proof of shell equivalence. The process environment is inherited except `NODE_OPTIONS` and `NODE_PATH`, cleared to prevent ambient Node startup hooks in the bundled supervisor. No provider authentication resolution, private Pi store inspection, or session-environment injection is performed. Approved processes can still access ambient secrets and same-user files; this is not credential isolation.
|
|
135
|
+
|
|
136
|
+
Starts require an active session and interactive UI, even for cached approvals and when Guard is Off. A synchronous event requests Guard's current mode, generation, and command decision. Guard/Strict critical denials and locks cannot be approved away; uncertainty is shown in the feature's approval prompt. An absent Guard falls back to the shared Guard-mode command policy. Unready, old, or ambiguous Guard responders fail closed. Trusted extensions and the shared event bus remain privileged, not an adversarial isolation boundary. Guard exempts the four background tools from its outer tool-call gate only when Pi's current tool-registration metadata identifies the sibling `background-tasks/index.ts` entry point. Missing, replaced, or foreign registrations retain ordinary Strict and locked checks; matching a name alone is insufficient. Genuine starts recheck admission inside execution, while observation/stop validate their own inputs and only address the extension's opaque task IDs. Registration provenance is not containment of a malicious trusted extension or a modified installed entry point.
|
|
137
|
+
|
|
138
|
+
Background approval is deliberately binary and session-scoped: v1 offers no allow-once option, unlike Guard's general approval menu. The confirmation explicitly grants the exact execution for the session. Approvals bind exact command, canonical cwd, shell policy, timeout, label, session generation, and Guard decision/generation. Inputs and policy are checked again immediately before launch, including cached approvals. Lifecycle and Guard changes invalidate pending prompts and approval state. Commands and approvals are not persisted by the extension. Directory replacement, changed scripts/executables, inherited environment changes, and filesystem races cannot be fully resolved by an exact command fingerprint; approval grants arbitrary execution, not an immutable executable snapshot.
|
|
139
|
+
|
|
140
|
+
The runner reserves at most four active/unconfirmed tasks before spawning. Command/cwd/label input limits are 16 KiB/4 KiB/128 UTF-8 bytes, approvals are capped at 128, and completed records at 32. Combined stdout/stderr retention is capped at 256 KiB per task with 64 KiB raw-output reads. Stream markers count toward absolute UTF-8 byte cursors; terminal controls are escaped after slicing, so serialized responses can be larger (bounded below 512 KiB). Lost bytes and truncation are explicit. Output and previews are untrusted; preview redaction is best effort, not a secrecy guarantee. There is no extension-owned command/output journal. Tool results can enter normal Pi conversation storage and model-provider retention, and executed programs can write files themselves. These caps do not bound child CPU, memory, disk, or network usage.
|
|
141
|
+
|
|
142
|
+
A bundled Node supervisor keeps the owned root alive after shell exit until cleanup. Deadlines (30 minutes by default, at most eight hours) initiate termination, not a guarantee of stopped work. POSIX uses a dedicated process group with a five-second SIGTERM grace then SIGKILL for running commands. If the command's exit or spawn failure was already observed when cleanup began, it skips that grace and cleans up the remaining group immediately; the supervisor still holds the root identity until escalation. Windows uses fixed-argv system `taskkill /T /F`. Cleanup observes the root exit and, on POSIX, process-group disappearance. It does not signal an observed-dead root again, reducing PID-reuse risk without eliminating OS races. Windows tree discovery can miss descendants after intermediate parent exit; detached/escaped descendants, orphaned zombies, crashes, denied signals, and forced host termination remain limitations. A confirmed cleanup covers only this supported root/group evidence, not universal descendant death. Failed/unconfirmed cleanup retains its slot and record while the extension instance lives.
|
|
143
|
+
|
|
144
|
+
Start cancellation attempts cleanup and returns its task outcome; cancelling log reads does not stop registered work. Shutdown (including reload/new/resume/fork) and tree navigation revoke approvals and attempt bounded cleanup. The UI reports uncertainty with blocking task IDs and recovery steps: starts stay disabled in that runtime, so retry `background_stop` for those IDs, then `/reload` after cleanup confirms. Old-instance memory and IDs do not survive replacement or a crash; inspect any remaining processes manually if the runtime has already changed. Stop remains available under policy locks and is idempotent. No automatic restart, persistence, or follow-up model turn is provided. Doctor checks installed checksums before running the bundled offline smoke; fixture success proves supported behavior, not OS containment.
|
|
145
|
+
|
|
132
146
|
## Local improvement state
|
|
133
147
|
|
|
134
148
|
Capability-gap collection is disabled until the user makes an explicit local on/off choice. When enabled, SpecPi stores bounded sanitized summaries and salted hashes used to measure distinct tasks, sessions, and projects. It does not read prompts, source files, sessions, history, credentials, provider authentication, or trust decisions to construct reports, and it never uploads wishlist state.
|
|
@@ -195,7 +209,7 @@ The sidebar uses Pi's documented JSONL RPC protocol. Pi owns provider authentica
|
|
|
195
209
|
|
|
196
210
|
Chat allows up to 90 seconds for an initial read-only RPC readiness probe before sending normal requests. This accommodates the 30-second startup fallback in older installed Guard versions without extending later request deadlines. Dialogs arriving before readiness receive cancellation only, never an approval or mode selection; the sidebar explains the limitation and retains a notice after connection. An extension with an unbounded startup dialog can still prevent readiness. Chat times out with recovery guidance instead of bypassing that extension or changing the user's installed harness.
|
|
197
211
|
|
|
198
|
-
The webview loads only packaged scripts/styles under a strict content security policy, forbids network resources, and renders model/tool/file text without interpreting embedded HTML. Images use validated data URLs under `img-src data:`; remote image URLs are never fetched by the webview. HTTP(S) links, including external image links, require a human click and pass through VS Code's external-link API. Clicked code references use a separate host action: paths are parsed, checked against the selected workspace, canonicalized, and restricted to regular non-sensitive files before VS Code opens the file and reveals a clamped line/range. Clicked workspace Markdown image references use the same path boundary and the image validator before a preview is returned. Arbitrary command and URI handlers are not executed. Raw paths preserve literal percent signs; Markdown link destinations and explicit local file URLs are decoded once before filesystem validation.
|
|
212
|
+
The webview loads only packaged scripts/styles under a strict content security policy, forbids network resources, and renders model/tool/file text without interpreting embedded HTML. Images use validated data URLs under `img-src data:`; remote image URLs are never fetched by the webview. HTTP(S) links, including external image links, require a human click and pass through VS Code's external-link API. Clicked code references use a separate host action: paths are parsed, checked against the selected workspace, canonicalized, and restricted to regular non-sensitive files before VS Code opens the file and reveals a clamped line/range. Clicked workspace Markdown image references, and ordinary file links or inline references ending in PNG, JPEG, GIF or WebP extensions, use the same path boundary and the image validator before a preview is returned. The extension suffix chooses the viewer, not whether the file is safe or valid. Arbitrary command and URI handlers are not executed. Raw paths preserve literal percent signs; Markdown link destinations and explicit local file URLs are decoded once before filesystem validation.
|
|
199
213
|
|
|
200
214
|
Text/code context requires an explicit file/selection action, is size-bounded, rejects binary and known sensitive paths, and checks canonical workspace containment. Workspace file suggestions enumerate a bounded set of names; choosing a suggestion invokes the same attachment validation before reading contents. Dropped workspace URIs are validated independently of their browser representation. Native image selection, clipboard images, and dropped image files are explicit attachment actions and may supply images from outside the selected workspace; filesystem image sources still require ordinary files and pass sensitive-path checks. Pi-state names such as `auth`, `trust`, `sessions`, `missions`, and `history` are reserved inside `.pi`, Chat-owned storage, and the configured Pi agent directory (including its canonical target), not arbitrary source directories. Global credential/key patterns remain blocked, including `auth.json`. Relative `PI_CODING_AGENT_DIR` overrides retain conservative name blocking because they resolve in the child workspace; use an absolute path or `~/…` to obtain directory-scoped filtering. These filename checks and format validators are not content-based secret detectors. Manually typed messages, ordinary files, and images may contain sensitive content, and the extension does not promise image metadata removal. Prompts and attachments are sent to the provider selected in Pi when the user sends them.
|
|
201
215
|
|
|
@@ -209,7 +223,7 @@ Pi persists chats, including sent image blocks, in a workspace-specific director
|
|
|
209
223
|
|
|
210
224
|
Conversation edits select entries from the active Pi tree, revalidate the selection before mutation, and fork before the chosen user prompt. Exact original text and validated image blocks are captured before forking because Pi's fork response returns text only. The original conversation is retained, restored drafts require another send action, and conversation forks/clones do not restore or checkpoint workspace files. Actions are scoped to the active workspace, connection, and session revision. Export opens an unsaved Markdown document and Copy Conversation writes the visible Markdown to the OS clipboard only on explicit request. Both omit image bytes and represent images as placeholders; they do not reconstruct truncated or hidden history. Usage displays report Pi measurements, preserving unavailable values rather than inventing totals. The separate provider Limits row projects bounded strings from the known Codex Usage and pi-usage status keys, strips terminal/control sequences, and inserts text only. It does not parse numeric quotas, combine budgets with conversation cost, read provider credentials or usage-cache files, or add provider requests. Reports stay with their live connection and are cleared on disconnect, exit, and reconnect; refreshing and cache age remain upstream plugin responsibilities. Opening the details has no RPC or provider side effect. Existing `/usage` notifications and exact confirmation dialogs continue through the normal UI protocol; no automatic reset consumption or terminal-footer emulation is added.
|
|
211
225
|
|
|
212
|
-
The
|
|
226
|
+
The delegate activity strip consumes the known `specpi-delegation-v1` RPC widget key, with one versioned JSON record of at most 32 KiB and eight projected worker rows. It appears only while workers run or retain unsettled requests, starts collapsed, and excludes settled results; reports remain in the conversation. Queued jobs are shown only alongside live work. Unsettled requests without current-generation job metadata show only a stopping count, never prior task labels. Public identities, shortened task labels, states, elapsed times, call counts and code-owned diagnostics are text-only; snapshots, full prompts, raw provider errors and child transcripts are not sent through the live widget. A single extension-side timer samples existing counters while slots are occupied; it makes no provider or source requests and is disposed on shutdown/rebinding. Invalidated-generation jobs contribute only to occupied-slot counts, not cross-session task labels; replaced same-generation stopping jobs remain visible until settlement. Chat clears live state on disconnect, exit and runtime session replacement and keeps at most 32 connection-local completion summaries for refresh; it adds no child journal or webview persistence. Existing delegate tool receipts are projected into readable advisory reports without changing their model/session content. Stop actions require a trusted workspace, foreground conversation, matching context and an observed running/queued batch/job/attempt. Pi rechecks that exact attempt before its human command cancels it. Worker Stop does not submit draft text/images, clear parent queues, approve tools, change quotas or claim remote termination. The widget is an integration contract with trusted installed extensions, not protection against a malicious extension in Pi's process.
|
|
213
227
|
|
|
214
228
|
Native change review reads VS Code's Git API, validates selected workspace paths, and opens the corresponding working-tree or staged view through the built-in diff/editor actions. It does not execute a shell command, stage, commit, discard, or restore files. Git integration availability and the filesystem race boundary still apply. The sidebar coordinator keeps independent Pi connections for explicitly opened conversations. Switching conversations or folders only changes the selected view. Stop and Disconnect target the selected process; extension-host shutdown stops all owned processes. User-initiated parallel conversations share their workspace files and are not isolated worktrees. Branching uses Pi's pre-runtime `--fork` to copy into a new owned session before extensions can write, then performs any active-branch or prompt selection there; the original process and session remain unchanged.
|
|
215
229
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export type StartSpec = Readonly<{
|
|
4
|
+
command: string;
|
|
5
|
+
cwd: string;
|
|
6
|
+
label: string;
|
|
7
|
+
timeoutSeconds: number;
|
|
8
|
+
shell: string;
|
|
9
|
+
dialect: "bash" | "cmd";
|
|
10
|
+
}>;
|
|
11
|
+
export type TaskSummary = {
|
|
12
|
+
id: string;
|
|
13
|
+
label: string;
|
|
14
|
+
command: string;
|
|
15
|
+
status: "starting" | "running" | "stopping" | "cleanup-unconfirmed" | "failed" | "exited" | "killed";
|
|
16
|
+
elapsedMs: number;
|
|
17
|
+
exitCode: number | null;
|
|
18
|
+
exitSignal: string | null;
|
|
19
|
+
reason: string | null;
|
|
20
|
+
cleanup: "pending" | "confirmed" | "unconfirmed";
|
|
21
|
+
generation: number;
|
|
22
|
+
};
|
|
23
|
+
export type OwnedRoot = {
|
|
24
|
+
child?: Pick<ChildProcess, "pid">;
|
|
25
|
+
rootExited: boolean;
|
|
26
|
+
exitCode?: number | null;
|
|
27
|
+
};
|
|
28
|
+
export type Task = OwnedRoot & {
|
|
29
|
+
id: string;
|
|
30
|
+
spec: StartSpec;
|
|
31
|
+
generation: number;
|
|
32
|
+
startedAt: number;
|
|
33
|
+
endedAt?: number;
|
|
34
|
+
status: TaskSummary["status"];
|
|
35
|
+
cleanup: TaskSummary["cleanup"];
|
|
36
|
+
ring: OutputRing;
|
|
37
|
+
child?: ChildProcess;
|
|
38
|
+
exitSignal?: string | null;
|
|
39
|
+
reason?: string;
|
|
40
|
+
failed?: boolean;
|
|
41
|
+
stopping?: Promise<TaskSummary>;
|
|
42
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
43
|
+
};
|
|
44
|
+
export declare const LIMITS: Readonly<
|
|
45
|
+
Record<"command" | "cwd" | "label" | "active" | "completed" | "approvals" | "buffer" | "read" | "timeout", number>
|
|
46
|
+
>;
|
|
47
|
+
export declare function safeText(value: unknown): string;
|
|
48
|
+
export declare function preview(value: unknown, limit?: number): string;
|
|
49
|
+
export declare function record(value: unknown, keys: string[]): void;
|
|
50
|
+
export declare function shellSpec(platform?: NodeJS.Platform): Pick<StartSpec, "shell" | "dialect">;
|
|
51
|
+
export declare function normalizeStart(input: unknown, cwd: string): StartSpec;
|
|
52
|
+
export declare class OutputRing {
|
|
53
|
+
constructor(capacity?: number);
|
|
54
|
+
capacity: number;
|
|
55
|
+
bytes: Buffer;
|
|
56
|
+
end: number;
|
|
57
|
+
append(stream: "stdout" | "stderr", chunk: Buffer, final?: boolean): void;
|
|
58
|
+
append(stream: "stdout" | "stderr", chunk: undefined, final: true): void;
|
|
59
|
+
read(offset?: number): {
|
|
60
|
+
output: string;
|
|
61
|
+
offset: number;
|
|
62
|
+
nextOffset: number;
|
|
63
|
+
endOffset: number;
|
|
64
|
+
lostBytes: number;
|
|
65
|
+
truncated: boolean;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export declare function terminateOwned(
|
|
69
|
+
task: OwnedRoot,
|
|
70
|
+
options?: { graceMs?: number; observeMs?: number },
|
|
71
|
+
): Promise<boolean>;
|
|
72
|
+
export declare class TaskRunner {
|
|
73
|
+
constructor(options?: {
|
|
74
|
+
spawnProcess?: typeof spawn;
|
|
75
|
+
terminate?: (task: Task) => Promise<boolean>;
|
|
76
|
+
startupMs?: number;
|
|
77
|
+
});
|
|
78
|
+
tasks: Map<string, Task>;
|
|
79
|
+
closed: boolean;
|
|
80
|
+
spawnProcess: typeof spawn;
|
|
81
|
+
terminate: (task: Task) => Promise<boolean>;
|
|
82
|
+
startupMs: number;
|
|
83
|
+
get(id: string): Task;
|
|
84
|
+
summary(task: Task): TaskSummary;
|
|
85
|
+
list(): TaskSummary[];
|
|
86
|
+
evict(): void;
|
|
87
|
+
start(spec: StartSpec, generation: number, signal?: AbortSignal): Promise<TaskSummary>;
|
|
88
|
+
stop(id: string, reason?: string): Promise<TaskSummary>;
|
|
89
|
+
shutdown(): Promise<TaskSummary[]>;
|
|
90
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { spawn, execFile } from "node:child_process";
|
|
5
|
+
import { StringDecoder } from "node:string_decoder";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { redactCommand } from "../command-guard/redact.mjs";
|
|
8
|
+
|
|
9
|
+
export const LIMITS = Object.freeze({
|
|
10
|
+
command: 16384,
|
|
11
|
+
cwd: 4096,
|
|
12
|
+
label: 128,
|
|
13
|
+
active: 4,
|
|
14
|
+
completed: 32,
|
|
15
|
+
approvals: 128,
|
|
16
|
+
buffer: 262144,
|
|
17
|
+
read: 65536,
|
|
18
|
+
timeout: 28800,
|
|
19
|
+
});
|
|
20
|
+
const supervisor = fileURLToPath(new URL("./supervisor.mjs", import.meta.url));
|
|
21
|
+
const terminal = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu;
|
|
22
|
+
export function safeText(value) {
|
|
23
|
+
return String(value).replace(
|
|
24
|
+
terminal,
|
|
25
|
+
(character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function preview(value, limit = 320) {
|
|
30
|
+
return safeText(redactCommand(value, limit));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function record(value, keys) {
|
|
34
|
+
if (
|
|
35
|
+
!value ||
|
|
36
|
+
typeof value !== "object" ||
|
|
37
|
+
Array.isArray(value) ||
|
|
38
|
+
Object.keys(value).some((key) => !keys.includes(key))
|
|
39
|
+
) {
|
|
40
|
+
throw new Error("Invalid background task input.");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function text(value, limit, name) {
|
|
45
|
+
if (typeof value !== "string" || !value.trim() || value.includes("\0") || Buffer.byteLength(value) > limit) {
|
|
46
|
+
throw new Error(`Invalid or oversized ${name}.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function shellSpec(platform = process.platform) {
|
|
53
|
+
if (platform === "win32") {
|
|
54
|
+
return { shell: path.join(process.env.SystemRoot || "C:\\Windows", "System32", "cmd.exe"), dialect: "cmd" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { shell: "/bin/sh", dialect: "bash" };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function normalizeStart(input, cwd) {
|
|
61
|
+
record(input, ["command", "cwd", "label", "timeoutSeconds"]);
|
|
62
|
+
const command = text(input.command, LIMITS.command, "command");
|
|
63
|
+
const requestedCwd = text(input.cwd ?? cwd, LIMITS.cwd, "cwd");
|
|
64
|
+
let resolved;
|
|
65
|
+
try {
|
|
66
|
+
resolved = fs.realpathSync(path.resolve(cwd, requestedCwd));
|
|
67
|
+
if (!fs.statSync(resolved).isDirectory()) {
|
|
68
|
+
throw new Error();
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
throw new Error("Background cwd must be an existing directory.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
text(resolved, LIMITS.cwd, "resolved cwd");
|
|
75
|
+
const label = input.label === undefined ? "" : text(input.label, LIMITS.label, "label");
|
|
76
|
+
const timeoutSeconds = input.timeoutSeconds ?? 1800;
|
|
77
|
+
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > LIMITS.timeout) {
|
|
78
|
+
throw new Error("timeoutSeconds must be an integer from 1 to 28800.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return Object.freeze({ command, cwd: resolved, label, timeoutSeconds, ...shellSpec() });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class OutputRing {
|
|
85
|
+
constructor(capacity = LIMITS.buffer) {
|
|
86
|
+
this.capacity = capacity;
|
|
87
|
+
this.bytes = Buffer.alloc(0);
|
|
88
|
+
this.end = 0;
|
|
89
|
+
this.decoders = { stdout: new StringDecoder("utf8"), stderr: new StringDecoder("utf8") };
|
|
90
|
+
}
|
|
91
|
+
append(stream, chunk, final = false) {
|
|
92
|
+
const decoded = final ? this.decoders[stream].end() : this.decoders[stream].write(chunk);
|
|
93
|
+
if (!decoded) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const next = Buffer.from(`[${stream}] ${decoded}`);
|
|
98
|
+
this.end += next.length;
|
|
99
|
+
if (next.length >= this.capacity) {
|
|
100
|
+
this.bytes = Buffer.from(next.subarray(next.length - this.capacity));
|
|
101
|
+
} else {
|
|
102
|
+
this.bytes = Buffer.concat([
|
|
103
|
+
this.bytes.subarray(Math.max(0, this.bytes.length + next.length - this.capacity)),
|
|
104
|
+
next,
|
|
105
|
+
]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let skip = 0;
|
|
109
|
+
while (skip < this.bytes.length && (this.bytes[skip] & 0xc0) === 0x80) {
|
|
110
|
+
skip += 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.bytes = this.bytes.subarray(skip);
|
|
114
|
+
}
|
|
115
|
+
read(offset = 0) {
|
|
116
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > this.end) {
|
|
117
|
+
throw new Error("Invalid or future output cursor.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const oldest = this.end - this.bytes.length;
|
|
121
|
+
let start = Math.max(offset, oldest) - oldest;
|
|
122
|
+
while (start < this.bytes.length && (this.bytes[start] & 0xc0) === 0x80) {
|
|
123
|
+
start += 1;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let end = Math.min(this.bytes.length, start + LIMITS.read);
|
|
127
|
+
while (end < this.bytes.length && end > start && (this.bytes[end] & 0xc0) === 0x80) {
|
|
128
|
+
end -= 1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
output: safeText(this.bytes.subarray(start, end).toString("utf8")),
|
|
133
|
+
offset: oldest + start,
|
|
134
|
+
nextOffset: oldest + end,
|
|
135
|
+
endOffset: this.end,
|
|
136
|
+
lostBytes: oldest + start - offset,
|
|
137
|
+
truncated: oldest + start > offset || end < this.bytes.length,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function delay(milliseconds) {
|
|
143
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function groupAlive(pid) {
|
|
147
|
+
try {
|
|
148
|
+
process.kill(-pid, 0);
|
|
149
|
+
|
|
150
|
+
return true;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return error.code !== "ESRCH";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function terminateOwned(task, { graceMs = 5000, observeMs = 1000 } = {}) {
|
|
157
|
+
if (!task.child?.pid) {
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const pid = task.child.pid;
|
|
162
|
+
if (process.platform === "win32") {
|
|
163
|
+
// Never taskkill a PID after observing that the owned supervisor exited.
|
|
164
|
+
if (task.rootExited) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const helper = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe");
|
|
169
|
+
const ok = await new Promise((resolve) => {
|
|
170
|
+
execFile(
|
|
171
|
+
helper,
|
|
172
|
+
["/PID", String(pid), "/T", "/F"],
|
|
173
|
+
{ windowsHide: true, timeout: 5000, maxBuffer: 16384 },
|
|
174
|
+
(error) => resolve(!error),
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
const end = Date.now() + observeMs;
|
|
178
|
+
while (!task.rootExited && Date.now() < end) {
|
|
179
|
+
await delay(20);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return ok && task.rootExited;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Preserve grace for a running command/tree. An observed command exit (including
|
|
186
|
+
// a null exit code for a signal/failure) needs only the remaining group cleanup.
|
|
187
|
+
// Keep the supervisor alive until escalation; never signal an observed-dead root.
|
|
188
|
+
const commandExited = task.exitCode !== undefined;
|
|
189
|
+
if (!task.rootExited) {
|
|
190
|
+
try {
|
|
191
|
+
process.kill(-pid, "SIGTERM");
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error.code !== "ESRCH") {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!commandExited) {
|
|
199
|
+
await delay(graceMs);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!task.rootExited) {
|
|
203
|
+
try {
|
|
204
|
+
process.kill(-pid, "SIGKILL");
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error.code !== "ESRCH") {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const end = Date.now() + observeMs;
|
|
214
|
+
while ((!task.rootExited || groupAlive(pid)) && Date.now() < end) {
|
|
215
|
+
await delay(20);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return task.rootExited && !groupAlive(pid);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export class TaskRunner {
|
|
222
|
+
constructor({ spawnProcess = spawn, terminate = terminateOwned, startupMs = 10000 } = {}) {
|
|
223
|
+
this.tasks = new Map();
|
|
224
|
+
this.spawnProcess = spawnProcess;
|
|
225
|
+
this.terminate = terminate;
|
|
226
|
+
this.startupMs = startupMs;
|
|
227
|
+
this.closed = false;
|
|
228
|
+
}
|
|
229
|
+
get(id) {
|
|
230
|
+
if (typeof id !== "string" || !this.tasks.has(id)) {
|
|
231
|
+
throw new Error("Unknown background task ID.");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return this.tasks.get(id);
|
|
235
|
+
}
|
|
236
|
+
summary(task) {
|
|
237
|
+
return {
|
|
238
|
+
id: task.id,
|
|
239
|
+
label: preview(task.spec.label, LIMITS.label),
|
|
240
|
+
command: preview(task.spec.command),
|
|
241
|
+
status: task.status,
|
|
242
|
+
elapsedMs: (task.endedAt ?? Date.now()) - task.startedAt,
|
|
243
|
+
exitCode: task.exitCode ?? null,
|
|
244
|
+
exitSignal: task.exitSignal ?? null,
|
|
245
|
+
reason: task.reason ?? null,
|
|
246
|
+
cleanup: task.cleanup,
|
|
247
|
+
generation: task.generation,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
list() {
|
|
251
|
+
return [...this.tasks.values()].map((task) => this.summary(task));
|
|
252
|
+
}
|
|
253
|
+
evict() {
|
|
254
|
+
const completed = [...this.tasks.values()].filter((task) => task.cleanup === "confirmed");
|
|
255
|
+
while (completed.length > LIMITS.completed) {
|
|
256
|
+
this.tasks.delete(completed.shift().id);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
async start(spec, generation, signal) {
|
|
260
|
+
signal?.throwIfAborted();
|
|
261
|
+
if (
|
|
262
|
+
this.closed ||
|
|
263
|
+
[...this.tasks.values()].filter((task) => task.cleanup !== "confirmed").length >= LIMITS.active
|
|
264
|
+
) {
|
|
265
|
+
throw new Error("Background admission closed or four active/unconfirmed tasks already exist.");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const task = {
|
|
269
|
+
id: randomUUID(),
|
|
270
|
+
spec,
|
|
271
|
+
generation,
|
|
272
|
+
startedAt: Date.now(),
|
|
273
|
+
status: "starting",
|
|
274
|
+
cleanup: "pending",
|
|
275
|
+
ring: new OutputRing(),
|
|
276
|
+
rootExited: false,
|
|
277
|
+
};
|
|
278
|
+
// Reserve synchronously before any await, including supervisor startup.
|
|
279
|
+
this.tasks.set(task.id, task);
|
|
280
|
+
let settle;
|
|
281
|
+
const started = new Promise((resolve) => {
|
|
282
|
+
settle = resolve;
|
|
283
|
+
});
|
|
284
|
+
const abort = () => {
|
|
285
|
+
void this.stop(task.id, "start cancelled");
|
|
286
|
+
settle(false);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
290
|
+
try {
|
|
291
|
+
task.child = this.spawnProcess(process.execPath, [supervisor], {
|
|
292
|
+
cwd: spec.cwd,
|
|
293
|
+
env: { ...process.env, NODE_OPTIONS: "", NODE_PATH: "" },
|
|
294
|
+
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
|
295
|
+
detached: process.platform !== "win32",
|
|
296
|
+
windowsHide: true,
|
|
297
|
+
});
|
|
298
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
299
|
+
task.child[stream].on("data", (chunk) => task.ring.append(stream, chunk));
|
|
300
|
+
task.child[stream].once("end", () => task.ring.append(stream, undefined, true));
|
|
301
|
+
task.child[stream].on("error", () => {});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
task.child.once("error", () => {
|
|
305
|
+
task.failed = true;
|
|
306
|
+
task.rootExited = true;
|
|
307
|
+
settle(false);
|
|
308
|
+
void this.stop(task.id, "spawn failed");
|
|
309
|
+
});
|
|
310
|
+
task.child.once("exit", () => {
|
|
311
|
+
task.rootExited = true;
|
|
312
|
+
settle(false);
|
|
313
|
+
if (!task.stopping) {
|
|
314
|
+
void this.stop(task.id, "supervisor exited unexpectedly");
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
task.child.on("message", (message) => {
|
|
318
|
+
if (message?.event === "ready" && task.status === "starting") {
|
|
319
|
+
const args =
|
|
320
|
+
process.platform === "win32" ? ["/d", "/s", "/c", `"${spec.command}"`] : ["-c", spec.command];
|
|
321
|
+
task.child.send({ operation: "start", shell: spec.shell, args, cwd: spec.cwd }, (error) => {
|
|
322
|
+
if (error) {
|
|
323
|
+
settle(false);
|
|
324
|
+
void this.stop(task.id, "startup failed");
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
} else if (message?.event === "started") {
|
|
328
|
+
if (task.status === "starting") {
|
|
329
|
+
task.status = "running";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
settle(true);
|
|
333
|
+
} else if (message?.event === "exited" || message?.event === "failed") {
|
|
334
|
+
task.exitCode = Number.isInteger(message.code) ? message.code : null;
|
|
335
|
+
task.exitSignal = typeof message.signal === "string" ? message.signal.slice(0, 32) : null;
|
|
336
|
+
task.failed = message.event === "failed";
|
|
337
|
+
settle(!task.failed);
|
|
338
|
+
void this.stop(task.id, task.failed ? "command spawn failed" : "command exited");
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
task.timer = setTimeout(() => {
|
|
342
|
+
void this.stop(task.id, "timeout");
|
|
343
|
+
}, spec.timeoutSeconds * 1000);
|
|
344
|
+
const startupTimer = setTimeout(() => settle(false), this.startupMs);
|
|
345
|
+
const success = await started;
|
|
346
|
+
clearTimeout(startupTimer);
|
|
347
|
+
if (!success || signal?.aborted || this.closed) {
|
|
348
|
+
await this.stop(task.id, signal?.aborted ? "start cancelled" : "startup failed");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return this.summary(task);
|
|
352
|
+
} catch {
|
|
353
|
+
task.failed = true;
|
|
354
|
+
await this.stop(task.id, "spawn failed");
|
|
355
|
+
|
|
356
|
+
return this.summary(task);
|
|
357
|
+
} finally {
|
|
358
|
+
signal?.removeEventListener("abort", abort);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async stop(id, reason = "stop requested") {
|
|
362
|
+
const task = this.get(id);
|
|
363
|
+
if (task.cleanup === "confirmed") {
|
|
364
|
+
return this.summary(task);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (task.stopping) {
|
|
368
|
+
return task.stopping;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
task.status = "stopping";
|
|
372
|
+
task.reason ??= reason;
|
|
373
|
+
clearTimeout(task.timer);
|
|
374
|
+
task.stopping = (async () => {
|
|
375
|
+
let confirmed = false;
|
|
376
|
+
try {
|
|
377
|
+
confirmed = await this.terminate(task);
|
|
378
|
+
} catch {
|
|
379
|
+
// Failure is evidence of uncertainty, never evidence of death.
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
task.cleanup = confirmed ? "confirmed" : "unconfirmed";
|
|
383
|
+
task.status = !confirmed
|
|
384
|
+
? "cleanup-unconfirmed"
|
|
385
|
+
: task.failed
|
|
386
|
+
? "failed"
|
|
387
|
+
: task.reason === "command exited"
|
|
388
|
+
? "exited"
|
|
389
|
+
: "killed";
|
|
390
|
+
if (confirmed) {
|
|
391
|
+
task.endedAt = Date.now();
|
|
392
|
+
task.child?.stdout?.destroy();
|
|
393
|
+
task.child?.stderr?.destroy();
|
|
394
|
+
if (task.child?.connected) {
|
|
395
|
+
task.child.disconnect();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
this.evict();
|
|
400
|
+
|
|
401
|
+
return this.summary(task);
|
|
402
|
+
})();
|
|
403
|
+
const result = await task.stopping;
|
|
404
|
+
task.stopping = undefined;
|
|
405
|
+
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
async shutdown() {
|
|
409
|
+
this.closed = true;
|
|
410
|
+
|
|
411
|
+
return Promise.all([...this.tasks.keys()].map((id) => this.stop(id, "session cleanup")));
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import { decideCommand } from "../command-guard/core.mjs";
|
|
5
|
+
import { LIMITS, TaskRunner, normalizeStart, preview, record } from "./core.mjs";
|
|
6
|
+
|
|
7
|
+
function fingerprint(value: unknown): string {
|
|
8
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function result(value: unknown) {
|
|
12
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details: {} };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function registerBackgroundTasks(
|
|
16
|
+
pi: ExtensionAPI,
|
|
17
|
+
dependencies: {
|
|
18
|
+
runner?: Pick<TaskRunner, "closed" | "list" | "shutdown" | "start" | "get" | "stop">;
|
|
19
|
+
approvalMs?: number;
|
|
20
|
+
} = {},
|
|
21
|
+
) {
|
|
22
|
+
let runner = dependencies.runner ?? new TaskRunner();
|
|
23
|
+
let generation = 0;
|
|
24
|
+
let active = false;
|
|
25
|
+
let pending = 0;
|
|
26
|
+
const approvals = new Set<string>();
|
|
27
|
+
let lifecycle = new AbortController();
|
|
28
|
+
const invalidate = () => {
|
|
29
|
+
generation += 1;
|
|
30
|
+
approvals.clear();
|
|
31
|
+
lifecycle.abort();
|
|
32
|
+
lifecycle = new AbortController();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let unsubscribe: (() => void) | undefined;
|
|
36
|
+
const subscribe = () => {
|
|
37
|
+
unsubscribe ??= pi.events.on("specpi:guard-policy-changed", invalidate);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
subscribe();
|
|
41
|
+
const admission = (spec: any, hasUI: boolean) => {
|
|
42
|
+
let stateReplies = 0;
|
|
43
|
+
pi.events.emit("specpi:guard-state", {
|
|
44
|
+
reply() {
|
|
45
|
+
stateReplies += 1;
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
const replies: any[] = [];
|
|
49
|
+
pi.events.emit("specpi:background-admission", {
|
|
50
|
+
input: { command: spec.command },
|
|
51
|
+
cwd: spec.cwd,
|
|
52
|
+
shell: spec.dialect,
|
|
53
|
+
hasUI,
|
|
54
|
+
reply(value: unknown) {
|
|
55
|
+
replies.push(value);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
if (stateReplies === 0 && replies.length === 0) {
|
|
59
|
+
const decision = decideCommand(spec.command, {
|
|
60
|
+
mode: "guard",
|
|
61
|
+
shell: spec.dialect,
|
|
62
|
+
cwd: spec.cwd,
|
|
63
|
+
platform: process.platform,
|
|
64
|
+
hasUI,
|
|
65
|
+
cache: false,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return { mode: "absent-guard-policy", generation: 0, action: decision.action, reason: decision.reason };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const policy = replies[0];
|
|
72
|
+
if (
|
|
73
|
+
stateReplies !== 1 ||
|
|
74
|
+
replies.length !== 1 ||
|
|
75
|
+
!["guard", "strict", "off", "locked"].includes(policy?.mode) ||
|
|
76
|
+
!Number.isSafeInteger(policy?.generation) ||
|
|
77
|
+
!["allow", "ask", "deny"].includes(policy?.action)
|
|
78
|
+
) {
|
|
79
|
+
throw new Error("Background Guard policy is unavailable or ambiguous; start denied.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return policy;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const cleanup = async (_event: unknown, ctx: any) => {
|
|
86
|
+
active = false;
|
|
87
|
+
invalidate();
|
|
88
|
+
const outcomes = await runner.shutdown();
|
|
89
|
+
const unconfirmed = outcomes.filter((task: any) => task.cleanup !== "confirmed");
|
|
90
|
+
if (unconfirmed.length) {
|
|
91
|
+
ctx.ui.notify(
|
|
92
|
+
`Background cleanup unconfirmed for ${unconfirmed.map((task: any) => task.id).join(", ")}. Processes may still be running. background_start is disabled in this runtime: retry background_stop for these IDs, then /reload after cleanup confirms. IDs do not survive runtime replacement; inspect any remaining processes manually.`,
|
|
93
|
+
"warning",
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
99
|
+
await cleanup(event, ctx);
|
|
100
|
+
unsubscribe?.();
|
|
101
|
+
unsubscribe = undefined;
|
|
102
|
+
});
|
|
103
|
+
pi.on("session_start", async (event, ctx) => {
|
|
104
|
+
subscribe();
|
|
105
|
+
if (runner.list().length) {
|
|
106
|
+
await cleanup(event, ctx);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (runner.list().some((task: any) => task.cleanup !== "confirmed")) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (runner.closed) {
|
|
114
|
+
runner = dependencies.runner ?? new TaskRunner();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
invalidate();
|
|
118
|
+
active = true;
|
|
119
|
+
});
|
|
120
|
+
pi.on("session_tree", async (event, ctx) => {
|
|
121
|
+
await cleanup(event, ctx);
|
|
122
|
+
if (!runner.list().some((task: any) => task.cleanup !== "confirmed")) {
|
|
123
|
+
runner = dependencies.runner ?? new TaskRunner();
|
|
124
|
+
active = true;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
pi.registerTool({
|
|
129
|
+
name: "background_start",
|
|
130
|
+
label: "Start background task",
|
|
131
|
+
description:
|
|
132
|
+
"Start a session-owned noninteractive command after explicit approval. Uses /bin/sh on POSIX and system cmd.exe on Windows, not Pi Bash configuration. Four active tasks; 30 minute default timeout, eight hour maximum. Inherits the process environment. Not a sandbox or proof of service readiness.",
|
|
133
|
+
promptSnippet: "Start an approved long-running command while other work continues",
|
|
134
|
+
promptGuidelines: [
|
|
135
|
+
"Never use background_start to reroute a denied command. Use background_logs to observe progress and background_stop when a task is no longer needed. Spawn success is not readiness or completion evidence.",
|
|
136
|
+
],
|
|
137
|
+
parameters: Type.Object(
|
|
138
|
+
{
|
|
139
|
+
command: Type.String({ minLength: 1, maxLength: LIMITS.command }),
|
|
140
|
+
cwd: Type.Optional(Type.String({ minLength: 1, maxLength: LIMITS.cwd })),
|
|
141
|
+
label: Type.Optional(Type.String({ minLength: 1, maxLength: LIMITS.label })),
|
|
142
|
+
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1, maximum: LIMITS.timeout })),
|
|
143
|
+
},
|
|
144
|
+
{ additionalProperties: false },
|
|
145
|
+
),
|
|
146
|
+
async execute(_id, input, signal, _update, ctx) {
|
|
147
|
+
if (!active || !ctx.hasUI || pending >= LIMITS.active) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
"Background starts require an active session, approval UI, and an available admission slot.",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pending += 1;
|
|
154
|
+
try {
|
|
155
|
+
const spec = normalizeStart(input, ctx.cwd);
|
|
156
|
+
const policy = admission(spec, ctx.hasUI);
|
|
157
|
+
if (policy.action === "deny") {
|
|
158
|
+
throw new Error(preview(policy.reason));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const epoch = generation;
|
|
162
|
+
const key = fingerprint({ spec, policy, generation: epoch });
|
|
163
|
+
const abort = AbortSignal.any([lifecycle.signal, ...(signal ? [signal] : [])]);
|
|
164
|
+
abort.throwIfAborted();
|
|
165
|
+
if (!approvals.has(key)) {
|
|
166
|
+
const controller = new AbortController();
|
|
167
|
+
const promptSignal = AbortSignal.any([abort, controller.signal]);
|
|
168
|
+
const timer = setTimeout(() => controller.abort(), dependencies.approvalMs ?? 600000);
|
|
169
|
+
let cancel: () => void = () => {};
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const cancelled = new Promise<boolean>((resolve) => {
|
|
173
|
+
cancel = () => resolve(false);
|
|
174
|
+
promptSignal.addEventListener("abort", cancel, { once: true });
|
|
175
|
+
});
|
|
176
|
+
const confirmed = await Promise.race([
|
|
177
|
+
ctx.ui.confirm(
|
|
178
|
+
"Start background command for this session?",
|
|
179
|
+
`Shell: ${preview(spec.shell)}\nCwd: ${preview(spec.cwd, LIMITS.cwd)}\nCommand: ${preview(spec.command, LIMITS.command)}\nTimeout: ${spec.timeoutSeconds}s\nGuard: ${preview(policy.mode)} — ${preview(policy.reason)}\nRuns with your permissions and inherited environment. Not a sandbox. Approves this exact execution for this session; output may enter conversation/provider retention.`,
|
|
180
|
+
{ signal: promptSignal },
|
|
181
|
+
),
|
|
182
|
+
cancelled,
|
|
183
|
+
]);
|
|
184
|
+
if (!confirmed || promptSignal.aborted) {
|
|
185
|
+
throw new Error("Background command was not approved.");
|
|
186
|
+
}
|
|
187
|
+
} finally {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
promptSignal.removeEventListener("abort", cancel);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
abort.throwIfAborted();
|
|
194
|
+
const current = normalizeStart(input, ctx.cwd);
|
|
195
|
+
const currentPolicy = admission(current, ctx.hasUI);
|
|
196
|
+
if (
|
|
197
|
+
!active ||
|
|
198
|
+
!ctx.hasUI ||
|
|
199
|
+
epoch !== generation ||
|
|
200
|
+
currentPolicy.action === "deny" ||
|
|
201
|
+
fingerprint({ spec: current, policy: currentPolicy, generation }) !== key
|
|
202
|
+
) {
|
|
203
|
+
throw new Error("Background input or policy changed during approval; start denied.");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
while (approvals.size >= LIMITS.approvals && !approvals.has(key)) {
|
|
207
|
+
approvals.delete(approvals.values().next().value!);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
approvals.add(key);
|
|
211
|
+
|
|
212
|
+
return result(await runner.start(current, generation, abort));
|
|
213
|
+
} finally {
|
|
214
|
+
pending -= 1;
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
pi.registerTool({
|
|
219
|
+
name: "background_list",
|
|
220
|
+
label: "List background tasks",
|
|
221
|
+
description:
|
|
222
|
+
"List at most four active and 32 completed session tasks with bounded redacted previews and cleanup outcomes.",
|
|
223
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
224
|
+
async execute(_id, input) {
|
|
225
|
+
record(input, []);
|
|
226
|
+
|
|
227
|
+
return result(runner.list());
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
pi.registerTool({
|
|
231
|
+
name: "background_logs",
|
|
232
|
+
label: "Read background output",
|
|
233
|
+
description:
|
|
234
|
+
"Read untrusted memory-only task output after an absolute byte offset. At most 64 KiB raw output per read from a 256 KiB ring; stream markers count toward offsets. Terminal controls are escaped; truncation and lost bytes are explicit. Returned text may enter conversation/provider retention.",
|
|
235
|
+
parameters: Type.Object(
|
|
236
|
+
{
|
|
237
|
+
id: Type.String({ minLength: 1, maxLength: 36 }),
|
|
238
|
+
offset: Type.Optional(Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })),
|
|
239
|
+
},
|
|
240
|
+
{ additionalProperties: false },
|
|
241
|
+
),
|
|
242
|
+
async execute(_id, input, signal) {
|
|
243
|
+
record(input, ["id", "offset"]);
|
|
244
|
+
signal?.throwIfAborted();
|
|
245
|
+
const task = runner.get(input.id);
|
|
246
|
+
|
|
247
|
+
return result({ id: task.id, status: task.status, ...task.ring.read(input.offset) });
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
pi.registerTool({
|
|
251
|
+
name: "background_stop",
|
|
252
|
+
label: "Stop background task",
|
|
253
|
+
description:
|
|
254
|
+
"Idempotently request bounded termination of an owned task. Available under Guard locks. Reports confirmed or unconfirmed cleanup; escaped descendants are not contained.",
|
|
255
|
+
parameters: Type.Object({ id: Type.String({ minLength: 1, maxLength: 36 }) }, { additionalProperties: false }),
|
|
256
|
+
async execute(_id, input) {
|
|
257
|
+
record(input, ["id"]);
|
|
258
|
+
|
|
259
|
+
return result(await runner.stop(input.id));
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { TaskRunner, OutputRing, normalizeStart, terminateOwned } from "./core.mjs";
|
|
3
|
+
|
|
4
|
+
const ring = new OutputRing(32);
|
|
5
|
+
ring.append("stdout", Buffer.from("x".repeat(100)));
|
|
6
|
+
assert.equal(ring.read().lostBytes > 0, true);
|
|
7
|
+
const runner = new TaskRunner({ terminate: (task) => terminateOwned(task, { graceMs: 20, observeMs: 2000 }) });
|
|
8
|
+
try {
|
|
9
|
+
const spec = normalizeStart(
|
|
10
|
+
{ command: `"${process.execPath}" -e "process.stdout.write('background-smoke');setInterval(()=>{},1000)"` },
|
|
11
|
+
process.cwd(),
|
|
12
|
+
);
|
|
13
|
+
const started = await runner.start(spec, 1);
|
|
14
|
+
assert.equal(started.status, "running");
|
|
15
|
+
const task = runner.get(started.id);
|
|
16
|
+
await new Promise((resolve, reject) => {
|
|
17
|
+
const timer = setTimeout(() => {
|
|
18
|
+
clearInterval(poll);
|
|
19
|
+
reject(new Error("Smoke output deadline exceeded"));
|
|
20
|
+
}, 10000);
|
|
21
|
+
const poll = setInterval(() => {
|
|
22
|
+
if (task.ring.read().output.includes("background-smoke")) {
|
|
23
|
+
clearInterval(poll);
|
|
24
|
+
clearTimeout(timer);
|
|
25
|
+
resolve();
|
|
26
|
+
}
|
|
27
|
+
}, 20);
|
|
28
|
+
});
|
|
29
|
+
assert.equal((await runner.stop(started.id)).cleanup, "confirmed");
|
|
30
|
+
console.log("BACKGROUND_TASKS_SMOKE=passed");
|
|
31
|
+
} finally {
|
|
32
|
+
await runner.shutdown();
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// An owned root stays alive until the parent completes bounded tree cleanup.
|
|
2
|
+
// Never load project modules or configuration here. Commands arrive once over IPC.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
let started = false;
|
|
6
|
+
process.on("message", (message) => {
|
|
7
|
+
if (started || message?.operation !== "start") {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
started = true;
|
|
12
|
+
try {
|
|
13
|
+
const child = spawn(message.shell, message.args, {
|
|
14
|
+
cwd: message.cwd,
|
|
15
|
+
env: process.env,
|
|
16
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
17
|
+
windowsHide: true,
|
|
18
|
+
windowsVerbatimArguments: process.platform === "win32",
|
|
19
|
+
});
|
|
20
|
+
child.once("spawn", () => process.send?.({ event: "started" }));
|
|
21
|
+
child.once("error", () => process.send?.({ event: "failed" }));
|
|
22
|
+
child.once("exit", (code, signal) => process.send?.({ event: "exited", code, signal }));
|
|
23
|
+
} catch {
|
|
24
|
+
process.send?.({ event: "failed" });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
// Keep the root alive during POSIX group grace so its identity cannot be reused.
|
|
28
|
+
process.on("SIGTERM", () => {});
|
|
29
|
+
process.on("disconnect", () => {
|
|
30
|
+
// A crashed parent cannot provide reliable tree cleanup. Do not persist a daemon.
|
|
31
|
+
process.exit(1);
|
|
32
|
+
});
|
|
33
|
+
process.send?.({ event: "ready" });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Narrow surface used by the background extension's scoped type gate, not the full Guard API.
|
|
2
|
+
export declare function decideCommand(
|
|
3
|
+
command: unknown,
|
|
4
|
+
options?: {
|
|
5
|
+
mode?: "guard" | "strict" | "off" | "locked";
|
|
6
|
+
shell?: "bash" | "cmd" | "powershell";
|
|
7
|
+
cwd?: string;
|
|
8
|
+
platform?: NodeJS.Platform;
|
|
9
|
+
hasUI?: boolean;
|
|
10
|
+
cache?: boolean;
|
|
11
|
+
},
|
|
12
|
+
): { action: "allow" | "ask" | "deny"; reason: string };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import { clearAnalysisCache, decideCommand, decidePath } from "./core.mjs";
|
|
5
6
|
import { boundedReason } from "./redact.mjs";
|
|
@@ -182,8 +183,54 @@ export default function registerCommandGuard(
|
|
|
182
183
|
rules: {},
|
|
183
184
|
};
|
|
184
185
|
state.onModeChanged = () => pi.events?.emit("specpi:guard-policy-changed", { reason: "guard policy changed" });
|
|
186
|
+
let backgroundSubscription: (() => void) | undefined;
|
|
185
187
|
let guardStateSubscription: (() => void) | undefined;
|
|
186
188
|
const subscribeGuardState = () => {
|
|
189
|
+
if (!backgroundSubscription) {
|
|
190
|
+
backgroundSubscription = pi.events?.on?.("specpi:background-admission", (request: any) => {
|
|
191
|
+
if (typeof request?.reply !== "function") {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const unavailable = !state.ready || state.startupFailed || state.mode === "locked";
|
|
196
|
+
if (
|
|
197
|
+
unavailable ||
|
|
198
|
+
!validCommandInput(request.input) ||
|
|
199
|
+
typeof request.cwd !== "string" ||
|
|
200
|
+
!["bash", "cmd"].includes(request.shell)
|
|
201
|
+
) {
|
|
202
|
+
request.reply({
|
|
203
|
+
mode: state.mode,
|
|
204
|
+
generation: state.generation,
|
|
205
|
+
action: "deny",
|
|
206
|
+
reason: "Command guard is locked, unavailable, or received invalid background input.",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const decision = decideCommand(request.input.command, {
|
|
213
|
+
mode: state.mode,
|
|
214
|
+
shell: request.shell,
|
|
215
|
+
cwd: request.cwd,
|
|
216
|
+
platform: process.platform,
|
|
217
|
+
hasUI: request.hasUI === true,
|
|
218
|
+
cache: false,
|
|
219
|
+
});
|
|
220
|
+
recordDecision(state, decision);
|
|
221
|
+
if (decision.action === "deny") {
|
|
222
|
+
deny(state, decision.reason, decision.lockSession === true);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
request.reply({
|
|
226
|
+
mode: state.mode,
|
|
227
|
+
generation: state.generation,
|
|
228
|
+
action: decision.action,
|
|
229
|
+
reason: decision.reason,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
187
234
|
if (!guardStateSubscription) {
|
|
188
235
|
guardStateSubscription = pi.events?.on?.("specpi:guard-state", (request: any) => {
|
|
189
236
|
request.reply({ mode: state.ready && !state.startupFailed ? state.mode : undefined });
|
|
@@ -192,6 +239,24 @@ export default function registerCommandGuard(
|
|
|
192
239
|
};
|
|
193
240
|
|
|
194
241
|
subscribeGuardState();
|
|
242
|
+
const backgroundSource = fileURLToPath(new URL("../background-tasks/index.ts", import.meta.url));
|
|
243
|
+
const ownsBackgroundTool = (name: string): boolean => {
|
|
244
|
+
if (!["background_start", "background_list", "background_logs", "background_stop"].includes(name)) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const matches = pi.getAllTools?.().filter((tool) => tool.name === name) ?? [];
|
|
249
|
+
const source = matches.length === 1 ? matches[0].sourceInfo?.path : undefined;
|
|
250
|
+
if (typeof source !== "string" || !path.isAbsolute(source)) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const actual = path.resolve(source);
|
|
255
|
+
const expected = path.resolve(backgroundSource);
|
|
256
|
+
|
|
257
|
+
return process.platform === "win32" ? actual.toLowerCase() === expected.toLowerCase() : actual === expected;
|
|
258
|
+
};
|
|
259
|
+
|
|
195
260
|
const delegationPolicy = (input: unknown): { fingerprint: string; summary: string } | undefined => {
|
|
196
261
|
let replies = 0;
|
|
197
262
|
let policy: any;
|
|
@@ -285,6 +350,8 @@ export default function registerCommandGuard(
|
|
|
285
350
|
});
|
|
286
351
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
287
352
|
reset();
|
|
353
|
+
backgroundSubscription?.();
|
|
354
|
+
backgroundSubscription = undefined;
|
|
288
355
|
if (typeof guardStateSubscription === "function") {
|
|
289
356
|
guardStateSubscription();
|
|
290
357
|
guardStateSubscription = undefined;
|
|
@@ -443,6 +510,13 @@ export default function registerCommandGuard(
|
|
|
443
510
|
return deny(state, "Malformed tool call.");
|
|
444
511
|
}
|
|
445
512
|
|
|
513
|
+
// Check Pi's current registration provenance, not a tool name or cached handshake.
|
|
514
|
+
// Genuine background tools validate again in execute; cleanup remains usable under a lock.
|
|
515
|
+
// A missing or replaced registration retains normal Strict/locked enforcement.
|
|
516
|
+
if (ownsBackgroundTool(name)) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
446
520
|
if (!state.ready || state.startupFailed) {
|
|
447
521
|
return deny(state, "Command guard is not initialized; protected tool calls are denied.");
|
|
448
522
|
}
|
|
@@ -7,7 +7,7 @@ import { DelegationError } from "./errors.mjs";
|
|
|
7
7
|
import { createTimeoutStore } from "./settings.mjs";
|
|
8
8
|
|
|
9
9
|
const stateKey = Symbol.for("specpi.delegation.native.v1");
|
|
10
|
-
const revision =
|
|
10
|
+
const revision = 10;
|
|
11
11
|
|
|
12
12
|
export async function withPiCompatibility(sdk, loadCompatibility) {
|
|
13
13
|
if (typeof sdk.clampThinkingLevel === "function") {
|
|
@@ -325,7 +325,8 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
|
|
|
325
325
|
check();
|
|
326
326
|
initialization ??= (async () => {
|
|
327
327
|
// Pi owns normal configuration and credential access. No credential object is
|
|
328
|
-
// requested, copied, or exposed.
|
|
328
|
+
// requested, copied, or exposed. Restore Pi's persisted catalogs without
|
|
329
|
+
// network refresh; skipping initialization also skips cached model overlays.
|
|
329
330
|
const configured = sdk.SettingsManager.create(ctx.cwd, undefined, { projectTrusted: false });
|
|
330
331
|
if (
|
|
331
332
|
configured.getGlobalSettings().httpProxy ||
|
|
@@ -343,7 +344,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
|
|
|
343
344
|
compaction: { enabled: false },
|
|
344
345
|
retry: { enabled: false, maxRetries: 0, provider: { maxRetries: 0 } },
|
|
345
346
|
};
|
|
346
|
-
const runtime = await sdk.ModelRuntime.create({ allowModelNetwork: false
|
|
347
|
+
const runtime = await sdk.ModelRuntime.create({ allowModelNetwork: false });
|
|
347
348
|
check();
|
|
348
349
|
const childModel = runtime.getModel(model.provider, model.id);
|
|
349
350
|
if (
|
package/package.json
CHANGED
package/scripts/specpi.mjs
CHANGED
|
@@ -46,6 +46,8 @@ const browserRuntimeSourceDir = path.join(repoRoot, "browser-runtime");
|
|
|
46
46
|
const browserRuntimeDir = path.join(stateDir, "browser-runtime");
|
|
47
47
|
const browserRuntimeMarker = path.join(browserRuntimeDir, "specpi-runtime.json");
|
|
48
48
|
const browserSmokePath = path.join(agentDir, "extensions", "browser", "smoke.mjs");
|
|
49
|
+
const backgroundFiles = ["index.ts", "core.mjs", "supervisor.mjs", "smoke.mjs"];
|
|
50
|
+
const backgroundRoot = path.join(agentDir, "extensions", "background-tasks");
|
|
49
51
|
const capabilityRegistryPath = path.join(agentDir, "extensions", "tool-wishlist", "capabilities.json");
|
|
50
52
|
const managedToolsDir = path.join(stateDir, "optional-tools");
|
|
51
53
|
const managedBinDir = path.join(stateDir, "bin");
|
|
@@ -752,6 +754,11 @@ function desiredSettingsOperations() {
|
|
|
752
754
|
|
|
753
755
|
function managedFiles(includeShell) {
|
|
754
756
|
const files = [
|
|
757
|
+
...backgroundFiles.map((name) => [
|
|
758
|
+
path.join(repoRoot, "extensions", "background-tasks", name),
|
|
759
|
+
path.join(backgroundRoot, name),
|
|
760
|
+
0o644,
|
|
761
|
+
]),
|
|
755
762
|
[path.join(repoRoot, "extensions", "spec.ts"), path.join(agentDir, "extensions", "spec.ts"), 0o644],
|
|
756
763
|
[
|
|
757
764
|
path.join(repoRoot, "extensions", "spec", "core.mjs"),
|
|
@@ -1134,6 +1141,7 @@ async function confirm(message, yes) {
|
|
|
1134
1141
|
|
|
1135
1142
|
function assertSources() {
|
|
1136
1143
|
const required = [
|
|
1144
|
+
...backgroundFiles.map((name) => `extensions/background-tasks/${name}`),
|
|
1137
1145
|
"extensions/spec.ts",
|
|
1138
1146
|
"extensions/spec/core.mjs",
|
|
1139
1147
|
"extensions/ui-refresh/index.ts",
|
|
@@ -1937,6 +1945,29 @@ async function doctor() {
|
|
|
1937
1945
|
}
|
|
1938
1946
|
}
|
|
1939
1947
|
|
|
1948
|
+
const backgroundIntegrity = backgroundFiles.every((name) => {
|
|
1949
|
+
const target = path.join(backgroundRoot, name);
|
|
1950
|
+
const record = manifest.files?.[target];
|
|
1951
|
+
|
|
1952
|
+
return record && fs.existsSync(target) && sha256(fs.readFileSync(target)) === record.installedHash;
|
|
1953
|
+
});
|
|
1954
|
+
if (!backgroundIntegrity || !commandGuardIntegrity) {
|
|
1955
|
+
errors.push("Background task smoke skipped: installed background/Guard checksum integrity failed.");
|
|
1956
|
+
} else {
|
|
1957
|
+
const smoke = spawnSync(process.execPath, [path.join(backgroundRoot, "smoke.mjs")], {
|
|
1958
|
+
cwd: agentDir,
|
|
1959
|
+
encoding: "utf8",
|
|
1960
|
+
timeout: 30000,
|
|
1961
|
+
maxBuffer: 32768,
|
|
1962
|
+
env: { ...process.env, NODE_OPTIONS: "", NODE_PATH: "" },
|
|
1963
|
+
});
|
|
1964
|
+
if (smoke.status === 0 && smoke.stdout.includes("BACKGROUND_TASKS_SMOKE=passed")) {
|
|
1965
|
+
console.log("BACKGROUND_TASKS_SMOKE=passed");
|
|
1966
|
+
} else {
|
|
1967
|
+
errors.push("Installed background task smoke failed or timed out.");
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1940
1971
|
const runtimeStatus = browserRuntimeStatus();
|
|
1941
1972
|
let browserSmoke;
|
|
1942
1973
|
if (!runtimeStatus.installed) {
|