@jc_stack/ez-agents 0.1.0-beta.34 → 0.1.0-beta.36

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.
Files changed (137) hide show
  1. package/.dockerignore +1 -0
  2. package/.env.example +5 -16
  3. package/AGENTS.md +17 -9
  4. package/CHANGELOG.md +67 -0
  5. package/Dockerfile +7 -9
  6. package/README.md +10 -13
  7. package/bin/ez +97 -0
  8. package/bin/ezenciel-agents-plugin-broker.mjs +16 -0
  9. package/bin/ezenciel-agents-provision-telegram.mjs +13 -0
  10. package/bin/ezenciel-agents.mjs +1 -1
  11. package/compose.yaml +40 -4
  12. package/docker/entrypoint.sh +1 -1
  13. package/docker/healthcheck.mjs +12 -4
  14. package/docker/run.ts +5 -6
  15. package/docker/smoke.mjs +28 -6
  16. package/docs/application-channel.md +32 -10
  17. package/docs/architecture/authority-boundaries.md +2 -2
  18. package/docs/channel-backend.md +4 -3
  19. package/docs/docker-runtime.md +79 -11
  20. package/docs/local-qa.md +2 -2
  21. package/docs/plugin-guide.md +202 -0
  22. package/docs/plugins.md +48 -2
  23. package/docs/releasing.md +13 -0
  24. package/docs/responsive-channels.md +1 -1
  25. package/docs/scheduling.md +4 -4
  26. package/docs/setup.md +11 -5
  27. package/docs/upgrades.md +46 -23
  28. package/package.json +5 -5
  29. package/scripts/smoke-scheduler.ts +10 -3
  30. package/scripts/smoke.ts +14 -11
  31. package/src/agent-guidance.ts +6 -9
  32. package/src/agent-install.ts +28 -7
  33. package/src/application-channel.ts +78 -7
  34. package/src/application-cli.ts +2 -2
  35. package/src/approval-cli.ts +14 -7
  36. package/src/approval.ts +27 -37
  37. package/src/channel-backend.ts +2 -3
  38. package/src/config.ts +12 -27
  39. package/src/control-state.ts +175 -8
  40. package/src/conversation-menu.ts +6 -17
  41. package/src/delivery-socket-client.d.mts +4 -0
  42. package/src/delivery-socket-client.mjs +104 -0
  43. package/src/delivery-socket.ts +354 -0
  44. package/src/execution-authority.ts +21 -5
  45. package/src/executor.ts +20 -23
  46. package/src/files.ts +38 -11
  47. package/src/host-executor.ts +28 -14
  48. package/src/inbox.ts +15 -49
  49. package/src/index.ts +95 -52
  50. package/src/install-tools.mjs +97 -22
  51. package/src/ledger-endpoint.d.mts +2 -0
  52. package/src/ledger-endpoint.mjs +17 -0
  53. package/src/menu.ts +7 -2
  54. package/src/message-history.ts +13 -18
  55. package/src/message.ts +35 -18
  56. package/src/plugin-broker.mjs +396 -0
  57. package/src/plugins/connection.mjs +2 -2
  58. package/src/plugins/manager.mjs +132 -23
  59. package/src/plugins/native-tasks.mjs +2 -2
  60. package/src/prompt-suffix.ts +12 -0
  61. package/src/react.ts +6 -4
  62. package/src/runs.ts +156 -243
  63. package/src/schedule-cli.ts +29 -10
  64. package/src/scheduled-tasks.ts +53 -10
  65. package/src/scheduler.ts +20 -7
  66. package/src/service.ts +3 -3
  67. package/src/software-status.ts +36 -4
  68. package/src/task-executor.ts +2 -2
  69. package/src/task-workspace.ts +4 -3
  70. package/src/tasks.ts +1 -1
  71. package/src/telegram-provisioning-cli.ts +22 -0
  72. package/src/telegram-provisioning.ts +214 -0
  73. package/src/updates/control.mjs +77 -7
  74. package/src/updates/runtime.mjs +28 -8
  75. package/src/updates/status.mjs +25 -3
  76. package/src/updates/supervisor.mjs +15 -16
  77. package/src/version.ts +20 -0
  78. package/src/workspace.ts +3 -9
  79. package/templates/failure-review.md +1 -1
  80. package/templates/maintainer-purpose.md +1 -1
  81. package/test/agent-guidance.test.ts +14 -80
  82. package/test/agent-install.test.ts +14 -0
  83. package/test/ai.test.ts +36 -0
  84. package/test/application-channel.test.ts +61 -13
  85. package/test/application-only.test.ts +14 -13
  86. package/test/channel-backend.test.ts +3 -7
  87. package/test/channel-delivery.test.ts +4 -1
  88. package/test/channel-owner.test.ts +6 -3
  89. package/test/codex-context.test.ts +2 -2
  90. package/test/config.test.ts +38 -43
  91. package/test/control-state.test.ts +83 -4
  92. package/test/conversation-menu.test.ts +2 -4
  93. package/test/delivery-transport.test.ts +72 -0
  94. package/test/docker-runtime.test.ts +27 -14
  95. package/test/engine-handoff.test.ts +7 -6
  96. package/test/event-sources.test.ts +2 -2
  97. package/test/execution-authority.test.ts +9 -9
  98. package/test/executor.test.ts +10 -11
  99. package/test/failure.test.ts +12 -5
  100. package/test/files.test.ts +2 -2
  101. package/test/helpers/ledger.ts +18 -0
  102. package/test/host-executor.test.ts +74 -0
  103. package/test/inbox.test.ts +8 -10
  104. package/test/install-config.test.ts +1 -0
  105. package/test/install-tools.test.mjs +74 -7
  106. package/test/intake-relay.test.ts +58 -11
  107. package/test/message-history.test.ts +39 -47
  108. package/test/native-tasks.test.ts +1 -1
  109. package/test/plugin-broker.test.mjs +172 -0
  110. package/test/plugin-manager.test.mjs +119 -2
  111. package/test/reaction.test.ts +4 -0
  112. package/test/relay.test.ts +5 -7
  113. package/test/repair-policy.test.ts +1 -4
  114. package/test/schedule-cli.test.ts +14 -9
  115. package/test/scheduled-tasks.test.ts +29 -6
  116. package/test/scheduler-host.test.ts +5 -5
  117. package/test/scheduler-relay.test.ts +15 -9
  118. package/test/security.test.ts +6 -3
  119. package/test/software-status.test.ts +21 -3
  120. package/test/telegram-provisioning.test.ts +160 -0
  121. package/test/updates.test.mjs +93 -13
  122. package/test/workspace.test.ts +4 -1
  123. package/bin/ezenciel-agents-watch.mjs +0 -8
  124. package/compose.workforce-watch.yaml +0 -33
  125. package/docker/recovery.ts +0 -11
  126. package/docs/pagerduty.md +0 -42
  127. package/docs/workforce-watch.md +0 -101
  128. package/src/pagerduty.ts +0 -109
  129. package/src/reply-context.ts +0 -43
  130. package/src/update-attention.ts +0 -18
  131. package/src/workforce-watch-cli.ts +0 -14
  132. package/src/workforce-watch.ts +0 -155
  133. package/templates/agent/AGENTS.md +0 -5
  134. package/templates/agent-guidance.md +0 -17
  135. package/test/pagerduty.test.ts +0 -104
  136. package/test/update-attention.test.ts +0 -20
  137. package/test/workforce-watch.test.ts +0 -180
