@junghanacs/entwurf 0.39.0 → 0.41.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/AGENTS.md +4 -2
- package/BASELINE.md +60 -5
- package/CHANGELOG.md +557 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +52 -2
- package/FAQ.md +8 -4
- package/README.md +37 -17
- package/VERIFY.md +23 -10
- package/docs/acp-backend-rail.md +5 -1
- package/docs/external-mcp-host.md +8 -6
- package/docs/fresh-cut-policy.md +9 -1
- package/docs/setup-clean-host.md +64 -9
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +100 -24
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +10 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +123 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +259 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js +80 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
- package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +65 -0
- package/mcp/entwurf-bridge/src/index.ts +118 -24
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/mcp/entwurf-bridge/tsconfig.build.json +17 -1
- package/package.json +5 -5
- package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
- package/pi-extensions/entwurf-control.ts +55 -20
- package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
- package/pi-extensions/lib/cc-retire-request.ts +553 -0
- package/pi-extensions/lib/control-retire.ts +195 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
- package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
- package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
- package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
- package/pi-extensions/lib/fresh-call-composition.ts +15 -0
- package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
- package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +13 -4
- package/pi-extensions/lib/meta-session.ts +140 -16
- package/pi-extensions/lib/mux-fresh-call.ts +7 -27
- package/pi-extensions/lib/mux-launch.ts +29 -0
- package/pi-extensions/lib/mux-resume-call.ts +33 -12
- package/pi-extensions/lib/pi-retire-request.ts +111 -0
- package/pi-extensions/lib/remote-pair-receive.ts +184 -0
- package/pi-extensions/lib/remote-pair-send.ts +325 -0
- package/pi-extensions/lib/remote-pair-transport.ts +81 -0
- package/pi-extensions/lib/resume-launch-identity.ts +104 -18
- package/pi-extensions/lib/retirement-event.ts +253 -0
- package/pi-extensions/meta-bridge-hook.ts +142 -13
- package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
- package/run.sh +122 -7
- package/scripts/agy-bridge-config.py +4 -1
- package/scripts/build-bridge.sh +14 -0
- package/scripts/check-capability-bundle-reach.ts +135 -2
- package/scripts/check-entwurf-bridge-boot.ts +2 -1
- package/scripts/check-entwurf-fact-provider.ts +1 -0
- package/scripts/check-entwurf-v2-production.ts +2 -0
- package/scripts/check-entwurf-v2-surface.ts +7 -0
- package/scripts/check-gate-qualification.ts +20 -8
- package/scripts/check-herdr-activation.ts +2 -2
- package/scripts/check-herdr-placement.ts +1 -0
- package/scripts/check-hook-launch-topology.ts +6 -15
- package/scripts/check-install-container.sh +2 -2
- package/scripts/check-meta-doctor-oracle.sh +27 -1
- package/scripts/check-meta-facts.ts +8 -3
- package/scripts/check-meta-hook-session-switch.ts +230 -13
- package/scripts/check-meta-identity-consumers.ts +1 -0
- package/scripts/check-meta-listing.ts +1 -0
- package/scripts/check-meta-manifest-schema.py +1 -1
- package/scripts/check-meta-session.ts +31 -0
- package/scripts/check-meta-v3-record.ts +55 -1
- package/scripts/check-mux-resume-call.ts +35 -9
- package/scripts/check-peer-facts.ts +1 -0
- package/scripts/check-pi-durable-contact.ts +3 -1
- package/scripts/check-pi-durable-send.ts +4 -2
- package/scripts/check-pi-mcp-bridge.ts +1 -0
- package/scripts/check-remote-pair-installed.ts +242 -0
- package/scripts/check-resume-launch-identity.ts +62 -23
- package/scripts/lib/meta-hook-bundle.ts +72 -0
- package/scripts/lib/remote-pair-installed-audit.ts +25 -0
- package/scripts/meta-bridge-claude-floor.sh +13 -9
- package/scripts/meta-bridge-doctor.sh +17 -0
- package/scripts/meta-bridge-install.sh +6 -0
- package/scripts/meta-facts.ts +1 -1
- package/scripts/mutants/bridge-boot-resume.json +5 -2
- package/scripts/mutants/cc-retire-pidfd.json +129 -0
- package/scripts/mutants/cc-retire-request.json +171 -0
- package/scripts/mutants/control-retire.json +105 -0
- package/scripts/mutants/control-send-receipt.json +2 -2
- package/scripts/mutants/copilot-receive.json +2 -6
- package/scripts/mutants/entwurf-v2-retire.json +476 -0
- package/scripts/mutants/fresh-call-dispatch.json +37 -0
- package/scripts/mutants/meta-hook-session-switch.json +88 -0
- package/scripts/mutants/meta-identity.json +57 -0
- package/scripts/mutants/mux-fresh-call.json +1 -1
- package/scripts/mutants/mux-resume-call.json +40 -4
- package/scripts/mutants/pi-durable.json +79 -0
- package/scripts/mutants/pi-retire-request.json +72 -0
- package/scripts/mutants/remote-pair-display.json +53 -0
- package/scripts/mutants/remote-pair-installed-audit.json +28 -0
- package/scripts/mutants/remote-pair-receive.json +126 -0
- package/scripts/mutants/remote-pair-roundtrip.json +83 -0
- package/scripts/mutants/remote-pair-send.json +194 -0
- package/scripts/mutants/remote-pair-transport.json +66 -0
- package/scripts/mutants/resume-launch-identity.json +35 -8
- package/scripts/mutants/retirement-event.json +195 -0
- package/scripts/mutants/v2-surface.json +16 -0
- package/scripts/mutants/v2-visible-resume.json +283 -13
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/remote-pair-receive.ts +73 -0
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-herdr-raw-install-live.sh +5 -4
- package/scripts/smoke-meta-install-state.sh +4 -4
- package/scripts/smoke-mux-lifecycle-live.ts +10 -10
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
- package/scripts/smoke-pi-provider-state.sh +3 -3
package/docs/setup-clean-host.md
CHANGED
|
@@ -19,8 +19,7 @@ evidence split, and its three states must not collapse into "macOS is supported"
|
|
|
19
19
|
unmeasured Darwin doctor reason is per-process environment DISCOVERY
|
|
20
20
|
(start-key is portable).
|
|
21
21
|
- **UNSUPPORTED** — native Windows `setup` and its harness rails. macOS is never this.
|
|
22
|
-
An experimental native-Windows Pi preview (0.39.0
|
|
23
|
-
and npm publication pending) exists outside `setup`; see [BASELINE](../BASELINE.md#release-host-baseline).
|
|
22
|
+
An experimental native-Windows Pi preview (core 0.39.0) exists outside `setup`; see [BASELINE](../BASELINE.md#release-host-baseline).
|
|
24
23
|
|
|
25
24
|
## Requirements
|
|
26
25
|
|
|
@@ -32,14 +31,14 @@ evidence split, and its three states must not collapse into "macOS is supported"
|
|
|
32
31
|
| entwurf | global/project-local `@junghanacs/entwurf`, or a source checkout | operator command and garden capability |
|
|
33
32
|
| pi | optional-by-presence, `>=1.3.0 <1.4` — absent is an explicit setup SKIP, below-floor is a named FAIL | ACP provider, control sockets |
|
|
34
33
|
| Native durable (`pi-durable`) | supplied by the Entwurf package (#130): Entwurf's owned app carrier plus the exact 1.3.0 pi SDK set as production dependencies — setup PASS when they verify, a named FAIL otherwise (never SKIP) | the `entwurf pi-durable` contact; native birth/LIVE on this supply surface is a separate release proof (§2b) |
|
|
35
|
-
| Claude Code | optional, **`>=2.1.
|
|
34
|
+
| Claude Code | optional, **`>=2.1.306`** — the exec-form hook floor | Claude ACP auth/runtime and mailbox-backed native citizen |
|
|
36
35
|
| GitHub Copilot CLI | optional-by-presence, operator-installed and authenticated — absent is an explicit setup SKIP; detected composes all four units (birth/MCP/receiver/footer) | self-fetch citizen and visible fresh |
|
|
37
36
|
| OMP (`omp`) | optional-by-presence, operator-installed — absent is an explicit setup SKIP; detected composes all four units (birth/MCP/`tools.xdev` setting/receiver) | self-fetch citizen and visible fresh (accepted on one host — see §4b) |
|
|
38
37
|
| Antigravity `agy` | optional, operator-installed and authenticated | native-push citizen |
|
|
39
38
|
| OpenAI Codex CLI | optional-by-presence, operator-installed and authenticated; detected composes all four operator-owned units (birth/MCP/status-line/terminal-title) and stays non-green until the operator trusts the birth declaration once in a visible Codex; visible fresh also requires the operator-owned app-server, started with `entwurf codex-app-server` in a tmux session of the operator's choosing (#95 D1 retired the fixed `codex` home) | native-push and visible fresh, supported in 0.21.0 |
|
|
40
39
|
| Cortex Code | optional, operator-installed and authenticated | Cortex ACP backend |
|
|
41
40
|
|
|
42
|
-
Claude Code >=2.1.
|
|
41
|
+
Claude Code >=2.1.306 is required for the managed exec-hook lifecycle and same-id resume. The package
|
|
43
42
|
never supplies or proxies backend credentials.
|
|
44
43
|
|
|
45
44
|
On a physical Darwin host, `python3` may be the CommandLineTools stub (a
|
|
@@ -105,7 +104,7 @@ project-local install, or `./run.sh <command>` from a source checkout. Keep that
|
|
|
105
104
|
same scope when upgrading and checking; native process restarts are separate (§7).
|
|
106
105
|
|
|
107
106
|
`check-bridge` is auth-free. It proves the installed prebuilt MCP server boots and
|
|
108
|
-
lists the
|
|
107
|
+
lists the nine garden tools (0.41.0); it does not prove a backend model turn, native hook or retirement permission.
|
|
109
108
|
|
|
110
109
|
Neither npm form installs a harness runtime. `pi`, Claude Code, Copilot CLI, Codex CLI, agy,
|
|
111
110
|
Cortex, and their authentication remain operator-owned optional prerequisites for the
|
|
@@ -241,10 +240,18 @@ A plain `pi` can load the package without becoming a citizen.
|
|
|
241
240
|
In that session ask for `entwurf_self`, then `entwurf_peers`. Pick an existing
|
|
242
241
|
live garden id and ask for `entwurf_v2` with `intent: "fire-and-forget"` and your
|
|
243
242
|
message. These are harness tools, not shell subcommands; pi exposes them through
|
|
244
|
-
Pi's built-in MCP as `mcp__entwurf_bridge__<verb>`. The bridge has
|
|
245
|
-
pi hides `entwurf_inbox_read` and `entwurf_register_native`, leaving
|
|
243
|
+
Pi's built-in MCP as `mcp__entwurf_bridge__<verb>`. The 0.41.0 bridge has nine tools;
|
|
244
|
+
pi hides `entwurf_inbox_read` and `entwurf_register_native`, leaving seven active.
|
|
245
|
+
The added `entwurf_retire_call` is only for one target/action explicitly requested by the
|
|
246
|
+
human operator, never autonomous cleanup. Where a host provides or is configured with an approval
|
|
247
|
+
step, that step applies as well; Pi and pi-durable 1.3.0 have no built-in approval prompt by
|
|
248
|
+
default. The configured Claude ACP child is also [set to run without per-call permission prompts](./acp-backend-rail.md#configured-claude-acp-permissions); ACP retirement remains unmeasured.
|
|
249
|
+
Tool availability, annotations and caller identity are not a grant.
|
|
250
|
+
These candidate counts do not claim that an older installed package or running host has rolled
|
|
251
|
+
forward; see [applying new source to a running host](#applying-new-source-to-a-running-host).
|
|
246
252
|
Tool-call ingress is MCP; delivery to a pi citizen is still its record-addressed
|
|
247
|
-
Unix
|
|
253
|
+
control address (a Unix socket; a named pipe on the experimental native-Windows Pi
|
|
254
|
+
preview). A boot/listing from `check-bridge` is not a model-turn or
|
|
248
255
|
native-rail acceptance.
|
|
249
256
|
|
|
250
257
|
To inspect the ACP provider from a project-local package instead of a global one:
|
|
@@ -449,6 +456,34 @@ The owned source is formatted at upstream width (`biome.json` override, indentWi
|
|
|
449
456
|
per-file exception. A formatting change to an owned file changes its emitted bytes. So it is
|
|
450
457
|
re-emitted with `--write`, and its generated digest is reviewed like any other carrier change.
|
|
451
458
|
|
|
459
|
+
## 2c. Optional remote pair between two hosts (#107)
|
|
460
|
+
|
|
461
|
+
This lets one declared pair of existing citizens on two hosts reach each other over SSH that you already run. [DELIVERY](../DELIVERY.md#remote-pair-107-one-declared-pair-across-two-hosts) owns the contract. The machine boundary is set by a person, and nothing here is automatic.
|
|
462
|
+
|
|
463
|
+
1. **Connect the hosts yourself.** Use a private network (Tailscale or similar is recommended) and SSH keys and authorization you already trust. From each host, `ssh -o BatchMode=yes <alias> true` must succeed non-interactively, and the other host's non-interactive `command -v entwurf` must resolve to the installation you intend. Entwurf edits no SSH config, key, known-hosts file or network setting. Whatever SSH itself writes under your own policy, such as host-key updates, stays yours.
|
|
464
|
+
2. **Run the accepted bytes on both hosts, in the processes that matter.** See [applying new source to a running host](#applying-new-source-to-a-running-host). A pulled checkout or a green setup line does not, by itself, show what a running bridge loaded.
|
|
465
|
+
3. **Write one file on each host,** at `remotePairOptInPath()`. That is the pi agent dir unless `ENTWURF_META_SESSIONS_DIR` moves the meta-record store. Check that the sending bridge's environment and the SSH receive environment resolve the same path. Use mode `0600` and never overwrite a file you did not inspect:
|
|
466
|
+
|
|
467
|
+
```json
|
|
468
|
+
{
|
|
469
|
+
"remoteGardenId": "<the other host's garden id>",
|
|
470
|
+
"ssh": "<your existing alias for the other host>",
|
|
471
|
+
"localGardenId": "<this host's garden id that talks to it>",
|
|
472
|
+
"remotePairFile": "<absolute path of the other host's own file>"
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
The two files mirror each other: one host's `remoteGardenId` is the other's `localGardenId`. The other host's `remotePairFile` must spell this path as it is normalized, not through a symlinked alias.
|
|
477
|
+
|
|
478
|
+
To check a file, read it with the repository's internal parser from a source checkout. Use the same agent/store-root settings the bridge runs with. This is a read-only dev-clone check: there is no installed operator validation verb, and it performs no send and no model input.
|
|
479
|
+
|
|
480
|
+
```bash
|
|
481
|
+
node --experimental-strip-types -e 'import("./pi-extensions/lib/remote-pair-send.ts").then(m=>console.log(m.readRemotePairOptIn(m.remotePairOptInPath())))'
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
Do not validate the configuration by sending a test message.
|
|
485
|
+
4. **Use it only between siblings you have open.** The measured self-fetch pair must be active and armed, and the target rail's own admission still applies. Delivery never starts, resumes or wakes an OFF citizen. To an active, armed one it rings the normal doorbell, which may start a turn at once in the receiving TUI. Remove the file to switch the pair off; there is nothing to stop or reconnect.
|
|
486
|
+
|
|
452
487
|
## 3. Optional Claude Code native citizen
|
|
453
488
|
|
|
454
489
|
This rail is CERTIFIED on Linux; on macOS it is NOT CERTIFIED — pending physical
|
|
@@ -469,7 +504,7 @@ entwurf install-meta-bridge
|
|
|
469
504
|
entwurf doctor-meta-bridge
|
|
470
505
|
```
|
|
471
506
|
|
|
472
|
-
The supported floor `>=2.1.
|
|
507
|
+
The supported floor `>=2.1.306` is enforced by package metadata through installer
|
|
473
508
|
and doctor gates. Older Claude versions validate an exec-form hook but silently drop
|
|
474
509
|
its `args` at runtime, so there is no shell-form fallback.
|
|
475
510
|
|
|
@@ -1128,6 +1163,26 @@ run `pnpm install` when the lockfile changed and `pnpm run build-bridge` before
|
|
|
1128
1163
|
artifact-consuming checks such as `./run.sh check-bridge-delivery`; ignored dist
|
|
1129
1164
|
may be missing or stale. Installed mode never bootstraps pnpm in `node_modules`.
|
|
1130
1165
|
|
|
1166
|
+
### Applying new source to a running host
|
|
1167
|
+
|
|
1168
|
+
New source on disk is not a fresh runtime. From a checkout, each surface loads different bytes, and each refreshes differently:
|
|
1169
|
+
|
|
1170
|
+
| Surface | Loads | Refreshed by |
|
|
1171
|
+
|---|---|---|
|
|
1172
|
+
| pi-durable host (contact and bridge) | compiled `mcp/entwurf-bridge/dist/…`, even from a checkout (the bootstrap's `PACKAGED_BRIDGE_ENTRY`) | a build, then a new host process |
|
|
1173
|
+
| ordinary Pi's built-in MCP bridge | the same compiled `dist` entry (`entwurfBridgeCompiledEntry`); the Pi extension itself loads from the package source | a build, then a new Pi process |
|
|
1174
|
+
| an MCP host registered through `start.sh` (Claude Code, Codex, Copilot, OMP, agy) | the TypeScript source in a checkout (compiled only under `node_modules`) | a new bridge child; no build |
|
|
1175
|
+
| installed hook/extension copies (Claude Code meta-bridge, Codex birth, Copilot birth and receive, OMP birth and receive) | their own installed copy, neither `dist` nor the checkout source | that unit's owning installer, then a new vendor session |
|
|
1176
|
+
|
|
1177
|
+
An already-running process keeps the modules it loaded, whatever is on disk. So, for a host that has to run new bridge code:
|
|
1178
|
+
|
|
1179
|
+
1. Build once from the checkout (`pnpm run build-bridge`, or its body `bash scripts/with-dist-lock.sh bash scripts/build-bridge.sh` when no package lifecycle should run).
|
|
1180
|
+
2. Compare the whole compiled output with the accepted candidate's, as a file-set and hash manifest. A hash of the selected entry alone is not enough: the entry can stay identical while a helper it loads differs. Check the pinned SDK set separately, and that the new process started after the build.
|
|
1181
|
+
3. Close the visible session and reopen it with its own native same-id resume. Closing a working sibling is an operator decision for that session, not something an agent does because new bytes landed.
|
|
1182
|
+
4. Confirm `entwurf_self` names the same garden id, and that the receiver/owner join points at the new process, whose bridge child started after the build.
|
|
1183
|
+
|
|
1184
|
+
A per-component setup PASS or a `git pull` does not show which bytes a running bridge loaded. On 2026-10-20 one host of the #107 pair still selected a pre-feature dist after a pull and a green setup. The cause was not traced. A hash check caught it before the first message, and a manual build and visible resume fixed it. Entwurf adds no auto-build, reload or watcher for this.
|
|
1185
|
+
|
|
1131
1186
|
## 8. Release acceptance versus host acceptance
|
|
1132
1187
|
|
|
1133
1188
|
- `entwurf check-bridge`: installed MCP bytes boot; no backend auth.
|
|
@@ -31,16 +31,18 @@
|
|
|
31
31
|
* seat, #105; nothing is ever created), optionally at ONE literal requested
|
|
32
32
|
* cwd (cross-repo fresh, #73); returns a LAUNCH receipt only, and the new
|
|
33
33
|
* address arrives later as the sender envelope of the sibling's nonce callback.
|
|
34
|
-
* - entwurf_resume_call — reopen ONE DORMANT pi citizen under its OWN garden id
|
|
35
|
-
* window; target-only,
|
|
36
|
-
* apart.
|
|
34
|
+
* - entwurf_resume_call — reopen ONE DORMANT pi or Claude Code citizen under its OWN garden id
|
|
35
|
+
* in a visible window; target-only, submits no prompt, LAUNCH and OBSERVATION
|
|
36
|
+
* receipts stay apart, the caller is attributed but grants nothing.
|
|
37
|
+
* - entwurf_retire_call — one operator-requested target, one retirement attempt; actor is not
|
|
38
|
+
* permission, and action / append-record / lock-release receipts stay separate.
|
|
37
39
|
* - entwurf_callback — ZERO-ARGUMENT fresh-sibling callback. Reads ENTWURF_CALLBACK_TARGET +
|
|
38
40
|
* ENTWURF_CALLBACK_NONCE from this process env (launcher-injected), validates
|
|
39
41
|
* grammar, and dispatches through the existing v2 runner. Refuses by name
|
|
40
42
|
* when env is absent/malformed or this process is a Codex-provenance bridge.
|
|
41
43
|
* No model-supplied target fallback.
|
|
42
44
|
*
|
|
43
|
-
* That list is the WHOLE public surface —
|
|
45
|
+
* That list is the WHOLE public surface — nine verbs — and `check-entwurf-bridge-boot`
|
|
44
46
|
* (G1f) holds it as an exact set on the runtime tools/list, so a verb added or dropped
|
|
45
47
|
* here without a decision is red rather than merely undocumented.
|
|
46
48
|
*
|
|
@@ -77,6 +79,8 @@ import { ENTWURF_PEERS_RENDER_LIMIT, renderEntwurfPeers } from "../../../pi-exte
|
|
|
77
79
|
import { computeSelfAddressability } from "../../../pi-extensions/lib/entwurf-self-address.js";
|
|
78
80
|
import { nativePushSupported } from "../../../pi-extensions/lib/entwurf-v2-contract.js";
|
|
79
81
|
import { resolveMailboxWakeModeCapability } from "../../../pi-extensions/lib/entwurf-v2-decider.js";
|
|
82
|
+
import { retireCitizen } from "../../../pi-extensions/lib/entwurf-v2-retire.js";
|
|
83
|
+
import { makeProductionRetireDeps } from "../../../pi-extensions/lib/entwurf-v2-retire-production.js";
|
|
80
84
|
import { runAndRenderEntwurfV2FromSurface } from "../../../pi-extensions/lib/entwurf-v2-surface.js";
|
|
81
85
|
import { makeVisibleResumeDeps, renderVisibleResume, visibleResume, } from "../../../pi-extensions/lib/entwurf-v2-visible-resume.js";
|
|
82
86
|
import { dispatchFreshCall, renderDispatchedFreshCall } from "../../../pi-extensions/lib/fresh-call-dispatch.js";
|
|
@@ -84,6 +88,9 @@ import { probeNativeSenderAlive, reconcileSenderIdentityClaims, resolveCodexRequ
|
|
|
84
88
|
import { applyOmpBridgeChildRootPolicy, defaultMetaMailboxDir, defaultMetaSessionsDir, makeStoreRecordReader, readActiveStoreEntries, readMetaIdentityByGardenId, readMetaInbox, readMetaReceiverMarker, readMetaSenderMarker, requireBackend, } from "../../../pi-extensions/lib/meta-session.js";
|
|
85
89
|
import { RESUME_CALL_REJECT_HINT, resumeCall } from "../../../pi-extensions/lib/mux-resume-call.js";
|
|
86
90
|
import { registerNativeConversation } from "../../../pi-extensions/lib/native-push/register.js";
|
|
91
|
+
import { makeLocalTraceFact, readRemotePairOptIn, remotePairOptInPath, runEntwurfV2WithRemoteRoute, } from "../../../pi-extensions/lib/remote-pair-send.js";
|
|
92
|
+
import { processTransport } from "../../../pi-extensions/lib/remote-pair-transport.js";
|
|
93
|
+
import { CONTROL_SOCKET_DIR } from "../../../pi-extensions/lib/socket-discovery.js";
|
|
87
94
|
import { probeSocketLiveness } from "../../../pi-extensions/lib/socket-probe.js";
|
|
88
95
|
const HOME = os.homedir();
|
|
89
96
|
// ============================================================================
|
|
@@ -431,11 +438,22 @@ server.tool("entwurf_v2", "CANONICAL DELIVERY SURFACE for garden ids: message, r
|
|
|
431
438
|
// Resolved ONCE so the dispatch-moment timestamp is fixed and the control RPC sender
|
|
432
439
|
// + the mailbox body sender share one envelope. No replyability gate (see above).
|
|
433
440
|
const sender = await buildSendSenderEnvelope({ requestMeta: extra._meta });
|
|
434
|
-
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
441
|
+
// #107: the operator's remote-pair opt-in is read per send. Absent = off and the local
|
|
442
|
+
// surface below runs exactly as before; a declared target goes over that one route.
|
|
443
|
+
const rendered = await runEntwurfV2WithRemoteRoute({ target, intent, message, mode, wants_reply }, {
|
|
444
|
+
senderProvider: () => sender,
|
|
445
|
+
route: readRemotePairOptIn(remotePairOptInPath()),
|
|
446
|
+
// The decider's canonical socket dir AND this bridge's ENTWURF_DIR: a trace in either is local.
|
|
447
|
+
localTrace: makeLocalTraceFact({
|
|
448
|
+
sessionsDir: defaultMetaSessionsDir(),
|
|
449
|
+
controlSocketDirs: [CONTROL_SOCKET_DIR, ENTWURF_DIR],
|
|
450
|
+
}),
|
|
451
|
+
transport: processTransport,
|
|
452
|
+
// No trust-preflight inputs are passed, and none exist to pass: the preflight on this
|
|
453
|
+
// path guarded the resume verdict and left with `owned-outcome`. `senderProvider` is
|
|
454
|
+
// the whole options surface now.
|
|
455
|
+
runLocal: (params, opts) => runAndRenderEntwurfV2FromSurface(params, opts),
|
|
456
|
+
});
|
|
439
457
|
return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
|
|
440
458
|
}
|
|
441
459
|
catch (err) {
|
|
@@ -672,7 +690,7 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling beside you and
|
|
|
672
690
|
// as an unused suppression while the escape goes back to being fixable.
|
|
673
691
|
// biome-ignore lint/complexity/noUselessEscapeInRegex: emitted to a Rust regex validator, see above
|
|
674
692
|
.regex(/^[A-Za-z0-9][A-Za-z0-9._/:\[\]-]*$/)
|
|
675
|
-
.describe("Required runtime model: canonical provider/model for pi, provider/<exact catalog model id> for pi-durable, a Claude Code model id/alias, or a Copilot/OMP/Codex model name."),
|
|
693
|
+
.describe("Required runtime model: canonical provider/model for pi (a bare alias is refused), provider/<exact catalog model id> for pi-durable, a Claude Code model id/alias, or a Copilot/OMP/Codex model name. Neither pi nor pi-durable may name the openrouter provider."),
|
|
676
694
|
task: z
|
|
677
695
|
.string()
|
|
678
696
|
.min(1)
|
|
@@ -749,36 +767,94 @@ server.tool("entwurf_fresh_call", "Open ONE fresh visible sibling beside you and
|
|
|
749
767
|
// resume` cannot open a window on its own and `mux-resume-call` cannot read a record — that is
|
|
750
768
|
// the import fence in docs/mux-launch-rail.md §11, expressed as a function argument.
|
|
751
769
|
const visibleResumeLaunch = (input) => {
|
|
752
|
-
const launched = resumeCall({ cwd: input.cwd, runtimeArgs: input.runtimeArgs });
|
|
770
|
+
const launched = resumeCall({ backend: input.backend, cwd: input.cwd, runtimeArgs: input.runtimeArgs });
|
|
753
771
|
return launched.ok
|
|
754
772
|
? { ok: true, handle: launched.receipt }
|
|
755
773
|
: { ok: false, reason: launched.reason, hint: RESUME_CALL_REJECT_HINT[launched.reason] };
|
|
756
774
|
};
|
|
757
|
-
server.tool("entwurf_resume_call", "Reopen ONE DORMANT pi citizen under its OWN garden id, in a visible window in the operator's
|
|
758
|
-
"session. The record supplies everything — which
|
|
759
|
-
"the only input is the target id: there is no model override, no task, and no
|
|
760
|
-
"
|
|
761
|
-
"
|
|
762
|
-
"
|
|
763
|
-
"
|
|
764
|
-
"
|
|
765
|
-
"
|
|
766
|
-
"
|
|
775
|
+
server.tool("entwurf_resume_call", "Reopen ONE DORMANT pi or Claude Code citizen under its OWN garden id, in a visible window in the operator's " +
|
|
776
|
+
"own tmux session. The record supplies everything — which session, which cwd, and for pi which transcript, " +
|
|
777
|
+
"model and provider — so the only input is the target id: there is no model override, no task, and no " +
|
|
778
|
+
"prompt, and a Claude Code session comes back as `claude --resume <its session id>` with none of the model " +
|
|
779
|
+
"or permission flags it was once started with. This verb submits NO prompt; what the runtime itself does " +
|
|
780
|
+
"after it comes back (for Claude Code, its hook re-arms the doorbell and unread mail may then wake a turn) " +
|
|
781
|
+
"is not measured, and talking to it is still entwurf_v2 fire-and-forget. You get TWO receipts and they mean " +
|
|
782
|
+
"different things: a LAUNCH receipt (tmux made a window and was asked to start the runtime) and an " +
|
|
783
|
+
"OBSERVATION receipt (pi: the control socket answered under the same id; Claude Code: a NEW live process in " +
|
|
784
|
+
"the window this call opened re-armed its receiver marker for the same session with a fresh arm binding the " +
|
|
785
|
+
"record also carries; or resume-unobserved; or record-unreadable with its cause). Unobserved is a real " +
|
|
786
|
+
"outcome, not an error to retry — the window is visible, so read it. A citizen that is already LIVE is " +
|
|
787
|
+
"refused: address it with entwurf_v2 instead. A Claude Code citizen is reopened only when the last validated " +
|
|
788
|
+
"owner the Entwurf hook armed for its session is proven gone AND that arm is still the record's last one; " +
|
|
789
|
+
"no marker, an unreadable or mismatched one, an inconclusive owner or an unbound one is refused, and a " +
|
|
790
|
+
"process holding the session without a committed hook attach is invisible to that proof. Other backends " +
|
|
791
|
+
"have no same-id resume. Requires that this agent itself runs inside tmux (inside herdr it is refused).", {
|
|
767
792
|
target: z
|
|
768
793
|
.string()
|
|
769
794
|
.min(1)
|
|
770
795
|
.regex(/^\d{8}T\d{6}-[0-9a-f]{6}$/)
|
|
771
|
-
.describe("Garden id of the DORMANT pi citizen to reopen (discover with entwurf_peers)."),
|
|
772
|
-
}, async ({ target }) => {
|
|
796
|
+
.describe("Garden id of the DORMANT pi or Claude Code citizen to reopen (discover with entwurf_peers)."),
|
|
797
|
+
}, async ({ target }, extra) => {
|
|
798
|
+
// Who asked, from the same authoritative caller identity fresh_call reads. Attribution only:
|
|
799
|
+
// an anonymous caller is reported as unattributed, never refused, and a record-backed one is
|
|
800
|
+
// granted nothing the target's record and live facts do not already decide.
|
|
801
|
+
let actor = null;
|
|
802
|
+
try {
|
|
803
|
+
actor = (await buildAuthoritativeSelfEnvelope({ requestMeta: extra._meta })).envelope.sessionId;
|
|
804
|
+
}
|
|
805
|
+
catch (err) {
|
|
806
|
+
if (!(err instanceof EntwurfEnvelopeWiringError)) {
|
|
807
|
+
return textErr(`entwurf_resume_call error: ${err instanceof Error ? err.message : String(err)}`);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
773
810
|
try {
|
|
774
811
|
const result = await visibleResume(target, makeVisibleResumeDeps(visibleResumeLaunch));
|
|
775
|
-
const rendered = renderVisibleResume(result);
|
|
812
|
+
const rendered = renderVisibleResume(result, { actor });
|
|
776
813
|
return rendered.isError ? textErr(rendered.text) : textOk(rendered.text);
|
|
777
814
|
}
|
|
778
815
|
catch (err) {
|
|
779
816
|
return textErr(`entwurf_resume_call error: ${err instanceof Error ? err.message : String(err)}`);
|
|
780
817
|
}
|
|
781
818
|
});
|
|
819
|
+
server.tool("entwurf_retire_call", "Only when the human operator explicitly requested this particular target/work: make ONE retirement attempt for that request. " +
|
|
820
|
+
"Development work, a role or a record-backed actor is NOT a grant. Use host approval where provided/configured; " +
|
|
821
|
+
"Pi and pi-durable have no built-in per-call approval prompt by default. An explicit operator request and " +
|
|
822
|
+
"agent operating policy are required; attribution and MCP annotations are hints, not grants or proof of approval. " +
|
|
823
|
+
"Never use for autonomous cleanup or timers. Never automatically retry an unknown, cancelled or lost result. " +
|
|
824
|
+
"Target must be an existing Pi or Claude Code garden id; self retirement refuses. Pi shutdown-requested is " +
|
|
825
|
+
"only an ACK, not process exit, flush or model silence. Claude Code exited means that fd-pinned process only, " +
|
|
826
|
+
"not conversation end, children or flush. Do not manually /resume or /clear the target during the request. " +
|
|
827
|
+
"The JSON receipt preserves outcome, record and release separately; returning a receipt does not mean the " +
|
|
828
|
+
"target exited. Cancellation can suppress the wire response without undoing an action. For plural explicit " +
|
|
829
|
+
"requests, invoke once for each named target; never infer a roster or expand the request.", {
|
|
830
|
+
target: z
|
|
831
|
+
.string()
|
|
832
|
+
.min(1)
|
|
833
|
+
.regex(/^\d{8}T\d{6}-[0-9a-f]{6}$/)
|
|
834
|
+
.describe("Exact garden id of the Pi or Claude Code target the human operator explicitly requested to retire."),
|
|
835
|
+
}, { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, async ({ target }, extra) => {
|
|
836
|
+
try {
|
|
837
|
+
const receipt = await retireCitizen(target, makeProductionRetireDeps({
|
|
838
|
+
resolveCaller: async () => (await resolveAuthoritativeSender({ requestMeta: extra._meta }))?.envelope ?? null,
|
|
839
|
+
signal: extra.signal,
|
|
840
|
+
}));
|
|
841
|
+
// isError reports uncertainty/faults, NOT a common Pi-ACK/CC-exit success boundary.
|
|
842
|
+
// Named refusals are completed decisions. Always preserve the full three-axis JSON.
|
|
843
|
+
const outcome = receipt.outcome;
|
|
844
|
+
const uncertain = outcome.kind === "unknown" ||
|
|
845
|
+
outcome.kind === "preflight-error" ||
|
|
846
|
+
(outcome.kind === "pi" && outcome.result.boundary === "unknown") ||
|
|
847
|
+
(outcome.kind === "claude-code" && outcome.result.outcome === "unknown");
|
|
848
|
+
const recordFault = receipt.record.status === "requested-append-error" || receipt.record.status === "outcome-append-error";
|
|
849
|
+
const releaseFault = receipt.release.status !== "released" && receipt.release.status !== "not-acquired";
|
|
850
|
+
const json = JSON.stringify(receipt);
|
|
851
|
+
return uncertain || recordFault || releaseFault ? textErr(json) : textOk(json);
|
|
852
|
+
}
|
|
853
|
+
catch (err) {
|
|
854
|
+
return textErr(`entwurf_retire_call error: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
855
|
+
"The outcome may be unknown; this does not prove no action. Do not automatically retry.");
|
|
856
|
+
}
|
|
857
|
+
});
|
|
782
858
|
server.tool("entwurf_callback", "ZERO-ARGUMENT callback for a fresh sibling this process was launched as. Reads " +
|
|
783
859
|
"ENTWURF_CALLBACK_TARGET and ENTWURF_CALLBACK_NONCE from this process environment " +
|
|
784
860
|
"(injected by the launcher next to the identity scrub), validates garden-id and nonce " +
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""cc-retire-pidfd — terminate ONE expected Linux process through a pidfd, or say why not (#147 G3).
|
|
2
|
+
|
|
3
|
+
A numeric PID names whichever process holds that number NOW. Checking a PID and then signalling
|
|
4
|
+
it leaves a window in which the process can exit and the number be reused, so the signal lands on
|
|
5
|
+
a stranger. A pidfd names the process it was opened on, for as long as the fd is open: a signal
|
|
6
|
+
sent through it can never reach a later holder of the number. This helper closes exactly that
|
|
7
|
+
check -> signal race, and nothing else.
|
|
8
|
+
|
|
9
|
+
python3 -I -S cc-retire-pidfd.py <expected-pid> <expected-start-key>
|
|
10
|
+
|
|
11
|
+
Order, and why each step is where it is:
|
|
12
|
+
|
|
13
|
+
1. Validate the inputs: a decimal pid > 1 and a `linux:<starttime>` start key (the scheme
|
|
14
|
+
`processStartKey` in meta-session.ts mints from /proc/<pid>/stat field 22).
|
|
15
|
+
2. pidfd_open(pid). From here the fd pins the process the number named at this instant.
|
|
16
|
+
3. Re-read /proc/<pid>/stat and compare the start key. /proc is a NUMERIC path, so this read
|
|
17
|
+
could already describe a reused number...
|
|
18
|
+
4. ...which is why the fd is then asked whether ITS process has exited. Not exited + key match
|
|
19
|
+
ties the fd and the stat to the same live process.
|
|
20
|
+
5. Print `{"phase": "ready"}` and wait, bounded, for ONE complete control frame on stdin — at
|
|
21
|
+
most CONTROL_MAX bytes of ASCII ending in a newline. Only the exact frame `go\n` proceeds;
|
|
22
|
+
`cancel\n`, EOF (with or without partial bytes), an oversized or non-ASCII frame, any other
|
|
23
|
+
line, a read failure, or the deadline (which partial input never extends) sends nothing. The caller re-checks its own
|
|
24
|
+
record/marker binding while the fd is held; this helper reads no record, marker, role or
|
|
25
|
+
permission and makes no decision of its own about WHETHER to terminate.
|
|
26
|
+
6. On `go`: ask the fd again (exited -> no signal), then pidfd_send_signal(fd, SIGTERM, None, 0)
|
|
27
|
+
ONCE, then poll the same fd for its exit within a fixed bound.
|
|
28
|
+
|
|
29
|
+
What it does NOT do, ever: signal a numeric pid, a process group, any signal but SIGTERM, a second
|
|
30
|
+
signal, an escalation, a retry, or a fallback to os.kill / kill(1) / Node's process.kill. A process
|
|
31
|
+
that ignores SIGTERM is left running and reported as `exit-unobserved`.
|
|
32
|
+
|
|
33
|
+
What the result does NOT mean:
|
|
34
|
+
- `signal-requested` means the kernel accepted the signal for that process, nothing more.
|
|
35
|
+
- `exited` means THAT process exited (its pidfd became readable). It is not its exit code, not
|
|
36
|
+
proof it flushed anything, and not proof its children are gone.
|
|
37
|
+
- A pidfd pins a PROCESS, not a conversation: a process that switched its native session after
|
|
38
|
+
the caller's last check is still the process this terminates.
|
|
39
|
+
- The start key is the same tick-resolution, same-boot value meta-session.ts compares; it is not
|
|
40
|
+
a cryptographic identity. The helper, the caller and the target must share one host, one PID
|
|
41
|
+
namespace and one procfs.
|
|
42
|
+
|
|
43
|
+
Output: JSON lines on stdout. Every normal protocol end prints `{"outcome": ..., ...}` as its last
|
|
44
|
+
line; a crash or a broken pipe can end without one, and a caller must read a missing outcome as
|
|
45
|
+
unknown — after `signal-requested` it means the signal may have been sent. Exit codes:
|
|
46
|
+
0 exited — SIGTERM was requested through the fd and that process's exit was observed
|
|
47
|
+
2 refused — nothing was opened or nothing was signalled: bad input, capability absent,
|
|
48
|
+
pid not found, permission, /proc unreadable, start-key mismatch, open error
|
|
49
|
+
3 not-signalled — the fd was held and NO signal was sent: cancelled, EOF, truncated / oversized
|
|
50
|
+
/ bad control, control read failure, go deadline, or the process had already
|
|
51
|
+
exited
|
|
52
|
+
4 unknown-after-signal — SIGTERM was requested but the exit was not observed in the bound, or
|
|
53
|
+
observing it failed; nothing is retried
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
import errno
|
|
57
|
+
import json
|
|
58
|
+
import os
|
|
59
|
+
import re
|
|
60
|
+
import select
|
|
61
|
+
import signal
|
|
62
|
+
import sys
|
|
63
|
+
import time
|
|
64
|
+
|
|
65
|
+
# Fixed on purpose — no flag or environment knob, so an outcome means the same thing everywhere.
|
|
66
|
+
GO_DEADLINE_S = 10.0
|
|
67
|
+
EXIT_WAIT_S = 5.0
|
|
68
|
+
|
|
69
|
+
# Linux PID_MAX_LIMIT (2^22): no pid_t a Linux kernel hands out is larger, so anything above it is
|
|
70
|
+
# malformed input, refused before any syscall.
|
|
71
|
+
PID_MAX = 4194304
|
|
72
|
+
# PID_MAX has 7 digits; fullmatch on this bound runs BEFORE int(), so no input reaches int()'s
|
|
73
|
+
# string-length limit and every over-long decimal is the named malformed-pid refusal.
|
|
74
|
+
PID_RE = re.compile(r"[0-9]{1,7}")
|
|
75
|
+
KEY_RE = re.compile(r"linux:[0-9]{1,20}")
|
|
76
|
+
CONTROL_MAX = 16
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def emit(obj):
|
|
80
|
+
sys.stdout.write(json.dumps(obj, sort_keys=True) + "\n")
|
|
81
|
+
sys.stdout.flush()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def finish(code, outcome, **facts):
|
|
85
|
+
emit(dict(outcome=outcome, **facts))
|
|
86
|
+
sys.exit(code)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def start_key(pid):
|
|
90
|
+
"""`linux:<starttime>` from /proc/<pid>/stat, split after the LAST ')' (comm may hold parens)."""
|
|
91
|
+
with open("/proc/%d/stat" % pid, "r", encoding="ascii", errors="strict") as fh:
|
|
92
|
+
stat = fh.read()
|
|
93
|
+
fields = stat[stat.rindex(")") + 1:].split()
|
|
94
|
+
starttime = fields[19]
|
|
95
|
+
if not starttime.isdigit():
|
|
96
|
+
raise ValueError("starttime field is not numeric")
|
|
97
|
+
return "linux:" + starttime
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def exited(fd):
|
|
101
|
+
"""True when the pidfd's process has exited (readable); raises when the fd cannot be observed."""
|
|
102
|
+
poller = select.poll()
|
|
103
|
+
poller.register(fd, select.POLLIN)
|
|
104
|
+
events = poller.poll(0)
|
|
105
|
+
for _, mask in events:
|
|
106
|
+
if mask & (select.POLLNVAL | select.POLLERR):
|
|
107
|
+
raise OSError(errno.EBADF, "pidfd not observable")
|
|
108
|
+
if mask & select.POLLIN:
|
|
109
|
+
return True
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def read_control(deadline):
|
|
114
|
+
"""ONE complete control frame from stdin before `deadline` (monotonic; partial input does not
|
|
115
|
+
extend it). Returns (kind, line): ("line", text) for a complete ASCII frame, or one of
|
|
116
|
+
("eof",), ("truncated",), ("oversize",), ("bad-encoding",), ("deadline",), ("read-failed",)."""
|
|
117
|
+
buf = b""
|
|
118
|
+
stdin = sys.stdin.fileno()
|
|
119
|
+
while True:
|
|
120
|
+
remaining = deadline - time.monotonic()
|
|
121
|
+
if remaining <= 0:
|
|
122
|
+
return ("deadline", None)
|
|
123
|
+
try:
|
|
124
|
+
ready, _, _ = select.select([stdin], [], [], remaining)
|
|
125
|
+
if not ready:
|
|
126
|
+
continue
|
|
127
|
+
chunk = os.read(stdin, CONTROL_MAX + 1)
|
|
128
|
+
except OSError:
|
|
129
|
+
return ("read-failed", None)
|
|
130
|
+
if not chunk:
|
|
131
|
+
return ("eof", None) if not buf else ("truncated", None)
|
|
132
|
+
buf += chunk
|
|
133
|
+
if b"\n" in buf:
|
|
134
|
+
frame = buf.split(b"\n", 1)[0]
|
|
135
|
+
if len(frame) > CONTROL_MAX:
|
|
136
|
+
return ("oversize", None)
|
|
137
|
+
try:
|
|
138
|
+
return ("line", frame.decode("ascii"))
|
|
139
|
+
except UnicodeDecodeError:
|
|
140
|
+
return ("bad-encoding", None)
|
|
141
|
+
if len(buf) > CONTROL_MAX:
|
|
142
|
+
return ("oversize", None)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main(argv):
|
|
146
|
+
if len(argv) != 3:
|
|
147
|
+
finish(2, "refused", reason="usage")
|
|
148
|
+
raw_pid, key = argv[1], argv[2]
|
|
149
|
+
if not PID_RE.fullmatch(raw_pid) or not 1 < int(raw_pid) <= PID_MAX:
|
|
150
|
+
finish(2, "refused", reason="malformed-pid")
|
|
151
|
+
if not KEY_RE.fullmatch(key):
|
|
152
|
+
finish(2, "refused", reason="malformed-start-key")
|
|
153
|
+
pid = int(raw_pid)
|
|
154
|
+
if not sys.platform.startswith("linux") or not hasattr(os, "pidfd_open") or not hasattr(signal, "pidfd_send_signal"):
|
|
155
|
+
finish(2, "refused", reason="capability-unsupported")
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
fd = os.pidfd_open(pid, 0)
|
|
159
|
+
except OSError as err:
|
|
160
|
+
if err.errno == errno.ESRCH:
|
|
161
|
+
finish(2, "refused", reason="pid-not-found")
|
|
162
|
+
if err.errno == errno.EPERM:
|
|
163
|
+
finish(2, "refused", reason="permission-denied")
|
|
164
|
+
if err.errno in (errno.ENOSYS, errno.EINVAL):
|
|
165
|
+
finish(2, "refused", reason="capability-unsupported", errno=errno.errorcode.get(err.errno, err.errno))
|
|
166
|
+
finish(2, "refused", reason="open-failed", errno=errno.errorcode.get(err.errno, err.errno))
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
try:
|
|
170
|
+
observed = start_key(pid)
|
|
171
|
+
except (OSError, ValueError, IndexError, UnicodeError):
|
|
172
|
+
finish(2, "refused", reason="proc-unreadable")
|
|
173
|
+
if observed != key:
|
|
174
|
+
finish(2, "refused", reason="start-key-mismatch")
|
|
175
|
+
try:
|
|
176
|
+
if exited(fd):
|
|
177
|
+
finish(3, "already-exited")
|
|
178
|
+
except OSError:
|
|
179
|
+
finish(2, "refused", reason="observe-failed")
|
|
180
|
+
|
|
181
|
+
emit({"phase": "ready", "pid": pid})
|
|
182
|
+
kind, line = read_control(time.monotonic() + GO_DEADLINE_S)
|
|
183
|
+
if kind == "deadline":
|
|
184
|
+
finish(3, "go-deadline")
|
|
185
|
+
if kind == "eof":
|
|
186
|
+
finish(3, "cancelled-eof")
|
|
187
|
+
if kind == "truncated":
|
|
188
|
+
finish(3, "truncated-control")
|
|
189
|
+
if kind == "oversize":
|
|
190
|
+
finish(3, "oversize-control")
|
|
191
|
+
if kind == "read-failed":
|
|
192
|
+
finish(3, "control-read-failed")
|
|
193
|
+
if kind != "line":
|
|
194
|
+
finish(3, "bad-control")
|
|
195
|
+
if line == "cancel":
|
|
196
|
+
finish(3, "cancelled")
|
|
197
|
+
if line != "go":
|
|
198
|
+
finish(3, "bad-control")
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
if exited(fd):
|
|
202
|
+
finish(3, "already-exited")
|
|
203
|
+
except OSError:
|
|
204
|
+
finish(3, "observe-failed-before-signal")
|
|
205
|
+
try:
|
|
206
|
+
signal.pidfd_send_signal(fd, signal.SIGTERM, None, 0)
|
|
207
|
+
except OSError as err:
|
|
208
|
+
if err.errno == errno.ESRCH:
|
|
209
|
+
finish(3, "already-exited")
|
|
210
|
+
if err.errno == errno.EPERM:
|
|
211
|
+
finish(2, "refused", reason="permission-denied")
|
|
212
|
+
finish(2, "refused", reason="signal-failed", errno=errno.errorcode.get(err.errno, err.errno))
|
|
213
|
+
emit({"phase": "signal-requested", "signal": "SIGTERM"})
|
|
214
|
+
|
|
215
|
+
poller = select.poll()
|
|
216
|
+
poller.register(fd, select.POLLIN)
|
|
217
|
+
deadline = time.monotonic() + EXIT_WAIT_S
|
|
218
|
+
while True:
|
|
219
|
+
remaining = deadline - time.monotonic()
|
|
220
|
+
if remaining <= 0:
|
|
221
|
+
finish(4, "exit-unobserved", waitedS=EXIT_WAIT_S)
|
|
222
|
+
try:
|
|
223
|
+
events = poller.poll(int(remaining * 1000) + 1)
|
|
224
|
+
except OSError:
|
|
225
|
+
finish(4, "observe-failed")
|
|
226
|
+
for _, mask in events:
|
|
227
|
+
if mask & (select.POLLNVAL | select.POLLERR):
|
|
228
|
+
finish(4, "observe-failed")
|
|
229
|
+
if mask & select.POLLIN:
|
|
230
|
+
finish(0, "exited")
|
|
231
|
+
finally:
|
|
232
|
+
os.close(fd)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
main(sys.argv)
|