pi-session-mcp 0.12.1 → 0.13.1
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/.pi-session-mcp-cli/client-adapters/mcp-verifier.js +2 -1
- package/.pi-session-mcp-cli/config.js +36 -3
- package/.pi-session-mcp-cli/doctor.js +52 -4
- package/.pi-session-mcp-cli/execution-modes.js +10 -2
- package/.pi-session-mcp-cli/execution-profile.js +1 -0
- package/.pi-session-mcp-cli/sandbox-shell.js +254 -0
- package/.pi-session-mcp-cli/session-tools.js +6 -2
- package/.pi-session-mcp-cli/writer-boundary.js +27 -0
- package/.pi-session-mcp-cli/writer-lifecycle.js +6 -4
- package/.pi-session-mcp-cli/writer-limits.js +19 -0
- package/.pi-session-mcp-cli/writer-state.js +5 -2
- package/CHANGELOG.md +147 -0
- package/CONTRIBUTING.md +8 -1
- package/README.md +5 -2
- package/dist/capabilities.d.ts +1 -0
- package/dist/capabilities.js +3 -1
- package/dist/client-adapters/mcp-verifier.js +2 -1
- package/dist/config.d.ts +12 -1
- package/dist/config.js +36 -3
- package/dist/confined-tools.d.ts +30 -0
- package/dist/confined-tools.js +122 -0
- package/dist/doctor.d.ts +5 -1
- package/dist/doctor.js +52 -4
- package/dist/execution-modes.d.ts +13 -1
- package/dist/execution-modes.js +10 -2
- package/dist/execution-profile.d.ts +4 -0
- package/dist/execution-profile.js +1 -0
- package/dist/main.js +10 -2
- package/dist/pi-adapter.d.ts +1 -1
- package/dist/pi-adapter.js +1 -0
- package/dist/sandbox-shell.d.ts +71 -0
- package/dist/sandbox-shell.js +254 -0
- package/dist/sdk-pi-adapter.d.ts +17 -1
- package/dist/sdk-pi-adapter.js +65 -10
- package/dist/session-tools.d.ts +4 -2
- package/dist/session-tools.js +6 -2
- package/dist/setup-cli.d.ts +1 -2
- package/dist/setup-cli.js +40 -9
- package/dist/setup-modes.d.ts +3 -0
- package/dist/setup-modes.js +18 -1
- package/dist/setup-writers-common.d.ts +78 -0
- package/dist/setup-writers-common.js +177 -0
- package/dist/setup-writers-plan.d.ts +34 -0
- package/dist/setup-writers-plan.js +105 -0
- package/dist/setup-writers-reconcile.d.ts +8 -0
- package/dist/setup-writers-reconcile.js +169 -0
- package/dist/setup-writers.d.ts +6 -30
- package/dist/setup-writers.js +19 -353
- package/dist/tool-schemas.d.ts +4 -0
- package/dist/tool-schemas.js +1 -0
- package/dist/wake-hook.d.ts +11 -0
- package/dist/wake-hook.js +51 -8
- package/dist/writer-boundary.d.ts +2 -0
- package/dist/writer-boundary.js +27 -0
- package/dist/writer-lifecycle.d.ts +1 -0
- package/dist/writer-lifecycle.js +6 -4
- package/dist/writer-limits.d.ts +13 -0
- package/dist/writer-limits.js +19 -0
- package/dist/writer-relay.js +1 -1
- package/dist/writer-state.d.ts +11 -0
- package/dist/writer-state.js +5 -2
- package/docs/acceptance.md +271 -0
- package/docs/adr/0009-execution-modes.md +3 -2
- package/docs/adr/0012-leased-writer-pool.md +3 -3
- package/docs/adr/0013-hardened-read-only-profiles.md +184 -0
- package/docs/architecture.md +1 -1
- package/docs/client-setup.md +3 -3
- package/docs/doctor-design.md +3 -1
- package/docs/execution-modes.md +60 -4
- package/docs/installation.md +25 -1
- package/docs/isolation-setup.md +31 -2
- package/docs/roadmap.md +14 -0
- package/docs/session-mcp.md +15 -0
- package/docs/threat-model.md +51 -0
- package/docs/tool-contracts.md +33 -4
- package/package.json +1 -1
- package/skills/pi-session-orchestration/SKILL.md +2 -2
- package/skills/pi-session-orchestration/references/start-errors.md +1 -1
|
@@ -5,6 +5,7 @@ import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises";
|
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { PACKAGE_VERSION } from "./package-metadata.js";
|
|
8
|
+
import { writerLimitsSchema } from "./writer-limits.js";
|
|
8
9
|
import { ALIAS_PATTERN } from "./execution-profile.js";
|
|
9
10
|
export const WRITER_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,63}-w([1-9]|1[0-6])$/;
|
|
10
11
|
export class WriterStateError extends Error {
|
|
@@ -31,8 +32,10 @@ export const writerStateSchema = z.object({
|
|
|
31
32
|
model: z.string().min(1).max(200),
|
|
32
33
|
}).strict()).refine((providers) => new Set(providers.map(({ provider }) => provider)).size === providers.length).readonly(),
|
|
33
34
|
network: z.enum(["pasta", "none"]),
|
|
34
|
-
cache: z.enum(["none", "go", "uv"]),
|
|
35
|
+
cache: z.enum(["none", "go", "uv", "npm"]),
|
|
35
36
|
tags: z.enum(["none", "reachable"]).default("none"),
|
|
37
|
+
clean: z.enum(["untracked", "all"]).default("untracked"),
|
|
38
|
+
limits: writerLimitsSchema.optional(),
|
|
36
39
|
imageId: hex64,
|
|
37
40
|
base: z.object({ ref: z.string(), commit: z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/) }).strict(),
|
|
38
41
|
acceptance: z.object({
|
|
@@ -62,7 +65,7 @@ export function writerPaths(home, name) {
|
|
|
62
65
|
const root = join(writersRoot(home), name);
|
|
63
66
|
return {
|
|
64
67
|
root, clone: join(root, "clone"), agent: join(root, "agent"), bundles: join(root, "bundles"),
|
|
65
|
-
config: join(root, "config"), events: join(root, "events"), go: join(root, "go"), uv: join(root, "uv"), wrapper: join(root, "wrapper.sh"), state: join(root, "state.json"),
|
|
68
|
+
config: join(root, "config"), events: join(root, "events"), go: join(root, "go"), uv: join(root, "uv"), npm: join(root, "npm"), wrapper: join(root, "wrapper.sh"), state: join(root, "state.json"),
|
|
66
69
|
acceptanceFailure: join(root, "acceptance-failure.json"),
|
|
67
70
|
};
|
|
68
71
|
}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,153 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.13.1 — 2026-10-29
|
|
6
|
+
|
|
7
|
+
Patch release under ADR 0003 with an unchanged MCP contract. It ships the
|
|
8
|
+
fixes after 0.13.0: Doctor names where the configuration schema rejects a
|
|
9
|
+
file ([#173](https://github.com/plagor44-ux/pi-session-mcp/issues/173)),
|
|
10
|
+
read-only confinement of Pi's agent directory
|
|
11
|
+
([#177](https://github.com/plagor44-ux/pi-session-mcp/issues/177)), a writer
|
|
12
|
+
added without `--base` ([#176](https://github.com/plagor44-ux/pi-session-mcp/issues/176)),
|
|
13
|
+
stale waiter-lock reclamation in the wake hook
|
|
14
|
+
([#179](https://github.com/plagor44-ux/pi-session-mcp/issues/179)) and
|
|
15
|
+
per-writer reconcile failures
|
|
16
|
+
([#180](https://github.com/plagor44-ux/pi-session-mcp/issues/180)). Setup
|
|
17
|
+
reports change for a broken kept writer: it now carries its own code on its
|
|
18
|
+
target instead of `writer_reconcile_failed`. `npm audit` still reports
|
|
19
|
+
`undici` 8.10.2, pinned by Pi SDK 1.4.0, the latest SDK release, now with
|
|
20
|
+
eight advisories ([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
|
|
21
|
+
|
|
22
|
+
Upgrading follows [Installation](docs/installation.md#upgrade); writers must be
|
|
23
|
+
accepted again after the upgrade, as after every release.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Doctor's `config_invalid` names where the schema rejects the configuration:
|
|
28
|
+
one check per issue location (at most five) with the subject
|
|
29
|
+
`configuration:<location>`, for example
|
|
30
|
+
`configuration:executionProfiles.review.default`. Locations hold only schema
|
|
31
|
+
field names, aliases and array indexes; an unknown key that is not a schema
|
|
32
|
+
field name, and any other key that is not a valid alias, is shown as
|
|
33
|
+
`<key>`, and values and messages are never reported. An error without a
|
|
34
|
+
location keeps the subject `configuration`
|
|
35
|
+
([#173](https://github.com/plagor44-ux/pi-session-mcp/issues/173)).
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- Issue forms for bug reports, change proposals and maintenance tasks, and a
|
|
40
|
+
pull request template, written so that a person or a coding agent can carry
|
|
41
|
+
an issue out and a reviewer can check it
|
|
42
|
+
([#178](https://github.com/plagor44-ux/pi-session-mcp/issues/178)).
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- Security, in the lockfile only, development path: `@hono/node-server`
|
|
47
|
+
2.1.1 → 2.1.5 (via MCP Inspector; GHSA-rmxm-3fg6-px4f). Still open:
|
|
48
|
+
`undici` 8.10.2 in the production path, pinned exactly by Pi SDK 1.4.0, now
|
|
49
|
+
with eight advisories (GHSA-4qv8-2p9j-2685, GHSA-f72v-6w45-q472,
|
|
50
|
+
GHSA-xjj6-f33q-929c, GHSA-4w7w-5xrc-9h26, GHSA-2m5v-pch6-74hx,
|
|
51
|
+
GHSA-qrxm-fqhp-jwvm, GHSA-9fm3-97px-rjf6, GHSA-55wc-3pg8-jfw6), so
|
|
52
|
+
`npm audit` still reports it
|
|
53
|
+
([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
|
|
54
|
+
- Writer reconciliation reports a kept writer that fails, for example with
|
|
55
|
+
a missing wrapper after a crash during provisioning, on that writer's
|
|
56
|
+
target with its own code (`writer_state_invalid`) and still reconciles the
|
|
57
|
+
other writers. Only failures of shared setup state (configuration,
|
|
58
|
+
ownership) end the whole step with `writer_reconcile_failed`
|
|
59
|
+
([#180](https://github.com/plagor44-ux/pi-session-mcp/issues/180)).
|
|
60
|
+
- Wake hook: reclaiming a stale waiter lock is serialized by an exclusive
|
|
61
|
+
`<lock>.reclaim` guard, and the lock is judged again while holding it. Two
|
|
62
|
+
waiters that both saw the same stale lock can no longer both take the turn
|
|
63
|
+
and send a duplicate wake reminder
|
|
64
|
+
([#179](https://github.com/plagor44-ux/pi-session-mcp/issues/179)).
|
|
65
|
+
- Read-only confinement protects Pi's agent directory (`~/.pi/agent`, or
|
|
66
|
+
`PI_CODING_AGENT_DIR`) and, for a symlinked configuration file, its target's
|
|
67
|
+
directory: a skill directory that equals or contains either contributes only
|
|
68
|
+
the skill file as a read root. Before, a skill file placed directly in the
|
|
69
|
+
agent directory made `auth.json` readable. The real file of a symlinked skill
|
|
70
|
+
file is now a read root too, so the model can read a skill it was given
|
|
71
|
+
([#177](https://github.com/plagor44-ux/pi-session-mcp/issues/177)).
|
|
72
|
+
- Setup provisions a writer added without `--base` (for example by raising
|
|
73
|
+
`--writers`) on the workspace's recorded default base, as finish and recreate
|
|
74
|
+
already did. Before, it landed on `origin/HEAD` and default starts never
|
|
75
|
+
leased it. A recorded base that is the repository default still provisions
|
|
76
|
+
from `origin/HEAD` ([#176](https://github.com/plagor44-ux/pi-session-mcp/issues/176)).
|
|
77
|
+
|
|
78
|
+
## 0.13.0 — 2026-10-28
|
|
79
|
+
|
|
80
|
+
Minor release under ADR 0003 for
|
|
81
|
+
[ADR 0013](docs/adr/0013-hardened-read-only-profiles.md)
|
|
82
|
+
([#169](https://github.com/plagor44-ux/pi-session-mcp/issues/169)): hardened
|
|
83
|
+
read-only profiles. The MCP contract changes: read-only tools are confined to
|
|
84
|
+
the workspace and the selected skills' directories, profiles gain the optional
|
|
85
|
+
`shell` field, capabilities report `shell`, and `pi_session_start` has the new
|
|
86
|
+
error code `sandbox_unavailable`. It also ships the writer settings changes of
|
|
87
|
+
[#162](https://github.com/plagor44-ux/pi-session-mcp/issues/162),
|
|
88
|
+
[#163](https://github.com/plagor44-ux/pi-session-mcp/issues/163),
|
|
89
|
+
[#165](https://github.com/plagor44-ux/pi-session-mcp/issues/165) and
|
|
90
|
+
[#166](https://github.com/plagor44-ux/pi-session-mcp/issues/166). `npm audit`
|
|
91
|
+
still reports `undici` 8.10.2, pinned by Pi SDK 1.4.0, the latest SDK release
|
|
92
|
+
([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
|
|
93
|
+
|
|
94
|
+
Upgrading follows [Installation](docs/installation.md#upgrade); writers must be
|
|
95
|
+
accepted again after the upgrade, as after every release.
|
|
96
|
+
|
|
97
|
+
### Changed
|
|
98
|
+
|
|
99
|
+
- **Breaking for read-only workflows:** `read`, `grep`, `find` and `ls` of every
|
|
100
|
+
read-only profile now accept only paths inside the session's workspace and the
|
|
101
|
+
directories of the profile's selected skill files. An absolute path, `..`, `~`
|
|
102
|
+
or a symlink leading outside is refused with the tool error `Path is outside
|
|
103
|
+
the allowed read roots`. Read-only workers that read files outside the
|
|
104
|
+
workspace, Pi credentials included, can no longer do so. Coding profiles are
|
|
105
|
+
unchanged ([ADR 0013](docs/adr/0013-hardened-read-only-profiles.md),
|
|
106
|
+
[#169](https://github.com/plagor44-ux/pi-session-mcp/issues/169)).
|
|
107
|
+
|
|
108
|
+
### Added
|
|
109
|
+
|
|
110
|
+
- Optional profile field `"shell": "sandboxed"` for read-only profiles: a `bash`
|
|
111
|
+
tool whose commands run in a bubblewrap sandbox without network and with the
|
|
112
|
+
workspace as a temporary overlay, so review workers can run `git log`,
|
|
113
|
+
`git blame`, tests and scripts without touching the host tree. Linux only,
|
|
114
|
+
not together with `grantedExtensionTools`. New start error
|
|
115
|
+
`sandbox_unavailable` (class `needs_operator`), new capabilities field
|
|
116
|
+
`shell` (`none` or `sandboxed`) after `grantedExtensionTools`, and the Doctor
|
|
117
|
+
checks `sandbox_unavailable` (error, probed for each configured workspace) and
|
|
118
|
+
`workspace_git_unavailable` (warning). The sandbox's `/tmp` is limited to
|
|
119
|
+
512 MiB, commands default to a 600 second timeout and are killed with all
|
|
120
|
+
their processes on timeout or abort; the start probe is cancelled with the
|
|
121
|
+
start request. A skill directory that equals or contains the operator's home,
|
|
122
|
+
the configuration directory or `~/.pi` is no longer a read root, only its
|
|
123
|
+
skill file is
|
|
124
|
+
([#169](https://github.com/plagor44-ux/pi-session-mcp/issues/169)).
|
|
125
|
+
|
|
126
|
+
- Optional container resource settings `--cpus`, `--cpu-shares` and `--memory`,
|
|
127
|
+
with host cgroup-controller preflight and fenced in-place writer updates
|
|
128
|
+
without reprovisioning. A later mode request that omits a limit keeps it;
|
|
129
|
+
`none` removes it
|
|
130
|
+
([#166](https://github.com/plagor44-ux/pi-session-mcp/issues/166)).
|
|
131
|
+
|
|
132
|
+
- `--cache npm` mounts a private per-writer npm cache at `/pi/npm` with
|
|
133
|
+
`npm_config_cache=/pi/npm/cache`, retained across reset, finish and recreate
|
|
134
|
+
and removed with the writer
|
|
135
|
+
([#165](https://github.com/plagor44-ux/pi-session-mcp/issues/165)).
|
|
136
|
+
|
|
137
|
+
- Opt-in container setting `--clean all` removes ignored files from writer
|
|
138
|
+
clones on reset and finish while preserving the default dependency-cache
|
|
139
|
+
behavior and per-writer caches outside the clone
|
|
140
|
+
([#162](https://github.com/plagor44-ux/pi-session-mcp/issues/162)).
|
|
141
|
+
|
|
142
|
+
### Fixed
|
|
143
|
+
|
|
144
|
+
- An apply of `pi-session-mcp-setup --repo <alias> --mode container ...` with
|
|
145
|
+
drifted settings is now refused before the mode is written: each kept writer
|
|
146
|
+
with a difference reports `writer_settings_changed`, the modes file stays
|
|
147
|
+
unchanged and no kept writer is changed (a surplus writer of a smaller count
|
|
148
|
+
may already have been fenced), so the recorded mode never drifts from
|
|
149
|
+
what the writers run
|
|
150
|
+
([#163](https://github.com/plagor44-ux/pi-session-mcp/issues/163)).
|
|
151
|
+
|
|
5
152
|
## 0.12.1 — 2026-10-28
|
|
6
153
|
|
|
7
154
|
Patch release for
|
package/CONTRIBUTING.md
CHANGED
|
@@ -42,7 +42,14 @@ ones:
|
|
|
42
42
|
Pull requests are limited to collaborators. If you are not a collaborator and
|
|
43
43
|
want to contribute a fix, open an issue that describes it.
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
Issues use the forms in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/):
|
|
46
|
+
bug report, change proposal or maintenance task. Each asks for acceptance
|
|
47
|
+
criteria and a way to verify them, so that a person or a coding agent can carry
|
|
48
|
+
the issue out and a reviewer can check it. Pull requests use
|
|
49
|
+
[`.github/pull_request_template.md`](.github/pull_request_template.md).
|
|
50
|
+
|
|
51
|
+
- Keep a pull request to one focused change, and name every issue it addresses
|
|
52
|
+
with its own closing keyword (`Closes #12. Closes #13.`).
|
|
46
53
|
- Run the checks above and `npm audit` before opening it.
|
|
47
54
|
- Describe what you verified and how. State only what was run: provider-free
|
|
48
55
|
tests do not establish live client or model behavior.
|
package/README.md
CHANGED
|
@@ -30,10 +30,12 @@ It is not the right tool when:
|
|
|
30
30
|
|
|
31
31
|
## Status
|
|
32
32
|
|
|
33
|
-
The package version is **0.
|
|
33
|
+
The package version is **0.13.1**.
|
|
34
34
|
|
|
35
35
|
[Per-repository execution modes](docs/execution-modes.md) make host writes explicit and block coding on read-only aliases. On container workspaces, the one host `pi-session-mcp` registration leases a rootless-Podman writer per coding session on Linux; no per-writer registrations or tool prefixes are needed ([ADR 0012](docs/adr/0012-leased-writer-pool.md)). Setup provisions, accepts, collects and resets the pool. Starts normally use the workspace default base; fix rounds can pass a short branch `base`. Start/get report `writer` and `base` for collection, and capabilities report configured `writers` and `defaultBase`. Reset, finish and recreate need no reconnect; finish returns the writer to the default base. See the [container upgrade runbook](docs/installation.md#container-writers).
|
|
36
36
|
|
|
37
|
+
Every read-only profile reads only inside its workspace and the directories of its selected skills, and a read-only profile on Linux can opt into a bubblewrap shell without network, home or host writes (`shell: "sandboxed"`, [ADR 0013](docs/adr/0013-hardened-read-only-profiles.md)).
|
|
38
|
+
|
|
37
39
|
- **Pre-1.0.** The public tool contract is stable within a minor line: a change to tools, fields, states or error codes needs a new minor release.
|
|
38
40
|
- **Linux and macOS**, Node.js 22.19.0 or newer. macOS is tested in CI on macOS 15 and 26 on Apple silicon; Intel Macs run the same code but are untested. Setup acceptance on a real Mac is recorded for Claude Code only; Codex is still pending. See [Acceptance](docs/acceptance.md#status). On other platforms `setup` refuses with `platform_unsupported`.
|
|
39
41
|
- **Clients:** Codex CLI and Claude Code. The `setup` command does not pin client versions: it accepts a client whose public `mcp` command output matches the expected contract and fails closed otherwise.
|
|
@@ -113,7 +115,7 @@ The [installation guide](docs/installation.md) also covers installing from a rel
|
|
|
113
115
|
```
|
|
114
116
|
|
|
115
117
|
- A workspace alias maps to a directory. Clients can select aliases only.
|
|
116
|
-
- An execution profile binds a permission profile (`read-only` or `coding`), a provider, a model and a thinking level. Exactly one profile is the default, and it must be `read-only`.
|
|
118
|
+
- An execution profile binds a permission profile (`read-only` or `coding`), a provider, a model and a thinking level. Exactly one profile is the default, and it must be `read-only`. A read-only profile can add `shell: "sandboxed"` on Linux.
|
|
117
119
|
- A profile can grant individual tools of local stdio MCP servers to its sessions; see [External MCP tools in embedded sessions](docs/session-mcp.md).
|
|
118
120
|
- An optional top-level `skillCatalog` maps skill aliases to `builtin:<name>` (a skill of the package or of your override directory) or to a `SKILL.md` file, and a profile's optional `skills` selects aliases from it. A profile without `skills` gets no skills. See [Pi skills](docs/skills.md).
|
|
119
121
|
- An optional top-level `"events": { "delivery": "spool" }` turns on content-free turn events, so that a Claude Code hook can wake the controller when a turn ends instead of polling. It is off by default and changes nothing for Codex. See [Wake the controller](docs/installation.md#wake-the-controller-claude-code).
|
|
@@ -174,6 +176,7 @@ Contract and design:
|
|
|
174
176
|
- [ADR 0007: execution profiles select Pi skills from an operator catalog](docs/adr/0007-profile-skill-selection.md)
|
|
175
177
|
- [ADR 0011: turn events and controller wake](docs/adr/0011-turn-events-and-controller-wake.md)
|
|
176
178
|
- [ADR 0012: one registration with a leased writer pool](docs/adr/0012-leased-writer-pool.md)
|
|
179
|
+
- [ADR 0013: hardened read-only profiles](docs/adr/0013-hardened-read-only-profiles.md)
|
|
177
180
|
|
|
178
181
|
Maintain:
|
|
179
182
|
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -30,6 +30,7 @@ export declare function projectCapabilities(workspaces: ReadonlyMap<string, stri
|
|
|
30
30
|
thinkingLevel: import("./execution-profile.js").ThinkingLevel;
|
|
31
31
|
extensions: string[];
|
|
32
32
|
grantedExtensionTools: string[];
|
|
33
|
+
shell: "none" | "sandboxed";
|
|
33
34
|
skills: string[];
|
|
34
35
|
}[];
|
|
35
36
|
};
|
package/dist/capabilities.js
CHANGED
|
@@ -18,7 +18,7 @@ export function projectCapabilities(workspaces, executionProfiles, configuration
|
|
|
18
18
|
}),
|
|
19
19
|
executionProfiles: [...executionProfiles.values()]
|
|
20
20
|
.sort((left, right) => compareAliases(left.alias, right.alias))
|
|
21
|
-
.map(({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel, extensions, grantedExtensionTools, skills }) => ({
|
|
21
|
+
.map(({ alias, default: isDefault, permissionProfile, provider, model, thinkingLevel, extensions, grantedExtensionTools, shell, skills }) => ({
|
|
22
22
|
alias,
|
|
23
23
|
default: isDefault,
|
|
24
24
|
permissionProfile,
|
|
@@ -27,6 +27,8 @@ export function projectCapabilities(workspaces, executionProfiles, configuration
|
|
|
27
27
|
thinkingLevel,
|
|
28
28
|
extensions: (extensions ?? []).map((extension) => extension.spec),
|
|
29
29
|
grantedExtensionTools: [...(grantedExtensionTools ?? [])],
|
|
30
|
+
// Configured intent only: whether the server's probe succeeds is a start-time fact.
|
|
31
|
+
shell: shell ?? "none",
|
|
30
32
|
skills: (skills ?? []).map((skill) => skill.alias),
|
|
31
33
|
})),
|
|
32
34
|
};
|
|
@@ -118,7 +118,7 @@ function validCapabilities(value) {
|
|
|
118
118
|
if (!profile || typeof profile !== "object")
|
|
119
119
|
return false;
|
|
120
120
|
const record = profile;
|
|
121
|
-
return exactKeys(record, ["alias", "default", "permissionProfile", "provider", "model", "thinkingLevel", "extensions", "grantedExtensionTools", "skills"])
|
|
121
|
+
return exactKeys(record, ["alias", "default", "permissionProfile", "provider", "model", "thinkingLevel", "extensions", "grantedExtensionTools", "shell", "skills"])
|
|
122
122
|
&& typeof record.alias === "string" && ALIAS_PATTERN.test(record.alias)
|
|
123
123
|
&& typeof record.default === "boolean"
|
|
124
124
|
&& (record.permissionProfile === "read-only" || record.permissionProfile === "coding")
|
|
@@ -130,6 +130,7 @@ function validCapabilities(value) {
|
|
|
130
130
|
&& Array.isArray(record.grantedExtensionTools) && record.grantedExtensionTools.length <= MAX_GRANTED_EXTENSION_TOOLS
|
|
131
131
|
&& record.grantedExtensionTools.every((name) => typeof name === "string" && isAcceptableExtensionToolName(name))
|
|
132
132
|
&& new Set(record.grantedExtensionTools).size === record.grantedExtensionTools.length
|
|
133
|
+
&& (record.shell === "none" || record.shell === "sandboxed")
|
|
133
134
|
&& Array.isArray(record.skills) && record.skills.length <= MAX_PROFILE_SKILLS
|
|
134
135
|
&& record.skills.every((skill) => typeof skill === "string" && ALIAS_PATTERN.test(skill))
|
|
135
136
|
&& new Set(record.skills).size === record.skills.length;
|
package/dist/config.d.ts
CHANGED
|
@@ -4,8 +4,18 @@ import { type WorkspaceModeEntry } from "./execution-modes.js";
|
|
|
4
4
|
export type ConfigErrorCode = "config_path_required" | "invalid_execution_profile_config" | "invalid_thinking_level" | "invalid_execution_modes" | "invalid_event_delivery";
|
|
5
5
|
export declare class ConfigError extends Error {
|
|
6
6
|
readonly code: ConfigErrorCode;
|
|
7
|
-
|
|
7
|
+
/** Dotted schema issue locations for Doctor; built by `issueLocations`, never a value, message or path. */
|
|
8
|
+
readonly locations: readonly string[];
|
|
9
|
+
constructor(code: ConfigErrorCode, locations?: readonly string[]);
|
|
8
10
|
}
|
|
11
|
+
export declare const MAX_CONFIG_ISSUE_LOCATIONS = 5;
|
|
12
|
+
/**
|
|
13
|
+
* Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
|
|
14
|
+
* A segment is kept only if it is a schema field name, or, inside the path, a key that has the alias
|
|
15
|
+
* form (record keys are aliases, which Doctor already reports). Any other key, and every unrecognized
|
|
16
|
+
* key that is not a schema field name, could be a path or a secret and becomes `<key>`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function issueLocations(issues: readonly z.core.$ZodIssue[]): string[];
|
|
9
19
|
export declare const configSchema: z.ZodObject<{
|
|
10
20
|
events: z.ZodOptional<z.ZodObject<{
|
|
11
21
|
delivery: z.ZodEnum<{
|
|
@@ -35,6 +45,7 @@ export declare const configSchema: z.ZodObject<{
|
|
|
35
45
|
mcpServers: z.ZodOptional<z.ZodCustom<Readonly<Record<string, import("./mcp-config.js").McpServerConfig>>, Readonly<Record<string, import("./mcp-config.js").McpServerConfig>>>>;
|
|
36
46
|
extensions: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
37
47
|
grantedExtensionTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
48
|
+
shell: z.ZodOptional<z.ZodLiteral<"sandboxed">>;
|
|
38
49
|
skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
39
50
|
}, z.core.$strict>>;
|
|
40
51
|
}, z.core.$strict>;
|
package/dist/config.js
CHANGED
|
@@ -20,6 +20,7 @@ const profileSchema = z.object({
|
|
|
20
20
|
mcpServers: z.custom((value) => isMcpServers(value)).optional(),
|
|
21
21
|
extensions: z.array(extensionSpec).max(MAX_PROFILE_EXTENSIONS).optional(),
|
|
22
22
|
grantedExtensionTools: z.array(z.string().refine(isAcceptableExtensionToolName)).max(MAX_GRANTED_EXTENSION_TOOLS).optional(),
|
|
23
|
+
shell: z.literal("sandboxed").optional(),
|
|
23
24
|
skills: z.array(alias).max(MAX_PROFILE_SKILLS).optional(),
|
|
24
25
|
}).strict().superRefine((profile, context) => {
|
|
25
26
|
if (profile.mcpServers !== undefined && !isMcpServers(profile.mcpServers, profile.permissionProfile)) {
|
|
@@ -46,6 +47,18 @@ const profileSchema = z.object({
|
|
|
46
47
|
context.addIssue({ code: "custom", path: ["grantedExtensionTools"], message: "Extension tool grants must not name built-in tools" });
|
|
47
48
|
}
|
|
48
49
|
}
|
|
50
|
+
if (profile.shell !== undefined) {
|
|
51
|
+
// Granted extension tools may write, so the sandbox's read-only guarantee would not hold (ADR 0013).
|
|
52
|
+
if (profile.permissionProfile !== "read-only") {
|
|
53
|
+
context.addIssue({ code: "custom", path: ["shell"], message: "A sandboxed shell requires a read-only profile" });
|
|
54
|
+
}
|
|
55
|
+
if (profile.grantedExtensionTools !== undefined) {
|
|
56
|
+
context.addIssue({ code: "custom", path: ["shell"], message: "A sandboxed shell cannot be combined with granted extension tools" });
|
|
57
|
+
}
|
|
58
|
+
if (process.platform !== "linux") {
|
|
59
|
+
context.addIssue({ code: "custom", path: ["shell"], message: "A sandboxed shell requires Linux" });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
49
62
|
if (profile.skills !== undefined && new Set(profile.skills).size !== profile.skills.length) {
|
|
50
63
|
context.addIssue({ code: "custom", path: ["skills"], message: "A skill alias may appear only once per profile" });
|
|
51
64
|
}
|
|
@@ -59,12 +72,31 @@ const CONFIG_ERROR_MESSAGES = {
|
|
|
59
72
|
};
|
|
60
73
|
export class ConfigError extends Error {
|
|
61
74
|
code;
|
|
62
|
-
|
|
75
|
+
/** Dotted schema issue locations for Doctor; built by `issueLocations`, never a value, message or path. */
|
|
76
|
+
locations;
|
|
77
|
+
constructor(code, locations = []) {
|
|
63
78
|
super(CONFIG_ERROR_MESSAGES[code]);
|
|
64
79
|
this.name = "ConfigError";
|
|
65
80
|
this.code = code;
|
|
81
|
+
this.locations = Object.freeze([...locations]);
|
|
66
82
|
}
|
|
67
83
|
}
|
|
84
|
+
export const MAX_CONFIG_ISSUE_LOCATIONS = 5;
|
|
85
|
+
/**
|
|
86
|
+
* Sorted, deduplicated and bounded issue locations; an issue at the root has the empty location.
|
|
87
|
+
* A segment is kept only if it is a schema field name, or, inside the path, a key that has the alias
|
|
88
|
+
* form (record keys are aliases, which Doctor already reports). Any other key, and every unrecognized
|
|
89
|
+
* key that is not a schema field name, could be a path or a secret and becomes `<key>`.
|
|
90
|
+
*/
|
|
91
|
+
export function issueLocations(issues) {
|
|
92
|
+
const fields = new Set([...Object.keys(configSchema.shape), ...Object.keys(profileSchema.shape), ...Object.keys(eventsSchema.shape)]);
|
|
93
|
+
const segment = (value, aliasAllowed) => typeof value === "number" ? String(value) : typeof value === "string" && (fields.has(value) || (aliasAllowed && ALIAS_PATTERN.test(value))) ? value : "<key>";
|
|
94
|
+
const locations = issues.flatMap((issue) => {
|
|
95
|
+
const path = issue.path.map((value) => segment(value, true));
|
|
96
|
+
return issue.code === "unrecognized_keys" ? issue.keys.map((key) => [...path, segment(key, false)]) : [path];
|
|
97
|
+
});
|
|
98
|
+
return [...new Set(locations.map((path) => path.join(".")))].sort().slice(0, MAX_CONFIG_ISSUE_LOCATIONS);
|
|
99
|
+
}
|
|
68
100
|
const eventsSchema = z.object({ delivery: z.enum(["spool", "off"]) }).strict();
|
|
69
101
|
export const configSchema = z.object({
|
|
70
102
|
events: eventsSchema.optional(),
|
|
@@ -138,7 +170,7 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
|
|
|
138
170
|
const thinkingLevelIssue = error.issues.some((issue) => issue.path.at(-1) === "thinkingLevel");
|
|
139
171
|
// Only the events field's own issues map to the delivery code; an unknown top-level key stays a profile-config error.
|
|
140
172
|
const eventsIssue = error.issues.some((issue) => issue.path[0] === "events");
|
|
141
|
-
throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config");
|
|
173
|
+
throw new ConfigError(thinkingLevelIssue ? "invalid_thinking_level" : eventsIssue ? "invalid_event_delivery" : "invalid_execution_profile_config", issueLocations(error.issues));
|
|
142
174
|
}
|
|
143
175
|
throw error;
|
|
144
176
|
}
|
|
@@ -146,13 +178,14 @@ export async function loadConfig(path = process.env.PI_SESSION_MCP_CONFIG) {
|
|
|
146
178
|
const overrideDirectory = skillOverrideDirectory(absoluteConfig);
|
|
147
179
|
const workspaces = new ImmutableMap(Object.entries(parsed.workspaces).map(([key, value]) => [key, resolve(base, value)]));
|
|
148
180
|
const catalog = parsed.skillCatalog ?? {};
|
|
149
|
-
const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, skills, ...value }]) => {
|
|
181
|
+
const executionProfiles = new ImmutableMap(Object.entries(parsed.executionProfiles).map(([key, { mcpServers, extensions, grantedExtensionTools, shell, skills, ...value }]) => {
|
|
150
182
|
const selection = resolveSkillSelection(skills, catalog, base, overrideDirectory);
|
|
151
183
|
return [key, Object.freeze({
|
|
152
184
|
alias: key, ...value,
|
|
153
185
|
...(mcpServers === undefined ? {} : { mcpServers: snapshotMcpServers(mcpServers) }),
|
|
154
186
|
...(extensions === undefined ? {} : { extensions: Object.freeze(extensions.map((spec) => parseExtensionSpec(spec))) }),
|
|
155
187
|
...(grantedExtensionTools === undefined ? {} : { grantedExtensionTools: Object.freeze([...grantedExtensionTools]) }),
|
|
188
|
+
...(shell === undefined ? {} : { shell }),
|
|
156
189
|
...(selection === undefined ? {} : { skills: selection }),
|
|
157
190
|
})];
|
|
158
191
|
}));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type GrepOperations, type LsOperations, type ReadOperations, type Skill, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
/** Fixed refusal text; it never names the refused path. */
|
|
3
|
+
export declare const OUTSIDE_ROOTS_MESSAGE = "Path is outside the allowed read roots";
|
|
4
|
+
/** The read-only built-ins whose definitions this server replaces with confined ones. */
|
|
5
|
+
export declare const CONFINED_TOOL_NAMES: readonly string[];
|
|
6
|
+
/**
|
|
7
|
+
* The allowed read roots: the workspace and the directory of each selected skill file, as real paths.
|
|
8
|
+
* A skill directory that equals or contains a protected path (the operator's home, the configuration
|
|
9
|
+
* directory, the Pi agent directory) would expose it, so only the skill file itself is a root then.
|
|
10
|
+
* A symlinked skill file resolves outside its directory, so its real file is a root as well.
|
|
11
|
+
*/
|
|
12
|
+
export declare function allowedReadRoots(workspace: string, skills: readonly Skill[], protectedPaths?: readonly string[]): Promise<string[]>;
|
|
13
|
+
/**
|
|
14
|
+
* Resolves a tool `path` argument the way the SDK will and checks the canonical result against the
|
|
15
|
+
* roots. Arguments the SDK would rewrite before resolving are refused, so both resolve the same path.
|
|
16
|
+
* Returns the canonical path; a refusal throws a fixed message without the path.
|
|
17
|
+
*/
|
|
18
|
+
export declare function confineToolPath(input: unknown, cwd: string, roots: readonly string[]): Promise<string>;
|
|
19
|
+
/** The `*Operations` hooks that re-check every absolute path the SDK hands them; exported for tests. */
|
|
20
|
+
export declare function confinedOperations(roots: readonly string[]): {
|
|
21
|
+
read: ReadOperations;
|
|
22
|
+
grep: GrepOperations;
|
|
23
|
+
ls: LsOperations;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Definitions named `read`, `grep`, `find` and `ls` that accept only paths below `roots`. Registered as
|
|
27
|
+
* SDK custom tools they replace the built-ins of the same name. `find` has no operations hook that
|
|
28
|
+
* would still cover `fd`, so it relies on the execute wrapper alone.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createConfinedReadOnlyTools(cwd: string, roots: readonly string[]): ToolDefinition[];
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { access, readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
4
|
+
import { createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, detectSupportedImageMimeTypeFromFile, } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
/** Fixed refusal text; it never names the refused path. */
|
|
6
|
+
export const OUTSIDE_ROOTS_MESSAGE = "Path is outside the allowed read roots";
|
|
7
|
+
/** The read-only built-ins whose definitions this server replaces with confined ones. */
|
|
8
|
+
export const CONFINED_TOOL_NAMES = ["read", "grep", "find", "ls"];
|
|
9
|
+
/** What the SDK's `normalizePath` would rewrite before resolving (core/tools/path-utils.js, utils/paths.js). */
|
|
10
|
+
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/;
|
|
11
|
+
/** Real path of an existing path, or the nearest existing ancestor's real path plus the missing rest. */
|
|
12
|
+
async function canonical(path) {
|
|
13
|
+
let current = resolve(path);
|
|
14
|
+
const rest = [];
|
|
15
|
+
for (;;) {
|
|
16
|
+
try {
|
|
17
|
+
return join(await realpath(current), ...rest.reverse());
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
const parent = dirname(current);
|
|
21
|
+
if (parent === current)
|
|
22
|
+
return resolve(path);
|
|
23
|
+
rest.push(basename(current));
|
|
24
|
+
current = parent;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function isWithin(path, root) {
|
|
29
|
+
return path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The allowed read roots: the workspace and the directory of each selected skill file, as real paths.
|
|
33
|
+
* A skill directory that equals or contains a protected path (the operator's home, the configuration
|
|
34
|
+
* directory, the Pi agent directory) would expose it, so only the skill file itself is a root then.
|
|
35
|
+
* A symlinked skill file resolves outside its directory, so its real file is a root as well.
|
|
36
|
+
*/
|
|
37
|
+
export async function allowedReadRoots(workspace, skills, protectedPaths = []) {
|
|
38
|
+
const roots = new Set([await realpath(workspace)]);
|
|
39
|
+
const protectedReal = await Promise.all(protectedPaths.map((path) => canonical(path)));
|
|
40
|
+
for (const skill of skills) {
|
|
41
|
+
const directory = await realpath(dirname(skill.filePath)), file = await canonical(skill.filePath);
|
|
42
|
+
const root = protectedReal.some((path) => isWithin(path, directory)) ? file : directory;
|
|
43
|
+
roots.add(root);
|
|
44
|
+
if (!isWithin(file, root))
|
|
45
|
+
roots.add(file);
|
|
46
|
+
}
|
|
47
|
+
return [...roots];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolves a tool `path` argument the way the SDK will and checks the canonical result against the
|
|
51
|
+
* roots. Arguments the SDK would rewrite before resolving are refused, so both resolve the same path.
|
|
52
|
+
* Returns the canonical path; a refusal throws a fixed message without the path.
|
|
53
|
+
*/
|
|
54
|
+
export async function confineToolPath(input, cwd, roots) {
|
|
55
|
+
const value = input === undefined || input === "" ? "." : input;
|
|
56
|
+
if (typeof value !== "string"
|
|
57
|
+
|| value.startsWith("@") || value.startsWith("~") || value.startsWith("file:")
|
|
58
|
+
|| value.includes("\0") || UNICODE_SPACES.test(value)) {
|
|
59
|
+
throw new Error(OUTSIDE_ROOTS_MESSAGE);
|
|
60
|
+
}
|
|
61
|
+
const real = await canonical(resolve(cwd, value));
|
|
62
|
+
if (!roots.some((root) => isWithin(real, root)))
|
|
63
|
+
throw new Error(OUTSIDE_ROOTS_MESSAGE);
|
|
64
|
+
return real;
|
|
65
|
+
}
|
|
66
|
+
/** The second layer: the absolute path the SDK hands to an operations hook. */
|
|
67
|
+
async function checkAbsolute(path, roots) {
|
|
68
|
+
const real = await canonical(path);
|
|
69
|
+
if (!roots.some((root) => isWithin(real, root)))
|
|
70
|
+
throw new Error(OUTSIDE_ROOTS_MESSAGE);
|
|
71
|
+
}
|
|
72
|
+
/** The `*Operations` hooks that re-check every absolute path the SDK hands them; exported for tests. */
|
|
73
|
+
export function confinedOperations(roots) {
|
|
74
|
+
const guard = async (path, run) => { await checkAbsolute(path, roots); return run(); };
|
|
75
|
+
return {
|
|
76
|
+
read: {
|
|
77
|
+
access: (path) => guard(path, () => access(path, constants.R_OK)),
|
|
78
|
+
readFile: (path) => guard(path, () => readFile(path)),
|
|
79
|
+
detectImageMimeType: (path) => guard(path, () => detectSupportedImageMimeTypeFromFile(path)),
|
|
80
|
+
},
|
|
81
|
+
grep: {
|
|
82
|
+
isDirectory: (path) => guard(path, async () => (await stat(path)).isDirectory()),
|
|
83
|
+
readFile: (path) => guard(path, () => readFile(path, "utf-8")),
|
|
84
|
+
},
|
|
85
|
+
ls: {
|
|
86
|
+
exists: (path) => guard(path, async () => { try {
|
|
87
|
+
await access(path, constants.F_OK);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
} }),
|
|
93
|
+
stat: (path) => guard(path, () => stat(path)),
|
|
94
|
+
readdir: (path) => guard(path, () => readdir(path)),
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** Runs the root check on `params.path` before the SDK tool executes. */
|
|
99
|
+
function confine(definition, cwd, roots) {
|
|
100
|
+
return {
|
|
101
|
+
...definition,
|
|
102
|
+
execute: async (toolCallId, params, signal, onUpdate, context) => {
|
|
103
|
+
// The SDK resolves against `ctx.cwd || cwd`; resolve the same way.
|
|
104
|
+
await confineToolPath(params.path, context?.cwd || cwd, roots);
|
|
105
|
+
return definition.execute(toolCallId, params, signal, onUpdate, context);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Definitions named `read`, `grep`, `find` and `ls` that accept only paths below `roots`. Registered as
|
|
111
|
+
* SDK custom tools they replace the built-ins of the same name. `find` has no operations hook that
|
|
112
|
+
* would still cover `fd`, so it relies on the execute wrapper alone.
|
|
113
|
+
*/
|
|
114
|
+
export function createConfinedReadOnlyTools(cwd, roots) {
|
|
115
|
+
const operations = confinedOperations(roots);
|
|
116
|
+
return [
|
|
117
|
+
confine(createReadToolDefinition(cwd, { operations: operations.read }), cwd, roots),
|
|
118
|
+
confine(createGrepToolDefinition(cwd, { operations: operations.grep }), cwd, roots),
|
|
119
|
+
confine(createFindToolDefinition(cwd), cwd, roots),
|
|
120
|
+
confine(createLsToolDefinition(cwd, { operations: operations.ls }), cwd, roots),
|
|
121
|
+
];
|
|
122
|
+
}
|
package/dist/doctor.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { type ExtensionSpec } from "./extension-spec.js";
|
|
|
3
3
|
import { type ReleasedSkillTree } from "./skill-library.js";
|
|
4
4
|
export type DoctorSeverity = "ok" | "warning" | "error";
|
|
5
5
|
export type DoctorExitCode = 0 | 1 | 2 | 64;
|
|
6
|
-
export type DoctorCheckId = "node_version_unsupported" | "package_metadata_unreadable" | "build_missing" | "build_stale" | "version_mismatch" | "config_missing" | "config_unreadable" | "config_invalid" | "workspace_missing" | "workspace_not_directory" | "workspace_unreadable" | "workspace_not_writable" | "workspace_gitfile" | "workspace_mode_unset" | "workspace_base_unset" | "workspace_host_writes_accepted" | "container_runtime_unavailable" | "container_profile_refused" | "writer_lease_directory" | "writer_not_ready" | "writer_not_migrated" | "writer_off_default_base" | "writer_dirty" | "writer_git_identity_missing" | "writer_uncollected_bundle" | "extension_missing" | "extension_version_mismatch" | "extension_unpinned" | "skill_missing" | "skill_unreadable" | "skill_duplicate" | "skill_override_outdated" | "event_spool_root";
|
|
6
|
+
export type DoctorCheckId = "node_version_unsupported" | "package_metadata_unreadable" | "build_missing" | "build_stale" | "version_mismatch" | "config_missing" | "config_unreadable" | "config_invalid" | "workspace_missing" | "workspace_not_directory" | "workspace_unreadable" | "workspace_not_writable" | "workspace_gitfile" | "workspace_mode_unset" | "workspace_base_unset" | "workspace_host_writes_accepted" | "container_runtime_unavailable" | "container_profile_refused" | "writer_lease_directory" | "writer_not_ready" | "writer_not_migrated" | "writer_off_default_base" | "writer_dirty" | "writer_git_identity_missing" | "writer_uncollected_bundle" | "extension_missing" | "extension_version_mismatch" | "extension_unpinned" | "skill_missing" | "skill_unreadable" | "skill_duplicate" | "skill_override_outdated" | "sandbox_unavailable" | "workspace_git_unavailable" | "event_spool_root";
|
|
7
7
|
export interface DoctorCheck {
|
|
8
8
|
readonly id: DoctorCheckId;
|
|
9
9
|
readonly severity: DoctorSeverity;
|
|
@@ -76,6 +76,10 @@ export interface DoctorDependencies {
|
|
|
76
76
|
readonly writerLeaseRoot?: (path: string) => Promise<EventSpoolRootInfo | undefined>;
|
|
77
77
|
/** Effective user id of the current process; defaults to the real one. */
|
|
78
78
|
readonly uid?: number;
|
|
79
|
+
/** Probes the read-only shell sandbox with the session's fixed arguments for one workspace; called only when a profile selects it. */
|
|
80
|
+
readonly sandboxProbe?: (workspace: string) => Promise<boolean>;
|
|
81
|
+
/** The main Git directory of a linked worktree workspace, or `undefined` when the pointer is unusable. */
|
|
82
|
+
readonly worktreeGitDir?: (workspace: string) => Promise<string | undefined>;
|
|
79
83
|
}
|
|
80
84
|
export interface EventSpoolRootInfo {
|
|
81
85
|
readonly isDirectory: boolean;
|