package/.dockerignore CHANGED
@@ -23,6 +23,7 @@
23
23
  !scripts/release-check.mjs
24
24
  !scripts/stage-qa.mjs
25
25
  !default-plugins.json
26
+ !compose.yaml
26
27
  !scripts/trusted-beta.mjs
27
28
  !scripts/generate-publish-caller.mjs
28
29
  !scripts/smoke-busy-reply.ts
package/.env.example CHANGED
@@ -32,26 +32,15 @@ EZ_EXECUTOR_CLI=agy
32
32
  # Does not grant GitHub access, merge or publication permissions.
33
33
  EZ_REPAIR_ENABLED=true
34
34
 
35
- # Optional PagerDuty incident paging for critical Stocks outages. Put the routing
36
- # key only in the private relay.env file when running Docker; place the other
37
- # settings in docker.env so Compose passes them to the relay.
38
- # PAGERDUTY_ROUTING_KEY=
39
- # EZ_PAGERDUTY_STOCKS_HEALTH_URL=http://10.97.0.1:3006/health/critical
40
- # EZ_PAGERDUTY_POLL_SECONDS=30
41
- # EZ_PAGERDUTY_FAILURE_THRESHOLD=3
42
-
43
- # Workforce Watch runs separately. Its enrollment secret and Telegram token are
44
- # monitor-only credentials; do not put them in an agent workspace.
45
- # EZ_WATCH_ENROLL_TOKEN=
46
- # EZ_WATCH_TELEGRAM_CHAT_ID=
47
-
48
- # Optional native application-only runtime: no Telegram bot or token.
49
- # Requires EZ_APPLICATION_PORT; administrator bootstrap uses the real operator ID.
50
- # EZ_TELEGRAM_ENABLED=false
35
+ # An agent without TELEGRAM_BOT_TOKEN is application-only.
36
+ # It requires EZ_APPLICATION_PORT; administrator bootstrap uses the real operator ID.
51
37
  # Isolation class from agent creation (default isolated).
52
38
  # EZ_ISOLATION=isolated
53
39
  # EZ_EXECUTOR_TRANSPORT=local
54
40
  # Host-capable: EZ_ISOLATION=host-capable and EZ_EXECUTOR_TRANSPORT=host
41
+ # Host-capable agents get a generated ledger.compose.yaml that publishes this
42
+ # loopback port so host CLIs can reach the relay ledger on macOS Docker.
43
+ # EZ_DELIVERY_TCP_PORT=23456
55
44
  # Private application-only local container isolation (optional; default workspace-write).
56
45
  # EZ_CODEX_SANDBOX=external
57
46
 
package/AGENTS.md CHANGED
@@ -14,13 +14,14 @@ Installed runtime operation uses Docker Compose. Read
14
14
  [Docker setup, state and QA](docs/docker-runtime.md). Docker owns relay/plugin services; the existing host CLI and login are shared
15
15
  through one generic transport, with separate agent workspaces and sessions.
16
16
 
17
- ## General core instructions
17
+ ## Operating-agent instructions
18
18
 
19
- When the owner refers to "general core instructions", they mean
20
- `templates/agent-guidance.md`, loaded from the installed package into CLI and
21
- desktop agent runs, including background tasks. Update that file for defaults
22
- that must reach all agents after upgrade. Workspace `AGENTS.md` files and
23
- `templates/agent/` seeds alone do not propagate changes to existing agents.
19
+ Do not inject a coding handbook into production agent minds. The package-owned
20
+ workspace locator is the managed `<!-- ez tools -->` block: the bound `ez`
21
+ launcher, `ez --help`, and `ez tools list --details`. Inventory is generated on
22
+ read; do not hardcode command recipes. Purpose lives in that agent's own
23
+ `AGENTS.md`. Coding defaults for this repository stay in this file and
24
+ CONTRIBUTING.md.
24
25
 
25
26
  ## Installing this package
26
27
 
@@ -53,6 +54,7 @@ This package will be published as an open-source, lightweight Telegram-to-CLI re
53
54
  - Plugins are ordinary CLI tools with explicit inputs, outputs and errors. Packaging a tool does not justify another LLM worker or business workflow owner.
54
55
  - **SUBTRACT is as valid as ADD.** Question the requirement; delete unnecessary behavior; simplify; shorten feedback; automate last. Name and try the subtraction option before proposing additions. Removing a wrapper or correcting existing engine instructions/tool contracts can be the complete fix.
55
56
  - Add code only for a demonstrated missing transport or tool capability. Prompts, agents, retries and lifecycle owners also count as machinery; do not replace deleted code with a scripted prompt workflow. Prefer existing engine, CLI and Docker capabilities.
57
+ - The spawn core (`src/executor.ts` `startExecutorJob`) is spawn + stream only: callers own admission/authority and pass the one trigger suffix; the core never reads the run ledger, history or the workspace.
56
58
  - `/goal do my daily routine` is literal task-prompt text for the engine. ez does not parse it, construct a native objective or own its workflow.
57
59
  - Keep changes focused. State what was removed, why anything added is necessary, and which observed outcome proves the simpler system works. Preserve authority and uncertain-delivery safeguards. Unchanged, non-actionable maintenance stops quietly; queued updates and passing tests alone do not prove an installed fix.
58
60
 
@@ -102,8 +104,8 @@ find flaws:
102
104
  If no answer identifies a material blocker, finish. Do not start another review
103
105
  round merely to seek more criticism, tests or hypothetical edge cases.
104
106
 
105
- ## 3. Crash-Safe Atomic Disk State
106
- - All persistent stores (`ControlStore`, `RunStore`, outbox queue) must be disk-backed JSON files.
107
+ ## 3. Crash-Safe Disk State
108
+ - Persistent authority state (`ControlStore` owner/pairing/sessions/presets, application bindings, schedule definitions) stays in disk-backed JSON files. Run/outbox/inbox bookkeeping is relay memory reached through the delivery socket (`src/delivery-socket.ts`): a restart drops in-flight runs and queued deliveries by design, uncertain sends report unknown rather than success, and Telegram redelivery is the replay mechanism. The live delivery socket also replaces the relay flock as the single-relay guard.
107
109
  - **Atomic write pattern:** Never write directly to a state file. Always write to a temporary file (`${target}.${process.pid}.tmp`) with mode `0o600`, then atomically `rename` it over the destination.
108
110
  - **Path sanitization:** Every ID parameter (`runId`, `outboxId`, `pairingId`) must be validated against `/^[a-zA-Z0-9_-]+$/` before being joined into paths. Never allow directory traversal (`..`).
109
111
 
@@ -119,7 +121,7 @@ round merely to seek more criticism, tests or hypothetical edge cases.
119
121
 
120
122
  ## 6. Concurrency & Workspace Invariants
121
123
  - **1 Writer Job per Workspace:** The agent's Markdown folder (`./agent/`) is its mind. Never run concurrent background processes writing to the same workspace simultaneously.
122
- - Main-conversation jobs queue sequentially in `RunStore`. Scheduled/background work uses separate task directories and native CLI sessions (up to four alongside chat). Never share a mutable task directory. Delegation decisions and goal persistence belong to the agent/executor; there is no automatic planner or canned chat ACK. Production executor runs have no wall-clock timeout; cancellation is explicit.
124
+ - Main-conversation jobs queue sequentially in the relay's memory ledger; the delivery socket carries every cross-process enqueue/receipt, never control/ files. Scheduled/background work uses separate task directories under the control directory and native CLI sessions (up to four alongside chat). Never share a mutable task directory. Delegation decisions and goal persistence belong to the agent/executor; there is no automatic planner or canned chat ACK. Production executor runs have no wall-clock timeout; cancellation is explicit.
123
125
 
124
126
  ## 7. Fail-Closed Authority (Channel Access ≠ Execution)
125
127
  - Incoming messages outside the approved owner binding must **never** spawn the executor. A first DM or group message records a pending request only. Explicitly approved owner groups grant owner access to all human members in that exact chat; bots and anonymous posts are ignored.
@@ -146,3 +148,9 @@ fix and green tests do not justify a conflicting execution or context owner.
146
148
  Existing legacy channel-backend code is a documented limitation, not a pattern
147
149
  for new integrations. Keep internal plans and private evidence outside this
148
150
  repository.
151
+
152
+ Source-checkout image builds are RCs: commit and open the PR first, then
153
+ `ezenciel-agents-install build --label X.Y.Z-beta.N.rc.M` (increasing `.rc.N`)
154
+ from the clean reviewed commit. The build refuses a dirty checkout or a package
155
+ tree that is not the checkout root; `/status` must show the RC tag for source
156
+ installs.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,77 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0-beta.36
4
+
5
+ - Move relay run/outbox/inbox bookkeeping into relay memory behind the control
6
+ delivery socket. A restart drops in-flight runs and queued deliveries by
7
+ design; Telegram redelivery is the replay mechanism. The live socket replaces
8
+ the relay flock as the single-relay guard.
9
+ - Slim the spawn core to spawn and stream only. Callers authorize admission and
10
+ pass the one trigger suffix; the core no longer reads the run ledger, history
11
+ or workspace.
12
+ - Add a dedicated plugin-broker service (isolated profile) that owns Docker
13
+ access with no network access. Isolated plugin commands run over a Unix socket
14
+ with single-use capabilities, pinned revisions and per-invocation receipts.
15
+ - Publish installed plugin versions through the broker and report the deployed
16
+ build identity; show the running RC tag, isolation/transport mode and host
17
+ platform in `/status`.
18
+ - Add `ezenciel-agents-provision-telegram` to attach a Telegram channel to an
19
+ existing botless deployment using that deployment's installed image.
20
+ - Add the Telegram `/tasks` list with numbered detail, attach Telegram identity
21
+ to an existing web owner, and infer Telegram transport from the token instead
22
+ of `EZ_TELEGRAM_ENABLED`.
23
+ - Remove PagerDuty and Workforce Watch from core. Authorization, transport,
24
+ scheduling, safety gates and delivery receipts remain.
25
+ - Stop waking the agent for software updates. The supervisor no longer writes an
26
+ attention notice and the relay no longer queues a wrapper-owned
27
+ `software_update_attention` run, so nothing outside the managed `ez tools`
28
+ locator is injected into an operating agent. `ez updates` stays owner-invoked.
29
+ - Stop injecting a package-owned coding handbook into operating-agent
30
+ `AGENTS.md`. Upgrades strip the old `ez shared guidance` block. Seed only the
31
+ installer purpose. The managed `ez tools` locator binds the agent launcher
32
+ (`ez --help` and `ez tools list --details`); inventory is generated on read.
33
+ Do not create `inbox/`, `work/`, `SOUL.md`, or `USER.md`.
34
+ - Generalize compatibility bridge upgrades so `.compat.N` builds hand off to
35
+ their canonical release, and allow reviewed legacy homes without `docker.env`.
36
+ - List the agent-bound Codex catalog in `/ai` for local relays, make declared
37
+ provider models authoritative and migrate persisted provider selections.
38
+ - Source-checkout RC builds require a clean reviewed commit and an increasing
39
+ `.rc.N` label; the label and commit SHA are baked into the image and shown
40
+ first in `/status`.
41
+ - Existing host-capable deployments need one installer-owned migration before
42
+ this release: `ezenciel-agents-install migrate-ledger --deployment <dir>` adds
43
+ the authenticated loopback ledger endpoint. Pre-beta.36 deployments also need
44
+ the installer-owned bootstrap because the new `plugin-broker` service and
45
+ environment changes do not match the previous release's deployment gate.
46
+ - Fix plugin application context delivery into the command container, plugin
47
+ status JSON parsing under Compose warnings, and EPERM lease liveness.
48
+
49
+ ## 0.1.0-beta.35
50
+
51
+ - Harden the beta.34 compatibility upgrade path, including supervisor handoff
52
+ after a completed main upgrade and cleanup of obsolete terminal backups while
53
+ retaining active, queued and rollback state.
54
+
55
+ - Publish one plugin author standard for local-only installation, exact public
56
+ releases, catalog registration, package boundaries, and executor verification.
57
+
3
58
  ## 0.1.0-beta.34
4
59
 
5
60
  - Correct setup guidance for host-capable custom Codex providers and replace
6
61
  stale pinned-beta installation guidance with current release records.
7
62
 
63
+ - Retain only the newest completed updater backup per target and reconcile older
64
+ terminal backups after updates and supervisor restarts, without touching
65
+ active, queued or rollback state.
66
+
67
+ - Let existing agents self-upgrade across the beta.34 runtime compatibility
68
+ migration while retaining fail-closed rejection of structural deployment
69
+ changes.
70
+
71
+ - Record the reviewed Library beta14 command-contract migration and hand a
72
+ completed main upgrade back to the retained bootstrap so the new updater code
73
+ is loaded without a manual supervisor restart.
74
+
8
75
  - Record an isolation class at agent creation. The default is `isolated`: the
9
76
  native CLI runs in the relay (`EZ_EXECUTOR_TRANSPORT=local`). Pass
10
77
  `--isolation host-capable` to reuse the installer UID and host CLI login.
package/Dockerfile CHANGED
@@ -1,6 +1,6 @@
1
1
  FROM node:22.22.0-bookworm-slim@sha256:dd9d21971ec4395903fa6143c2b9267d048ae01ca6d3ea96f16cb30df6187d94 AS dependencies
2
2
  WORKDIR /app
3
- RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git util-linux ffmpeg && rm -rf /var/lib/apt/lists/*
3
+ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git util-linux ffmpeg docker.io docker-compose && rm -rf /var/lib/apt/lists/*
4
4
  RUN corepack enable && corepack prepare pnpm@10.30.3 --activate
5
5
  COPY package.json ./
6
6
  COPY docker/pnpm-lock.yaml ./pnpm-lock.yaml
@@ -13,15 +13,13 @@ FROM dependencies AS runtime
13
13
  # in the relay; do not bind-mount the operator's ~/.codex.
14
14
  ARG CODEX_CLI_VERSION=0.153.4
15
15
  RUN npm install -g @openai/codex@${CODEX_CLI_VERSION} && command -v codex
16
- RUN chmod +x docker/entrypoint.sh bin/ezenciel-agents* && mkdir -p /state/control /state/home /workspace && chown node:node /state/control /state/home /workspace
17
- RUN node -e 'for (const [name, target] of Object.entries(require("./package.json").bin)) require("node:fs").symlinkSync("/app/" + target, "/usr/local/bin/" + name)'
16
+ RUN chmod +x docker/entrypoint.sh bin/ez bin/ezenciel-agents* && mkdir -p /state/control /state/home /workspace && chown node:node /state/control /state/home /workspace
17
+ RUN node -e 'for (const [name, target] of Object.entries(require("./package.json").bin)) require("node:fs").symlinkSync("/app/" + target, "/usr/local/bin/" + name); require("node:fs").symlinkSync("/app/bin/ez", "/usr/local/bin/ez")'
18
+ # Build identity for relay status (package version stays release-owned).
19
+ ARG BUILD_TAG=""
20
+ ARG BUILD_SHA=""
21
+ RUN node -e 'require("node:fs").writeFileSync("/app/build.json",JSON.stringify({tag:process.env.BUILD_TAG||"",sha:process.env.BUILD_SHA||""})+"\n")'
18
22
  ENV HOME=/state/home EZ_AGENT_WORKSPACE=/workspace EZ_CONTROL_DIR=/state/control EZ_EXECUTOR_CLI=grok PATH=/app/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
19
23
  WORKDIR /workspace
20
24
  ENTRYPOINT ["/app/docker/entrypoint.sh"]
21
25
  CMD ["start"]
22
-
23
- FROM dependencies AS workforce-watch
24
- RUN chmod +x bin/ezenciel-agents-watch.mjs && mkdir /state
25
- ENV EZ_WATCH_STATE_DIR=/state
26
- WORKDIR /app
27
- CMD ["node", "--import", "tsx", "src/workforce-watch-cli.ts"]
package/README.md CHANGED
@@ -140,7 +140,8 @@ here:
140
140
 
141
141
  - **Install for a user:** [setup and verified onboarding](docs/setup.md), then the installer instructions below.
142
142
  - **Understand access and delegation:** [authority boundaries](docs/architecture/authority-boundaries.md) and [security scope](SECURITY.md).
143
- - **Discover or add capabilities:** [plugin contract](docs/plugins.md). Check the actual executor's native tools before adding an integration.
143
+ - **Build or share a plugin:** [plugin guide](docs/plugin-guide.md). It covers local unpublished installs, public releases, and catalog registration.
144
+ - **Discover or add capabilities:** [plugin manager contract](docs/plugins.md). Check the actual executor's native tools before adding an integration.
144
145
  - **Contribute:** [AGENTS.md](AGENTS.md) and [CONTRIBUTING.md](CONTRIBUTING.md).
145
146
 
146
147
  Repository discovery is not an instruction to install or contact anyone. Act on
@@ -169,13 +170,6 @@ services; the existing host CLI and login are shared through one generic
169
170
  transport, with separate agent workspaces and sessions. See
170
171
  [Docker setup, state and QA](docs/docker-runtime.md).
171
172
 
172
- For optional PagerDuty paging of a critical Stocks outage, see
173
- [PagerDuty critical-outage paging](docs/pagerduty.md).
174
-
175
- For independent agent check-ins and concise incident context, see
176
- [Workforce Watch](docs/workforce-watch.md). It runs separately from relays and
177
- does not receive their executor or provider credentials.
178
-
179
173
  Telegram `/status` shows the running relay and host versions plus installed
180
174
  plugin versions. Its read-only **Scheduled tasks** control lists the paired
181
175
  owner's active schedules in alphabetical order, with their engine/model/effort,
@@ -243,7 +237,8 @@ credentials, startup, and verified pairing; never present this as human homework
243
237
  | Tool | Purpose |
244
238
  |---|---|
245
239
  | `ezenciel-agents-install preflight --executor <path-or-name>` | Check prerequisites, disk and executor identity hints before setup |
246
- | `ezenciel-agents-install build` | Build once per artifact, with a private log and duplicate-build detection |
240
+ | `ezenciel-agents-install build [--label <X.Y.Z-beta.N.rc.M>]` | Build once per artifact, with a private log and duplicate-build detection; a source checkout requires the RC label and a clean reviewed commit |
241
+ | `ezenciel-agents-install migrate-ledger --deployment <path>` | Add the loopback host ledger endpoint to an existing host-capable deployment (idempotent; no restart) |
247
242
  | `ezenciel-agents-install status --deployment <path>` | Distinguish runtime/configuration, pairing and Telegram reply evidence |
248
243
  | `ezenciel-agents-create --register-cli <current-cli>` | Record the CLI that installs the initial package |
249
244
  | `ezenciel-agents-create --name <name> --purpose <agent-brief>` | Create an agent from the installing LLM's compact brief; packaged Codex defaults isolated, other CLIs stay host-capable; token via stdin |
@@ -265,10 +260,11 @@ restart and shutdown. Host-capable agents register the small host CLI transport
265
260
 
266
261
  The selected host CLI runs in this agent's persistent workspace and owns its
267
262
  Markdown/work files. New conversations and AI changes preserve those files.
268
- One compact, purpose-scoped `AGENTS.md` is seeded; `MEMORY.md` is optional.
269
- The installing LLM authors the initial brief from the owner's request and known
270
- application context; Ez stores it without trying to infer the product itself.
271
- Installed plugin snippets and skill paths come from `ez tools list --details`; no tool inventory file needs maintenance. Received files go in `inbox/`, tasks and deliverables in `work/`.
263
+ One compact `AGENTS.md` is seeded with the installer purpose; `MEMORY.md` is
264
+ optional. The installing LLM authors the initial brief from the owner's request
265
+ and known application context; Ez stores it without trying to infer the product
266
+ itself. Installed plugin snippets and skill paths come from the bound
267
+ `ez tools list --details` locator; no tool inventory file needs maintenance.
272
268
  Credentials and control state stay outside the mind. Isolated agents use relay
273
269
  mounts as the filesystem boundary. Host-capable file separation is not OS
274
270
  isolation against a process running as the same user.
@@ -319,6 +315,7 @@ without authorization. Local registry rehearsal uses `pnpm publish:local`.
319
315
  - [Security scope](SECURITY.md)
320
316
 
321
317
  - [Contributing](CONTRIBUTING.md) and [releasing](docs/releasing.md)
318
+ - [Plugin author guide](docs/plugin-guide.md)
322
319
  - [First plugin and extension contract](docs/plugins.md)
323
320
  - [License and dependency notices](THIRD_PARTY_NOTICES.md)
324
321
 
package/bin/ez ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ import { createConnection } from 'node:net';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ const MAX_STDIN_BYTES = 1024 * 1024;
6
+ const RUN_ID = /^[A-Za-z0-9_-]{1,160}$/;
7
+ const socketPath = process.env.EZ_PLUGIN_BROKER_SOCKET?.trim();
8
+ const runId = process.env.EZ_RUN_ID?.trim();
9
+
10
+ const fail = message => {
11
+ process.stderr.write(`ez plugin broker: ${message}\n`);
12
+ process.exitCode = 1;
13
+ };
14
+
15
+ const request = (payload, signal = undefined) => new Promise((resolve, reject) => {
16
+ if (!socketPath || !socketPath.startsWith('/') || socketPath.length > 100) return reject(Error('isolated plugin broker is not configured'));
17
+ const timeoutMs = Number(process.env.EZ_PLUGIN_BROKER_CLIENT_TIMEOUT_MS ?? 600000)
18
+ const socket = createConnection(socketPath);
19
+ let buffer = '';
20
+ let settled = false;
21
+ const finish = (error, value) => {
22
+ if (settled) return;
23
+ settled = true;
24
+ clearTimeout(timer);
25
+ signal?.removeEventListener('abort', cancel);
26
+ socket.destroy();
27
+ if (error) reject(error); else resolve(value);
28
+ };
29
+ const cancel = () => {
30
+ socket.destroy();
31
+ finish(Error('plugin command cancelled'));
32
+ };
33
+ const timer = setTimeout(() => {
34
+ socket.destroy();
35
+ finish(Error('plugin broker request timed out'));
36
+ }, Number.isSafeInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : 600000);
37
+ signal?.addEventListener('abort', cancel, { once: true });
38
+ socket.on('error', error => finish(error));
39
+ socket.on('close', () => finish(Error('plugin broker closed the connection')));
40
+ socket.on('data', chunk => {
41
+ buffer += chunk.toString();
42
+ const end = buffer.indexOf('\n');
43
+ if (end < 0) return;
44
+ let value;
45
+ try { value = JSON.parse(buffer.slice(0, end)); } catch { return finish(Error('invalid plugin broker response')); }
46
+ finish(undefined, value);
47
+ });
48
+ socket.on('connect', () => socket.write(JSON.stringify(payload) + '\n'));
49
+ });
50
+
51
+ const readStdin = async () => {
52
+ if (process.stdin.isTTY) return '';
53
+ const chunks = [];
54
+ let bytes = 0;
55
+ for await (const chunk of process.stdin) {
56
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
57
+ bytes += value.length;
58
+ if (bytes > MAX_STDIN_BYTES) throw Error('plugin stdin exceeds the broker limit');
59
+ chunks.push(value);
60
+ }
61
+ return Buffer.concat(chunks).toString('utf8');
62
+ };
63
+
64
+ const invoke = async (args, stdin) => {
65
+ if (!runId || !RUN_ID.test(runId)) throw Error('EZ_RUN_ID is required for an isolated plugin command');
66
+ const alias = args.shift();
67
+ if (!alias || !/^[a-z][a-z0-9-]{0,39}$/.test(alias) || ['plugins', 'tools', 'status', 'updates', 'message', 'owner', 'approval', 'react', 'setup', 'help', 'version'].includes(alias)) throw Error('Use a declared plugin alias');
68
+ const resolved = await request({ version: 1, id: randomUUID(), operation: 'resolve', runId, alias });
69
+ if (!resolved?.ok) throw Error(resolved?.error || 'Plugin alias resolution failed');
70
+ const result = await request({ version: 1, id: randomUUID(), operation: 'invoke', runId, alias, revision: resolved.revision, capability: resolved.capability, args, ...(stdin ? { stdin } : {}) });
71
+ if (!result?.ok) throw Error(result?.error || 'Plugin invocation failed');
72
+ if (result.stdout) process.stdout.write(result.stdout);
73
+ if (result.stderr) process.stderr.write(result.stderr);
74
+ process.exitCode = Number.isInteger(result.code) ? result.code : 1;
75
+ };
76
+
77
+ const manager = async args => {
78
+ if (!runId || !RUN_ID.test(runId)) throw Error('EZ_RUN_ID is required for isolated plugin management');
79
+ const result = await request({ version: 1, id: randomUUID(), operation: 'manager', runId, args });
80
+ if (!result?.ok) throw Error(result?.error || 'Plugin broker management failed');
81
+ if (result.stdout) process.stdout.write(result.stdout);
82
+ if (result.stderr) process.stderr.write(result.stderr);
83
+ process.exitCode = Number.isInteger(result.code) ? result.code : 0;
84
+ };
85
+
86
+ const main = async () => {
87
+ const args = process.argv.slice(2);
88
+ if (!args.length || args[0] === '--help' || args[0] === '-h') {
89
+ process.stdout.write('Use `ez tools list` to discover installed plugins, then `ez <alias> ...` for a declared command.\n');
90
+ return;
91
+ }
92
+ const stdin = ['plugins', 'tools', 'updates', 'status'].includes(args[0]) ? '' : await readStdin();
93
+ if (['plugins', 'tools', 'updates', 'status'].includes(args[0])) await manager(args);
94
+ else await invoke(args, stdin);
95
+ };
96
+
97
+ main().catch(error => fail(error instanceof Error ? error.message : 'Plugin broker client failed'));
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { createRequire } from 'node:module';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ const require = createRequire(import.meta.url);
9
+ const tsx = require.resolve('tsx');
10
+ const entry = join(here, '..', 'src', 'plugin-broker.mjs');
11
+ const child = spawn(process.execPath, ['--import', tsx, entry, ...process.argv.slice(2)], { stdio: 'inherit' });
12
+ child.on('error', error => { console.error(error.message); process.exitCode = 1; });
13
+ child.on('exit', (code, signal) => {
14
+ if (signal) process.kill(process.pid, signal);
15
+ process.exit(code ?? 1);
16
+ });
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process'
3
+ import { createRequire } from 'node:module'
4
+ import { dirname, join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url))
8
+ const require = createRequire(import.meta.url)
9
+ const child = spawn(process.execPath, ['--import', require.resolve('tsx'), join(here, '..', 'src', 'telegram-provisioning-cli.ts'), ...process.argv.slice(2)], { stdio: 'inherit' })
10
+ child.on('exit', (code, signal) => {
11
+ if (signal) process.kill(process.pid, signal)
12
+ process.exit(code ?? 1)
13
+ })
@@ -22,7 +22,7 @@ if (forwarded.length && (forwarded.length !== 1 || forwarded[0] !== 'start')) {
22
22
  process.exit(1)
23
23
  }
24
24
  if (!existsSync('/.dockerenv') && process.env.EZ_DEVELOPMENT !== '1') {
25
- const docker = spawn(join(here, 'ezenciel-agents-docker'), ['up', '-d', '--wait', 'relay'], { stdio: 'inherit' })
25
+ const docker = spawn(join(here, 'ezenciel-agents-docker'), ['up', '-d', '--wait'], { stdio: 'inherit' })
26
26
  docker.on('error', () => { console.error('Docker launcher could not start'); process.exitCode = 1 })
27
27
  docker.on('exit', code => { process.exitCode = code ?? 1 })
28
28
  } else {
package/compose.yaml CHANGED
@@ -19,9 +19,9 @@ services:
19
19
  EZ_AGENT_PURPOSE_FILE: /run/agent-purpose.md
20
20
  EZ_ISOLATION: ${EZ_ISOLATION:-}
21
21
  EZ_EXECUTOR_TRANSPORT: ${EZ_EXECUTOR_TRANSPORT:-host}
22
+ EZ_PLUGIN_BROKER_SOCKET: ${EZ_PLUGIN_BROKER_SOCKET:-}
22
23
  EZ_CHANNEL_BACKEND_URL: ${EZ_CHANNEL_BACKEND_URL:-}
23
24
  EZ_TELEGRAM_WEB_APP: ${EZ_TELEGRAM_WEB_APP:-}
24
- EZ_TELEGRAM_ENABLED: ${EZ_TELEGRAM_ENABLED:-true}
25
25
  EZ_APPLICATION_PORT: ${EZ_APPLICATION_PORT:-}
26
26
  EZ_APPLICATION_HOST: ${EZ_APPLICATION_HOST:-127.0.0.1}
27
27
  EZ_AGENT_WORKSPACE: ${EZ_AGENT_WORKSPACE:?Set this agent workspace}
@@ -30,9 +30,6 @@ services:
30
30
  EZ_CODEX_SANDBOX: ${EZ_CODEX_SANDBOX:-}
31
31
  EZ_CODEX_AUTO_COMPACT_TOKENS: ${EZ_CODEX_AUTO_COMPACT_TOKENS:-64000}
32
32
  EZ_EXECUTOR_TIMEOUT_SECONDS: ${EZ_EXECUTOR_TIMEOUT_SECONDS:-300}
33
- EZ_PAGERDUTY_STOCKS_HEALTH_URL: ${EZ_PAGERDUTY_STOCKS_HEALTH_URL:-}
34
- EZ_PAGERDUTY_POLL_SECONDS: ${EZ_PAGERDUTY_POLL_SECONDS:-30}
35
- EZ_PAGERDUTY_FAILURE_THRESHOLD: ${EZ_PAGERDUTY_FAILURE_THRESHOLD:-3}
36
33
  secrets: [relay_env]
37
34
  configs:
38
35
  - source: agent_purpose
@@ -49,6 +46,45 @@ services:
49
46
  timeout: 5s
50
47
  start_period: 30s
51
48
  retries: 3
49
+ plugin-broker:
50
+ profiles: [isolated]
51
+ image: ${EZ_RELAY_IMAGE:-ezenciel-agents:local}
52
+ build:
53
+ context: .
54
+ target: runtime
55
+ init: true
56
+ restart: unless-stopped
57
+ stop_grace_period: 10s
58
+ user: "0:0"
59
+ cap_drop: [ALL]
60
+ security_opt: [no-new-privileges:true]
61
+ network_mode: none
62
+ environment:
63
+ # Defaults keep Compose interpolation valid for host-capable deployments;
64
+ # the broker itself rejects these placeholders if its isolated profile is enabled.
65
+ EZ_PLUGIN_BROKER_HOME: ${EZ_TOOLS_HOME:-/nonexistent/ez-tools}
66
+ EZ_PLUGIN_BROKER_WORKSPACE: ${EZ_AGENT_WORKSPACE:-/nonexistent/ez-workspace}
67
+ EZ_PLUGIN_BROKER_CONTROL_DIR: ${EZ_CONTROL_DIR:-/nonexistent/ez-control}
68
+ EZ_PLUGIN_BROKER_SOCKET: ${EZ_PLUGIN_BROKER_SOCKET:-/nonexistent/ez-control/plugin-broker.sock}
69
+ EZ_PLUGIN_BROKER_HOST_CONFIG: ${EZ_PLUGIN_BROKER_HOST_CONFIG:-/nonexistent/host-executor.json}
70
+ EZ_PLUGIN_BROKER_TIMEOUT_MS: ${EZ_PLUGIN_BROKER_TIMEOUT_MS:-300000}
71
+ EZ_DOCKER_COMPOSE: standalone
72
+ HOME: /tmp
73
+ DOCKER_CONFIG: /tmp/docker-config
74
+ volumes:
75
+ - ${EZ_TOOLS_HOME:-/nonexistent/ez-tools}:${EZ_TOOLS_HOME:-/nonexistent/ez-tools}
76
+ - ${EZ_AGENT_WORKSPACE:-/nonexistent/ez-workspace}:${EZ_AGENT_WORKSPACE:-/nonexistent/ez-workspace}:ro
77
+ - ${EZ_CONTROL_DIR:-/nonexistent/ez-control}:${EZ_CONTROL_DIR:-/nonexistent/ez-control}
78
+ - ${EZ_PLUGIN_BROKER_HOST_CONFIG:-/nonexistent/host-executor.json}:${EZ_PLUGIN_BROKER_HOST_CONFIG:-/nonexistent/host-executor.json}:ro
79
+ - /var/run/docker.sock:/var/run/docker.sock
80
+ tmpfs: [/tmp]
81
+ entrypoint: [node, --import, /app/node_modules/tsx/dist/loader.mjs, /app/src/plugin-broker.mjs]
82
+ healthcheck:
83
+ test: [CMD, node, -e, "const fs=require('node:fs'); try { process.exit(fs.statSync(process.env.EZ_PLUGIN_BROKER_SOCKET).isSocket() ? 0 : 1) } catch { process.exit(1) }"]
84
+ interval: 2s
85
+ timeout: 5s
86
+ start_period: 5s
87
+ retries: 30
52
88
  configs:
53
89
  agent_purpose:
54
90
  file: ${EZ_AGENT_PURPOSE_FILE:-./templates/agent-purpose.md}
@@ -28,6 +28,6 @@ fi
28
28
  ( : <&3 ) 2>/dev/null || exec 3</dev/null
29
29
  case "${1:-start}" in
30
30
  start|smoke|exec|setup)
31
- exec flock --no-fork -n -E 73 /state/control/relay.lock node --import /app/node_modules/tsx/dist/loader.mjs /app/docker/run.ts "$@" ;;
31
+ exec node --import /app/node_modules/tsx/dist/loader.mjs /app/docker/run.ts "$@" ;;
32
32
  *) exec node --import /app/node_modules/tsx/dist/loader.mjs /app/docker/run.ts "$@" ;;
33
33
  esac
@@ -1,10 +1,14 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ const heartbeatFile = process.env.EZ_HEARTBEAT_FILE || '/tmp/ez-relay-heartbeat.json';
2
3
  const relayControl = process.env.EZ_HEALTH_RELAY_CONTROL_DIR || '/state/control';
3
4
  const fail = code => { process.stderr.write(`EZ_HEALTH_${code}\n`); process.exit(1); };
4
5
  let value;
5
- try { value = JSON.parse(readFileSync(relayControl + '/heartbeat.json', 'utf8')); }
6
- catch { fail('RELAY_UNREADABLE'); }
7
- if (!value.polling && !(process.env.EZ_TELEGRAM_ENABLED === 'false' && value.applicationOnly === true)) fail('RELAY_NOT_POLLING');
6
+ try { value = JSON.parse(readFileSync(heartbeatFile, 'utf8')); }
7
+ catch {
8
+ try { value = JSON.parse(readFileSync(relayControl + '/heartbeat.json', 'utf8')); }
9
+ catch { fail('RELAY_UNREADABLE'); }
10
+ }
11
+ if (!value.polling && (value.applicationOnly !== true || value.telegramConfigured !== false)) fail('RELAY_NOT_POLLING');
8
12
  if (!Number.isFinite(value.at) || Date.now() - value.at > 20000) fail('RELAY_STALE');
9
13
 
10
14
  if (process.env.EZ_EXECUTOR_TRANSPORT === 'host') {
@@ -13,3 +17,7 @@ if (process.env.EZ_EXECUTOR_TRANSPORT === 'host') {
13
17
  catch { fail('HOST_UNREADABLE'); }
14
18
  if (!Number.isFinite(host.at) || Date.now() - host.at > 15000) fail('HOST_STALE');
15
19
  }
20
+ if (process.env.EZ_PLUGIN_BROKER_SOCKET?.trim()) {
21
+ try { if (!statSync(process.env.EZ_PLUGIN_BROKER_SOCKET).isSocket()) fail('PLUGIN_BROKER_UNREADABLE'); }
22
+ catch { fail('PLUGIN_BROKER_UNREADABLE'); }
23
+ }
package/docker/run.ts CHANGED
@@ -4,7 +4,6 @@ import { executorEnvironment, executorInvocation } from '../src/executor.js'
4
4
  import { parseEnv } from 'node:util'
5
5
  import { writeFile, rm } from 'node:fs/promises'
6
6
  import { loadConfig } from '../src/config.js'
7
- import { recoverInterruptedRuns } from './recovery.js'
8
7
  import { createRelay } from '../src/index.js'
9
8
  import { packageVersion } from '../src/version.js'
10
9
 
@@ -21,18 +20,18 @@ try { privateEnv = parseEnv(readFileSync(3, 'utf8')) } catch (error) {
21
20
  if ((error as NodeJS.ErrnoException).code !== 'EINVAL' && (error as NodeJS.ErrnoException).code !== 'EBADF') throw error
22
21
  } finally { try { closeSync(3) } catch {} }
23
22
  for (const [key, value] of Object.entries(privateEnv)) {
24
- if (value !== undefined && ['TELEGRAM_BOT_TOKEN', 'GEMINI_API_KEY', 'OPENAI_API_KEY', 'EZ_CHANNEL_BACKEND_TOKEN', 'PAGERDUTY_ROUTING_KEY'].includes(key)) process.env[key] = value
23
+ if (value !== undefined && ['TELEGRAM_BOT_TOKEN', 'GEMINI_API_KEY', 'OPENAI_API_KEY', 'EZ_CHANNEL_BACKEND_TOKEN'].includes(key)) process.env[key] = value
25
24
  }
26
25
  privateEnv = {}
27
26
  const [command = 'start', ...args] = process.argv.slice(2)
28
27
  process.argv = [process.argv[0], '', ...args]
29
- if (['start', 'smoke'].includes(command)) await recoverInterruptedRuns(loadConfig().controlDir, Boolean(loadConfig().channelBackendUrl))
30
28
  if (command === 'start') {
31
- const relay = createRelay(loadConfig())
32
- const heartbeat = '/state/control/heartbeat.json'
29
+ const config = loadConfig()
30
+ const relay = createRelay(config)
31
+ const heartbeat = process.env.EZ_HEARTBEAT_FILE || '/tmp/ez-relay-heartbeat.json'
33
32
  await rm(heartbeat, { force: true })
34
33
  const timer = setInterval(() => {
35
- if (relay.isRunning()) void writeFile(heartbeat, JSON.stringify({ at: Date.now(), polling: Boolean(relay.bot?.isRunning()), applicationOnly: process.env.EZ_TELEGRAM_ENABLED === 'false', version: packageVersion }), { mode: 0o600 })
34
+ if (relay.isRunning()) void writeFile(heartbeat, JSON.stringify({ at: Date.now(), polling: Boolean(relay.bot?.isRunning()), applicationOnly: !relay.telegramEnabled, telegramConfigured: config.telegramBotToken !== '', version: packageVersion }), { mode: 0o600 })
36
35
  }, 5000)
37
36
  for (const signal of ['SIGINT', 'SIGTERM'] as const) process.once(signal, () => { clearInterval(timer); void relay.stop() })
38
37
  try { await relay.start() } finally { clearInterval(timer); await rm(heartbeat, { force: true }) }
package/docker/smoke.mjs CHANGED
@@ -21,7 +21,7 @@ try {
21
21
  '--mount',`type=bind,src=${join(dir,'purpose.md')},dst=/run/agent-purpose.md,readonly`,'-e','EZ_AGENT_PURPOSE_FILE=/run/agent-purpose.md',
22
22
  '--tmpfs','/tmp:mode=1777','--tmpfs','/state/control:uid=20000,gid=20000,mode=700',
23
23
  '--tmpfs','/state/home:uid=20000,gid=20000,mode=700','--tmpfs','/workspace:uid=20000,gid=20000,mode=700',
24
- '-e','EZ_TELEGRAM_ENABLED=false','-e','EZ_APPLICATION_PORT=8110','-e','EZ_ISOLATION=isolated','-e','EZ_EXECUTOR_TRANSPORT=local','-e','EZ_EXECUTOR_CLI=codex'];
24
+ '-e','EZ_APPLICATION_PORT=8110','-e','EZ_ISOLATION=isolated','-e','EZ_EXECUTOR_TRANSPORT=local','-e','EZ_EXECUTOR_CLI=codex'];
25
25
  const help = run(['run','--rm',...nonroot,image,'application','--help']);
26
26
  assert.equal(help.status,0,help.stderr);
27
27
  assert.match(help.stdout,/ezenciel-agents-application/);
@@ -72,7 +72,23 @@ try {
72
72
  assert.equal(JSON.parse(custom.stdout).uid,20001);
73
73
  const failed = run(['run','--rm',image,'exec','node','-e','process.exit(23)']);
74
74
  assert.equal(failed.status, 23, failed.stderr);
75
- const held = run(['run','-d','--name',holder,'-v',`${volume}:/state/control`,image,'exec','node','-e',"require('fs').writeFileSync('/state/control/ready','yes');setInterval(()=>{},1000)"]);
75
+ // The live delivery socket is the single-relay guard (it replaced the kernel
76
+ // flock). This mirrors the relay's own start check: an answering address
77
+ // refuses the second owner; a stale file is removed before binding.
78
+ const guardScript = `
79
+ const net=require('net'),fs=require('fs'),stay=process.argv[1]==='hold',path='/state/control/delivery.sock';
80
+ const probe=net.createConnection(path);
81
+ const bind=()=>{
82
+ fs.rmSync(path,{force:true});
83
+ const s=net.createServer(()=>{});
84
+ s.on('error',()=>process.exit(1));
85
+ s.listen(path,()=>{if(stay){fs.writeFileSync('/state/control/ready','yes');setInterval(()=>{},1000)}else s.close(()=>process.exit(0))});
86
+ };
87
+ probe.on('connect',()=>{probe.destroy();process.exit(73)});
88
+ probe.on('error',bind);
89
+ probe.setTimeout(3000,()=>{probe.destroy();process.exit(1)});
90
+ `;
91
+ const held = run(['run','-d','--name',holder,'-v',`${volume}:/state/control`,image,'exec','node','-e',guardScript,'hold']);
76
92
  assert.equal(held.status,0,held.stderr);
77
93
  for (let n=0;n<30;n++) {
78
94
  const probe=run(['exec',holder,'test','-f','/state/control/ready']);
@@ -80,10 +96,16 @@ try {
80
96
  await new Promise(resolve=>setTimeout(resolve,100));
81
97
  }
82
98
  assert.equal(run(['exec',holder,'test','-f','/state/control/ready']).status,0);
83
- const duplicate=run(['run','--rm','-v',`${volume}:/state/control`,image,'exec','node','-e','process.exit(0)']);
99
+ const duplicate=run(['run','--rm','-v',`${volume}:/state/control`,image,'exec','node','-e',guardScript]);
84
100
  assert.equal(duplicate.status,73,duplicate.stderr);
85
101
  assert.equal(run(['kill',holder]).status,0);
86
- const recovered=run(['run','--rm','-v',`${volume}:/state/control`,image,'exec','node','-e','process.exit(0)']);
87
- assert.equal(recovered.status,0,recovered.stderr);
88
- console.log('Docker smoke passed: direct non-root application help/start/health/stop, inherited private descriptor, non-root executor, isolated Codex on PATH, private secret isolation, literal argv, exit code, no Docker socket, duplicate writer rejection and crash lock release.');
102
+ let released=1;
103
+ for (let n=0;n<50;n++) {
104
+ const recovered=run(['run','--rm','-v',`${volume}:/state/control`,image,'exec','node','-e',guardScript]);
105
+ released=recovered.status;
106
+ if(released===0)break;
107
+ await new Promise(resolve=>setTimeout(resolve,100));
108
+ }
109
+ assert.equal(released,0,'a dead relay must release the delivery socket');
110
+ console.log('Docker smoke passed: direct non-root application help/start/health/stop, inherited private descriptor, non-root executor, isolated Codex on PATH, private secret isolation, literal argv, exit code, no Docker socket, live delivery-socket guard and crash release.');
89
111
  } finally { run(['rm','-f',holder,application]); run(['volume','rm',volume]); rmSync(dir, {recursive:true, force:true}); }