threadwire 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Remove the default one-hour full-run deadline from the isolated runtime: when `THREADWIRE_ISOLATED_RUNTIME_CLIENT_TIMEOUT_MS` (client) and `THREADWIRE_WORKER_TIMEOUT_MS` (supervisor) are unset or blank, a healthy running worker continues until natural completion, caller cancellation/disconnect, supervisor shutdown, or concrete failure. Setting either to a positive millisecond value enables the existing single absolute preflight+run deadline with unchanged validation and caps. Compose no longer inserts numeric timeout defaults. Preflight admission, per-operation Docker/network calls, state collection (30 seconds), and emergency cleanup stay independently bounded.
6
+ - Add authenticated broker grant-lease renewal (Kimi and Codex): grants are issued as a bounded lease (default 60 seconds, `THREADWIRE_GRANT_LEASE_MS`, capped at one hour) that the supervisor renews via `POST /admin/grants/{token}/renew` while the run is active. Cleanup still revokes explicitly; if the supervisor dies or loses contact, renewals stop and the broker expires and aborts the grant within one lease. Malformed renewals (unknown/expired token, invalid ttlMs, missing admin auth) fail closed without extending the grant, and no renewal route exists on worker endpoints.
7
+ - Add Kimi-only Threadwire binding v1 named-volume admission: task-labelled source/context/lease validation, immutable context manifest validation, offline validator, dynamic task UID/GID workers with context RO/state RW mounts and an exact binding-declared source mount mode, and v5 authenticated binding state. Kimi path/worktree preflights and supervisor host workspace/socket mounts are no longer accepted.
8
+ - Fail closed on Kimi context manifest task/image identity mismatch: the worker itself revalidates the mounted manifest against the binding task attestation before launch, so a mismatched manifest task identity can never produce a successful client run.
9
+ - Support explicitly read-only Kimi review sources: binding v1 `source.readOnly: true` pins a read-only lease/worker source mount for Docker-enforced reviewers, while writable implementation sources keep the exact read-write mode.
10
+ - Add a credential-free per-run cross-daemon relay: the central Kimi broker splits into an internal admin/control endpoint and a stable worker endpoint, and each run creates a tiny mount-free, credential-free relay on the separate task DinD that forwards only the fixed chat shape to the worker endpoint. The worker URL and published bind have no insecure default and must be explicitly task-DinD-reachable; the configured worker URL is the stable worker origin, which the relay normalizes to the fixed `/v1/chat/completions` path (rejecting any other path fail-closed). Kimi-specific values are load-time optional so a genuine Codex-only relay-write profile renders and starts without them, while the Kimi runtime and client still fail closed at startup or job launch when they are absent. Outer-Compose DNS and outer-host loopback are never assumed, and admin/worker authority stay distinct.
11
+ - Stream Kimi output incrementally over bounded framed NDJSON: the supervisor follows the worker's multiplexed Docker logs, validates each stdout envelope as it arrives, and relays `record` frames to the client before worker completion, followed by exactly one `terminal` exit-code frame. Session envelopes are buffered and published only once the run succeeds, so a resume hint followed by a nonzero exit never exposes an unusable resume session. Byte caps, redaction/admission rules, exact session validation, cancellation/deadline behavior, and cleanup are preserved; stderr is never relayed.
12
+ - Reconcile exact relay/worker/network/state/grant identity on restart: genuine resources are adopted and cleaned up by their exact sealed spec, while image, mount, network, or environment tampering fails closed and retains authenticated cleanup evidence. The sealed Kimi worker/validator/relay image is the resolved image content Id (the worker/validator pin the binding's `contextImageId`; the relay pins its inspected image Id), matching how Docker normalizes a created container's `Config.Image`, so a genuine crash survivor is recognized rather than retained as a cleanup failure. The validator and relay environments accept legitimate non-sensitive keys inherited from the image while every owned key must match its sealed value exactly once and any injected sensitive or controlled key fails closed.
13
+ - Qualify genuine separate-daemon fresh/resume/denial/read-only/concurrency operation with zero residue: adversarial split-daemon E2E proves exact resume, read-only review mounts, binding/argument denial, concurrent task isolation, and complete relay/network/state cleanup on the task DinD.
14
+
5
15
  ## 0.1.10 - 2026-07-28
6
16
 
7
17
  - Accept official slash-qualified Kimi model aliases through workspace-free
package/README.md CHANGED
@@ -12,6 +12,15 @@ requires a reviewed `--workspace-profile` plus the root-owned isolated runtime
12
12
  and credential broker. The worker gets one writable task worktree, no upstream
13
13
  or Git credential, and no route except its run-scoped broker. Missing
14
14
  prerequisites fail before prompt or credential reads with no native fallback.
15
+ The isolated runtime imposes no default full-run deadline: a healthy running
16
+ worker is never stopped merely because time elapsed. An operator may set
17
+ `THREADWIRE_WORKER_TIMEOUT_MS` (supervisor) and
18
+ `THREADWIRE_ISOLATED_RUNTIME_CLIENT_TIMEOUT_MS` (client) to a positive
19
+ millisecond value to enable one absolute preflight+run deadline; empty or
20
+ unset means unlimited. Broker grants use a bounded lease that the supervisor
21
+ renews while the run is active and that expires within one lease if the
22
+ supervisor dies; unrelated safety timeouts (preflight slice, per-operation
23
+ Docker/network calls, state collection, emergency cleanup) are unchanged.
15
24
  See [Isolated provider runtime](docs/isolated-provider-runtime.md).
16
25
 
17
26
  ## Run with npx
@@ -62,7 +71,7 @@ Use the local launcher. Install or refresh it after updating Threadwire with `np
62
71
 
63
72
  The target always has the form `telegram:<chat-id>:<thread-id>` for topics, or `telegram:<chat-id>` for direct messages (DMs) and ordinary chats. The chat ID is always required and must be a nonzero signed integer. The thread ID is optional and valid only as the explicit topic suffix: when present it must be a positive safe integer, and a DM target simply omits it — a missing thread ID is never an error. `--provider` must be exactly one of `codex`, `claude`, `kimi`, or `opencode`. Malformed or missing provider, chat ID, or thread ID arguments are hard failures (exit code 2) reported through Threadwire's normal error path with fixed messages; they fail before the Telegram token is read, before any transport is created, and before any provider is launched, and the messages never echo the bot token or the untrusted argument value. `--process-number` accepts an explicit positive safe integer for automation; when omitted, Threadwire uses its launcher PID. Every Telegram message is labeled consistently, such as `[P42] …`, including chunked continuations.
64
73
 
65
- Pass `--workspace-profile <name>` only when invoking Threadwire inside the container-native runtime, for example with `node bin/threadwire.js run` as shown in [Container runtime](docs/container-runtime.md). A profile selects a reviewed entry from the checked-in [threadwire.workspace-profiles.json](threadwire.workspace-profiles.json). It supplies the absolute in-container repository root, the exact in-container working directory, and the allowlisted providers for that workspace. Threadwire validates the selected profile, rejects malformed or unknown names, verifies the configured workspace exists and is still a healthy Git worktree, and records the selected profile plus immutable revision/worktree identity in the local activity log before provider spawn. The checked-in `container-runtime` default targets `/workspace/threadwire` inside the container-native runtime and allowlists `codex` and isolated `kimi`.
74
+ Pass `--workspace-profile <name>` only when invoking Threadwire inside the container-native runtime, for example with `node bin/threadwire.js run` as shown in [Container runtime](docs/container-runtime.md). A profile selects a reviewed entry from the checked-in [threadwire.workspace-profiles.json](threadwire.workspace-profiles.json). Non-Kimi providers resolve the reviewed in-container repository path and Git provenance. Kimi instead requires the v2 `threadwire-v1` profile marker and a trusted `THREADWIRE_KIMI_TASK_BINDING`; it never accepts a repository path preflight. The binding names the task-owned source/context volumes, immutable context digests, revision, runtime identity, and live lease. Only safe provenance is recorded locally.
66
75
 
67
76
  Provider tool-start and tool-finish events do not produce Telegram messages by default. Pass the boolean `--tool-messages` flag to opt in, as shown above. When enabled, each tool is one concise, silent status line rather than a pair of noisy start/finish notices: when a tool starts, Threadwire sends `🛠 [P42] Tool: <safe command or tool description>` (for example `🛠 [P42] Tool: command — git status --short`), and when that same tool finishes, it edits the original message in place so it begins `✅` instead — no second completion message is sent, and Telegram edits raise no notification. The command/description shows a safe CLI-style preview with credential-bearing values (tokens, passwords, keys, authorization/cookie forms, credentials in URLs) redacted and control characters stripped; only normalized Codex command-completion output may also be appended to that same edited status as a Telegram-native expandable HTML blockquote with the visible label `Output →`; it is credential-redacted, HTML-escaped, and Unicode-safe bounded. Stderr, reasoning, and every other raw provider result remain excluded. Use `--max-output-length <positive-integer>` to truncate only that preview Unicode-safely; omit the option for unlimited tool detail. A prompt may instead come from `--prompt-file` or piped stdin, and every source rejects blank/whitespace content. Arguments following `--` pass to the selected provider, except output-stream options owned by Threadwire. Kimi is narrower: it always uses the dedicated isolated subscription runtime and accepts only one server-approved model alias through `--model <alias>` (or `-m <alias>`); Threadwire owns its prompt, output, session, permissions, configuration, tools, skills, plugins, MCP, and directories.
68
77
 
@@ -85,6 +85,8 @@ Optional:
85
85
  | `THREADWIRE_UPDATE_ID_TTL_MS` | Retention TTL for completed `update_id` entries in ms (default `86400000`) |
86
86
  | `THREADWIRE_KIMI_ISOLATED_RUNTIME_URL` | Dedicated Kimi supervisor URL; Compose defaults to `http://kimi-isolated-runtime:8790` |
87
87
  | `THREADWIRE_KIMI_ISOLATED_RUNTIME_CONTROL_TOKEN` | Dedicated Kimi control token. When absent, `/code kimi` remains recognized but fails closed before launch. |
88
+ | `THREADWIRE_KIMI_TASK_BINDING` | Trusted closed Threadwire binding v1 JSON. It is captured before provider filtering, sent only to Kimi preflight, and is never forwarded to a provider or worker. |
89
+ | `THREADWIRE_ISOLATED_RUNTIME_CLIENT_TIMEOUT_MS` | Optional absolute preflight+run deadline in ms for isolated (Codex relay-write and Kimi) runs. Empty/unset means no full-run deadline: a healthy running worker is never stopped merely because time elapsed. The supervisor-side equivalent is `THREADWIRE_WORKER_TIMEOUT_MS`. Broker grants always use a bounded lease renewed by the supervisor while the run is active, expiring within one lease if the supervisor dies; unrelated safety timeouts are unchanged. |
88
90
 
89
91
  ## Run
90
92
 
@@ -203,12 +203,17 @@ Never export or archive mounted secret files with workspace/provider backups.
203
203
 
204
204
  ## Kimi subscription containers
205
205
 
206
+ Kimi source is not a host worktree. The task controller creates one labelled source named volume (including its `.git` directory), one labelled immutable context named volume, and a running labelled lease container. The trusted Kimi supervisor reaches only the owning task-DinD API through `THREADWIRE_KIMI_TASK_DOCKER_HOST`; it has no workspace bind or outer Docker socket. It verifies the closed binding, exact labels/volume identities, lease image/user/workdir/mounts, references, Git revision, and manifest before issuing a broker grant. The worker has exactly the binding-declared source mount at `/workspace` (read-write for implementation sources, read-only for review sources), context RO at `/context`, and lineage state RW at `/state`. The worker entrypoint independently revalidates the mounted context manifest task/image identity and the exact source mount mode before Kimi starts; a mismatched manifest task identity or mount mode fails the run closed.
207
+
206
208
  Kimi is intentionally excluded from the same-UID container-native provider
207
209
  launch described above. Never mount `threadwire-kimi-oauth`, its access/refresh
208
210
  tokens, or the auth home into the Threadwire runtime, supervisor, worker,
209
- workspace, or session-state volume. Kimi uses four immutable build targets:
210
- `kimi-auth`, `kimi-model-broker`, `kimi-relay-worker`, and `isolated-runtime`.
211
- The first two share UID/GID 10003 and only the OAuth volume; the worker uses
211
+ workspace, or session-state volume. Kimi uses five immutable build targets:
212
+ `kimi-auth`, `kimi-model-broker`, `kimi-model-relay`, `kimi-relay-worker`, and
213
+ `isolated-runtime`.
214
+ The first two share UID/GID 10003 and only the OAuth volume; the credential-free
215
+ relay also uses UID/GID 10003 with no mounts and no credential environment; the
216
+ worker uses
212
217
  UID/GID 10002 with only the selected worktree and provider-bound state; the
213
218
  root supervisor alone receives the Docker socket. The broker/auth service uses
214
219
  Node `22.19.0` and exactly `@moonshot-ai/kimi-code@0.29.2` with the checked npm
@@ -216,8 +221,39 @@ integrity and archive SHA-512 in the Dockerfile.
216
221
 
217
222
  Enable the checked-in override with `--profile kimi` only after the OAuth/model
218
223
  operator flow in [Isolated provider runtime](isolated-provider-runtime.md).
224
+ The per-run relay is created on the separate task DinD, so the deployment has
225
+ no default central worker route: set
226
+ `THREADWIRE_KIMI_MODEL_BROKER_WORKER_URL` to an explicit address the task DinD
227
+ can reach (for example the outer host's task-DinD gateway) and
228
+ `THREADWIRE_KIMI_MODEL_BROKER_WORKER_BIND` to an explicit bind reachable by that
229
+ daemon. The URL is the stable worker origin; the relay normalizes a bare origin
230
+ to the fixed `/v1/chat/completions` path and rejects any other path fail-closed.
231
+ Outer-Compose DNS and outer-host loopback are unreachable from the task
232
+ daemon. Both values are load-time optional so a Codex-only relay-write profile
233
+ renders without them; the Kimi runtime then fails closed at startup when they
234
+ are empty, so the Kimi profile never becomes functional without both explicit
235
+ values. An unset bind renders the published worker port on a non-task-reachable
236
+ loopback sentinel (`127.0.0.1`) so the file loads without publishing on every
237
+ interface; the broker receives the raw setting and refuses to open either
238
+ listener until an explicit nonempty bind is configured, so the sentinel is
239
+ never an operational default.
240
+ Only the worker port is published, on that bind — the admin endpoint stays on
241
+ the internal control network.
219
242
  The default workspace profile allowlists Kimi because isolation is mandatory;
220
243
  without the dedicated Kimi control token/client, both CLI and Telegram Kimi
221
244
  jobs fail closed rather than using the native child path. Keep broker egress
222
245
  restricted to official Kimi Code subscription/auth endpoints and do not provide
223
246
  a Moonshot API key, custom base URL, Docker socket, or ingress secret.
247
+
248
+ Neither the Kimi nor the Codex isolated runtime imposes a default full-run
249
+ deadline: `THREADWIRE_WORKER_TIMEOUT_MS` (supervisor) and
250
+ `THREADWIRE_ISOLATED_RUNTIME_CLIENT_TIMEOUT_MS` (client) pass through empty
251
+ when unset, and an empty/unset value means a healthy running worker is never
252
+ stopped merely because time elapsed. Setting either to a positive millisecond
253
+ value enables one absolute preflight+run deadline. Broker grants are bounded
254
+ leases (default 60 seconds, `THREADWIRE_GRANT_LEASE_MS`, maximum one hour;
255
+ invalid, zero, or excessive values fail startup) that the supervisor renews
256
+ over the authenticated admin endpoint while the run is active; the grant
257
+ expires within one lease if
258
+ the supervisor dies. Unrelated safety timeouts (preflight slice, per-operation
259
+ Docker/network calls, state collection, emergency cleanup) are unchanged.
@@ -30,7 +30,11 @@ container-local tmp/run, resource limits, and exactly one writable
30
30
  worktree mount. It has no host home, common Git directory, sibling worktree,
31
31
  host temp, shared provider state, secret, Docker/SSH socket, or external route. Its
32
32
  short-lived broker grant is revoked during cleanup.
33
- The grant owns one absolute deadline and every accepted socket. Expiry,
33
+ The grant is issued as a bounded lease (default 60 seconds) that the
34
+ supervisor renews over the authenticated admin endpoint while its run is
35
+ active; cleanup still revokes it explicitly, and if the supervisor dies or
36
+ loses contact the broker's own expiry timer fires within one lease. The grant
37
+ owns every accepted socket. Expiry,
34
38
  explicit revoke, credential reload, or broker shutdown aborts an incomplete
35
39
  upload or upstream request, destroys accepted sockets, and closes the listener;
36
40
  authorization is checked again after the bounded request body is acquired.
@@ -69,15 +73,19 @@ expire without requiring a matching `/run`.
69
73
  Consumed capabilities enter a separate global/per-task active-run admission
70
74
  before validation can yield. The lineage reservation is acquired synchronously
71
75
  at that boundary, preventing periodic GC from deleting resume state during
72
- revalidation. Docker and broker operations have bounded deadlines, and each
73
- worker has a bounded operator-configured lifetime; timeout cleanup revokes the
76
+ revalidation. Docker and broker operations have bounded deadlines. There is no
77
+ default full-run deadline: a healthy running worker is never stopped merely
78
+ because time elapsed. An operator may set `THREADWIRE_WORKER_TIMEOUT_MS` to a
79
+ positive millisecond value to enable one absolute preflight+run deadline;
80
+ timeout cleanup revokes the
74
81
  grant and removes the exactly labelled worker and network before capacity is
75
82
  released.
76
- The relay creates one absolute launch deadline before preflight. The same
77
- deadline and cancellation signal span prompt/stdin and file-secret reads,
83
+ When the operator enables it, the relay creates one absolute launch deadline
84
+ before preflight. That
85
+ deadline and the cancellation signal span prompt/stdin and file-secret reads,
78
86
  evidence and Telegram setup, Docker setup, broker readiness, execution,
79
87
  response/log reads, upstream work and exact cleanup. A disconnected caller or
80
- supervisor shutdown aborts its run. Independent per-step timeouts cannot extend
88
+ supervisor shutdown aborts its run regardless. Independent per-step timeouts cannot extend
81
89
  the overall budget, and response bodies are stream-limited to 2 MiB.
82
90
 
83
91
  Codex bypasses its nested sandbox only inside this mandatory outer container.
@@ -105,10 +113,20 @@ credential. The supervisor alone receives `/var/run/docker.sock`. Restrict
105
113
  broker egress at the host/firewall to provider endpoints.
106
114
 
107
115
  The supervisor defaults to 16 active runs globally, two per reviewed task, a
108
- 30-second maximum preflight slice, and a one-hour end-to-end launch deadline. Operators may
109
- lower these finite limits with `THREADWIRE_PREFLIGHT_TIMEOUT_MS`,
116
+ 30-second maximum preflight slice, and no full-run deadline: an unset or empty
117
+ `THREADWIRE_WORKER_TIMEOUT_MS` (supervisor) or
118
+ `THREADWIRE_ISOLATED_RUNTIME_CLIENT_TIMEOUT_MS` (client) means a run may
119
+ continue until natural completion, caller cancellation/disconnect, supervisor
120
+ shutdown, or concrete failure. Setting either to a positive millisecond value
121
+ enables one absolute preflight+run deadline. Operators may
122
+ lower the finite limits with `THREADWIRE_PREFLIGHT_TIMEOUT_MS`,
110
123
  `THREADWIRE_ACTIVE_RUN_CAPACITY`, `THREADWIRE_ACTIVE_TASK_CAPACITY`, and
111
124
  `THREADWIRE_WORKER_TIMEOUT_MS`; invalid, zero, or excessive values fail startup.
125
+ Broker grants always use a bounded lease (default 60 seconds, tunable with
126
+ `THREADWIRE_GRANT_LEASE_MS`, maximum one hour) that the supervisor renews while
127
+ the run is active; the grant expires within one lease if the supervisor dies.
128
+ An invalid, zero, or excessive `THREADWIRE_GRANT_LEASE_MS` value fails startup
129
+ rather than being silently coerced.
112
130
 
113
131
  The authenticated registry persists through file fsync, atomic rename, and
114
132
  parent-directory fsync. Startup removes only strictly named abandoned temp
@@ -139,6 +157,10 @@ logs; worker self-report is not the sole assertion.
139
157
 
140
158
  ## Native Kimi Code subscription boundary
141
159
 
160
+ Kimi binding v1 is separate from the Codex worktree contract. A Kimi preflight contains `binding`, never `repositoryRoot` or `cwd`. The supervisor queries the task-DinD daemon for exactly one labelled source volume, context volume, and running lease, rejects ambiguity/RW context references, validates the lease and worker image, then uses an offline two-volume validator to check the real Git revision and complete immutable context manifest. Only after that does it create the per-run data plane on the task daemon: a private internal worker network, a task-owned non-internal egress network, one credential-free relay attached to both, and a three-volume Kimi worker attached only to the internal network. OAuth remains broker-only; workers receive a synthetic per-run grant and no raw binding, volume, lease, control, or OAuth value. Kimi v6 state seals include the binding digest, source/context identities, source mount mode, dynamic UID/GID/workdir, validator, worker, relay, relay image, both networks, the central endpoint identity, state, and image. Old path-based and pre-relay Kimi state is intentionally not adopted.
161
+
162
+ The binding's `source.readOnly` selects the exact admitted mount mode: `false` is a writable implementation source, `true` an explicitly read-only review source for Docker-enforced reviewers. Admission and revalidation pin the lease and worker source mounts to that exact mode, one-writer isolation is unchanged, and the worker entrypoint independently asserts the mounted mode from `/proc/self/mountinfo` before launch. Both the validator and the worker compare the mounted context manifest's task and image identity against the binding attestation; a mismatch fails the run closed and can never produce a successful client run.
163
+
142
164
  Kimi uses the same proven supervisor mechanics but a distinct protocol and
143
165
  credential service. `THREADWIRE_ISOLATED_PROVIDER=kimi` makes the supervisor
144
166
  accept only Kimi preflights, select only `THREADWIRE_KIMI_RELAY_WORKER_IMAGE`,
@@ -147,14 +169,54 @@ provider-bound state in the separate Kimi namespace. Codex requests, sessions,
147
169
  workers, grants, and volumes cannot be adopted by this service, and the Codex
148
170
  broker remains unchanged.
149
171
 
172
+ The central Kimi broker runs on an outer daemon, not the task daemon, so the
173
+ supervisor never resolves a broker container by name. It splits the broker into
174
+ an admin/control endpoint (grant lifecycle only) and a stable worker endpoint
175
+ (grant-auth chat proxy). For each run it creates a tiny credential-free relay
176
+ on the task daemon from the immutable `THREADWIRE_KIMI_MODEL_RELAY_IMAGE`
177
+ digest. The relay knows only the configured central worker URL
178
+ (`THREADWIRE_KIMI_MODEL_BROKER_WORKER_URL`) and its own listen port, receives
179
+ no grant, OAuth, admin token, binding, mount, or Docker socket, and is attached
180
+ to both the internal worker network and a dedicated egress network. The worker
181
+ reaches only the relay's internal address; the relay forwards the fixed
182
+ `POST /v1/chat/completions` shape to the central worker endpoint, preserving
183
+ the grant bearer. The configured worker URL is the stable worker origin: a bare
184
+ origin (for example `http://<gateway>:8792`) or the already-exact accepted path
185
+ is normalized to the fixed `/v1/chat/completions` path, and any other path is
186
+ rejected fail-closed at relay startup rather than forwarded verbatim. There is
187
+ no DNS or host-gateway fallback, no container-name resolution, and TLS
188
+ verification is never disabled.
189
+
190
+ Because the relay runs on the separate task daemon, the stable worker URL is
191
+ not defaulted: `THREADWIRE_KIMI_MODEL_BROKER_WORKER_URL` must be set to an
192
+ explicit address the task daemon can reach (for example the outer host's
193
+ task-DinD gateway), and the broker's published worker bind
194
+ (`THREADWIRE_KIMI_MODEL_BROKER_WORKER_BIND`) must likewise be chosen explicitly
195
+ to be reachable by that daemon. Outer-Compose DNS and outer-host loopback are
196
+ unreachable from the task daemon and are never assumed. These values are
197
+ load-time optional so a Codex-only relay-write profile renders and starts
198
+ without them; the Kimi runtime and client then fail closed at startup or job
199
+ launch when they are empty, so a Kimi deployment that does not supply an
200
+ explicit task-reachable URL and bind never becomes functional. When the bind
201
+ is unset, Compose renders the published worker port on a non-task-reachable
202
+ loopback sentinel (`127.0.0.1`) purely so the file loads — never on every
203
+ interface — and passes the raw setting through to the broker, which requires a
204
+ nonempty explicit bind before opening either listener. The sentinel is not an
205
+ operational default: without the explicit bind the broker exits instead of
206
+ serving. The
207
+ admin/control endpoint is never published — only the worker
208
+ port is, on the explicit bind — so admin and worker authority stay distinct.
209
+
150
210
  The Kimi broker is not a generic credential proxy. It accepts only
151
211
  `POST /v1/chat/completions`, validates a closed request schema and one approved
152
212
  wire model, substitutes OAuth immediately before the fixed official
153
213
  `https://api.kimi.com/coding/v1/chat/completions` request, and streams the
154
214
  bounded response. A grant is pending until the inspected worker is ready and is
155
215
  bound to provider, task hash, exact resume session (or fresh lineage), run,
156
- Docker network, approved alias, and wire model. Revocation, timeout, shutdown,
157
- or cleanup closes its private listener and active sockets.
216
+ Docker network, approved alias, and wire model. The grant is a bounded lease
217
+ renewed by the supervisor while the run is active. Revocation, lease expiry,
218
+ shutdown,
219
+ or cleanup rejects the grant and closes its active sockets.
158
220
 
159
221
  The immutable Kimi worker image pins `@moonshot-ai/kimi-code@0.29.2` and verifies
160
222
  the npm archive SHA-512 before installation. It receives no OAuth file, Docker
@@ -174,10 +236,26 @@ and OAuth material are discarded. The worker is the only reader of native Kimi
174
236
  stdout and Docker receives only sanitized envelopes and fixed lifecycle events;
175
237
  the supervisor returns `rawChunks: []` for every Kimi run.
176
238
 
239
+ The supervisor does not wait for worker completion before relaying output. It
240
+ follows the worker's multiplexed Docker logs, demultiplexes and validates each
241
+ stdout envelope as it arrives, and streams it to the relay caller over a single
242
+ framed NDJSON run response: `record` frames are written incrementally, then
243
+ exactly one `terminal` frame carries the final exit code. The client parses the
244
+ same NDJSON stream and forwards each validated event to `WorkerControl` as it
245
+ arrives, so long runs relay assistant/tool/lifecycle output to Telegram
246
+ incrementally. Session envelopes are the one exception: they are buffered and
247
+ only published once the run's success is known, so a resume hint followed by a
248
+ nonzero exit never exposes an unusable resume session. Stderr frames are
249
+ dropped, the same byte caps and conflicting-session rejection apply as the
250
+ buffered path, and a mid-stream
251
+ failure or cancellation still revokes the grant and removes the exact worker,
252
+ relay, and both networks before capacity is released.
253
+
177
254
  ### Operator OAuth and model approval
178
255
 
179
- Build and publish the `kimi-auth`, `kimi-model-broker`, `kimi-relay-worker`, and
180
- existing `isolated-runtime` targets as immutable digests. Set distinct random
256
+ Build and publish the `kimi-auth`, `kimi-model-broker`, `kimi-model-relay`,
257
+ `kimi-relay-worker`, and existing `isolated-runtime` targets as immutable
258
+ digests. Set distinct random
181
259
  Kimi runtime and broker admin tokens of at least 32 characters, the Kimi image digests, and an allowlist
182
260
  such as:
183
261
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadwire",
3
- "version": "0.1.11",
3
+ "version": "0.1.12",
4
4
  "description": "Stream Codex, Claude, Kimi Code, and OpenCode worker progress to an explicit Telegram destination",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -77,6 +77,7 @@ const EXPECTED_FILES = [
77
77
  "src/telegram-ingress/http.js",
78
78
  "src/telegram-ingress/update-guard.js",
79
79
  "src/telegram-webhook.js",
80
+ "src/threadwire-binding.js",
80
81
  "src/types.js",
81
82
  "src/worker-control.js",
82
83
  "src/workspace-profile.js",
@@ -3,7 +3,7 @@
3
3
 
4
4
  export class AbsoluteDeadline {
5
5
  constructor(expiresAt, {signal, request, response, now = Date.now, timeoutMessage = "Operation timeout", disconnectMessage = "Caller disconnected"} = {}) {
6
- if (!Number.isSafeInteger(expiresAt) || expiresAt <= now()) throw new Error(timeoutMessage)
6
+ if (expiresAt !== undefined && (!Number.isSafeInteger(expiresAt) || expiresAt <= now())) throw new Error(timeoutMessage)
7
7
  this.expiresAt = expiresAt
8
8
  this.now = now
9
9
  this.controller = new AbortController()
@@ -26,13 +26,14 @@ export class AbsoluteDeadline {
26
26
  request?.once?.("aborted", this.abortRequest)
27
27
  response?.once?.("close", this.abortResponse)
28
28
  this.socket?.once?.("close", this.abortSocket)
29
- this.timer = setTimeout(() => this.controller.abort(new Error(timeoutMessage)), Math.max(1, expiresAt - now()))
30
- this.timer.unref()
29
+ this.timer = expiresAt === undefined ? undefined : setTimeout(() => this.controller.abort(new Error(timeoutMessage)), Math.max(1, expiresAt - now()))
31
30
  this.signal.addEventListener("abort", () => request?.destroy?.(this.signal.reason), {once: true})
32
31
  if (request?.aborted || (response?.destroyed && !response.writableEnded)) this.abortRequest()
33
32
  }
34
33
  remaining() {
35
34
  this.throwIfAborted()
35
+ if (this.expiresAt === undefined) return undefined
36
+ if (this.expiresAt === undefined) return undefined
36
37
  const remaining = this.expiresAt - this.now()
37
38
  if (remaining <= 0) {
38
39
  this.controller.abort(new Error(this.timeoutMessage))
@@ -41,13 +42,14 @@ export class AbsoluteDeadline {
41
42
  return Math.max(1, remaining)
42
43
  }
43
44
  options() {
44
- return {signal: this.signal, timeoutMs: this.remaining()}
45
+ const timeoutMs = this.remaining()
46
+ return timeoutMs === undefined ? {signal: this.signal} : {signal: this.signal, timeoutMs}
45
47
  }
46
48
  throwIfAborted() {
47
49
  if (this.signal.aborted) throw this.signal.reason instanceof Error ? this.signal.reason : new Error("Operation aborted")
48
50
  }
49
51
  close() {
50
- clearTimeout(this.timer)
52
+ if (this.timer !== undefined) clearTimeout(this.timer)
51
53
  this.abortParent && this.parentSignal?.removeEventListener?.("abort", this.abortParent)
52
54
  this.request?.off?.("aborted", this.abortRequest)
53
55
  this.response?.off?.("close", this.abortResponse)
@@ -89,6 +91,7 @@ export async function readResponseCapped(response, capacity, signal) {
89
91
  }
90
92
  return Buffer.concat(chunks, size)
91
93
  } finally {
94
+ if (signal?.aborted) await reader.cancel(signal.reason).catch(() => {})
92
95
  reader.releaseLock()
93
96
  }
94
97
  }
package/src/cli.js CHANGED
@@ -16,6 +16,7 @@ import {WorkerControl} from "./worker-control.js"
16
16
  import {EvidenceStore} from "./evidence-store.js"
17
17
  import {ContextBudgetMetrics} from "./context-budget-metrics.js"
18
18
  import {isolatedRuntimeClientFromEnvironment, kimiIsolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
19
+ import {parseTrustedThreadwireBinding} from "./threadwire-binding.js"
19
20
  import {kimiSessionEnvelopeId} from "./providers/kimi.js"
20
21
  import {validateRelayWriteProviderArguments} from "./relay-write.js"
21
22
  import {abortable} from "./absolute-deadline.js"
@@ -199,10 +200,16 @@ export async function main(arguments_, dependencies = {}) {
199
200
  runAdmission = normalizedOutput === undefined ? undefined : new DelegatedResultAdmission({output: normalizedOutput, metrics})
200
201
  const target = parseTelegramTarget(parsed.target)
201
202
  if (parsed.relayWrite) validateRelayWriteProviderArguments(parsed.providerArguments)
203
+ const kimiBinding = parsed.provider === "kimi"
204
+ ? parseTrustedThreadwireBinding(sourceEnvironment.THREADWIRE_KIMI_TASK_BINDING)
205
+ : undefined
202
206
  const resolvedWorkspace = parsed.workspaceProfile === undefined
203
207
  ? undefined
204
208
  : await resolveWorkspaceProfile(
205
- {provider: /** @type {"codex" | "claude" | "kimi" | "opencode"} */ (parsed.provider), profile: parsed.workspaceProfile},
209
+ {
210
+ provider: /** @type {"codex" | "claude" | "kimi" | "opencode"} */ (parsed.provider), profile: parsed.workspaceProfile,
211
+ ...(kimiBinding === undefined ? {} : {binding: kimiBinding})
212
+ },
206
213
  dependencies.workspaceProfileOperations
207
214
  )
208
215
  if (validateOnly) return 0
@@ -216,8 +223,12 @@ export async function main(arguments_, dependencies = {}) {
216
223
  ? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).preflight({
217
224
  provider: parsed.provider,
218
225
  profile: /** @type {string} */ (parsed.workspaceProfile),
219
- repositoryRoot: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).repositoryRoot,
220
- cwd: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).cwd,
226
+ ...(parsed.provider === "kimi"
227
+ ? {binding: kimiBinding}
228
+ : {
229
+ repositoryRoot: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).repositoryRoot,
230
+ cwd: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).cwd
231
+ }),
221
232
  providerArguments: parsed.providerArguments,
222
233
  ...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession})
223
234
  })
package/src/docker-api.js CHANGED
@@ -60,13 +60,12 @@ export class DockerApi {
60
60
  const abort = () => request.destroy(options.signal.reason instanceof Error ? options.signal.reason : new Error("Docker request aborted"))
61
61
  if (options.signal?.aborted) abort()
62
62
  else options.signal?.addEventListener("abort", abort, {once: true})
63
- const deadline = setTimeout(
64
- () => request.destroy(new Error(`Docker API ${method} ${path} timed out`)),
65
- timeoutMs
66
- )
67
- deadline.unref()
63
+ const deadline = timeoutMs > 0
64
+ ? setTimeout(() => request.destroy(new Error(`Docker API ${method} ${path} timed out`)), timeoutMs)
65
+ : undefined
66
+ deadline?.unref()
68
67
  request.once("close", () => {
69
- clearTimeout(deadline)
68
+ if (deadline !== undefined) clearTimeout(deadline)
70
69
  options.signal?.removeEventListener("abort", abort)
71
70
  })
72
71
  if (payload !== undefined) request.end(payload)
@@ -77,13 +76,11 @@ export class DockerApi {
77
76
  version(options) { return this.request("GET", "/version", undefined, false, options) }
78
77
  inspectImage(image, options) { return this.request("GET", `/images/${encodeURIComponent(image)}/json`, undefined, false, options) }
79
78
  createNetwork(name, labels = {}, options) {
80
- return this.request("POST", "/networks/create", {
81
- Name: name,
82
- Internal: true,
83
- CheckDuplicate: true,
84
- EnableIPv6: false,
85
- Labels: {"org.threadwire.owner": "isolated-runtime", ...labels}
86
- }, false, options)
79
+ return createNetwork(this.request.bind(this), name, labels, true, options)
80
+ }
81
+ /** Create a task-owned non-internal egress network for the per-run Kimi relay. */
82
+ createEgressNetwork(name, labels = {}, options) {
83
+ return createNetwork(this.request.bind(this), name, labels, false, options)
87
84
  }
88
85
  removeNetwork(id, options) { return this.request("DELETE", `/networks/${encodeURIComponent(id)}`, undefined, false, options) }
89
86
  inspectNetwork(id, options) { return this.request("GET", `/networks/${encodeURIComponent(id)}`, undefined, false, options) }
@@ -108,8 +105,72 @@ export class DockerApi {
108
105
  return this.request("GET", `/networks?filters=${encodeURIComponent(JSON.stringify(filters))}`, undefined, false, options)
109
106
  }
110
107
  startContainer(id, options) { return this.request("POST", `/containers/${encodeURIComponent(id)}/start`, undefined, false, options) }
111
- waitContainer(id, options) { return this.request("POST", `/containers/${encodeURIComponent(id)}/wait?condition=not-running`, undefined, false, options) }
108
+ waitContainer(id, options = {}) {
109
+ return this.request("POST", `/containers/${encodeURIComponent(id)}/wait?condition=not-running`, undefined, false, {
110
+ ...options,
111
+ timeoutMs: options.timeoutMs ?? 0
112
+ })
113
+ }
112
114
  logs(id, options) { return this.request("GET", `/containers/${encodeURIComponent(id)}/logs?stdout=1&stderr=1`, undefined, true, options) }
115
+ /**
116
+ * Stream a container's multiplexed logs (stdout+stderr, follow) as raw bytes.
117
+ * Resolves once the follow stream ends (container stop) or rejects on abort,
118
+ * capacity overflow, or a Docker error. Each `onData` invocation carries one
119
+ * received chunk; framing/demultiplexing is the caller's responsibility.
120
+ * @param {string} id @param {(chunk: Buffer) => void} onData @param {{signal?: AbortSignal, timeoutMs?: number}} [options]
121
+ */
122
+ streamLogs(id, onData, options = {}) {
123
+ const timeoutMs = options.timeoutMs
124
+ const target = dockerTarget(this.host, `/containers/${encodeURIComponent(id)}/logs?stdout=1&stderr=1&follow=1`)
125
+ return new Promise((resolve, reject) => {
126
+ let bytes = 0
127
+ let settled = false
128
+ const fail = (error) => {
129
+ if (settled) return
130
+ settled = true
131
+ request.destroy()
132
+ reject(error)
133
+ }
134
+ const request = this.requestImplementation({...target, method: "GET", headers: {}}, (response) => {
135
+ const status = response.statusCode ?? 500
136
+ if (status < 200 || status >= 300) {
137
+ response.resume()
138
+ fail(new Error(`Docker API GET /containers/${id}/logs failed (${status})`))
139
+ return
140
+ }
141
+ response.on("data", (chunk) => {
142
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
143
+ bytes += buffer.length
144
+ if (bytes > 8_388_608) {
145
+ fail(new Error("Docker response exceeded capacity"))
146
+ return
147
+ }
148
+ onData(buffer)
149
+ })
150
+ response.on("end", () => {
151
+ if (settled) return
152
+ settled = true
153
+ resolve(undefined)
154
+ })
155
+ response.on("error", fail)
156
+ })
157
+ request.on("error", (error) => {
158
+ if (settled) return
159
+ settled = true
160
+ reject(error)
161
+ })
162
+ const abort = () => fail(options.signal.reason instanceof Error ? options.signal.reason : new Error("Docker request aborted"))
163
+ if (options.signal?.aborted) abort()
164
+ else options.signal?.addEventListener("abort", abort, {once: true})
165
+ const deadline = timeoutMs === undefined ? undefined : setTimeout(() => fail(new Error(`Docker API GET /containers/${id}/logs timed out`)), timeoutMs)
166
+ deadline?.unref()
167
+ request.once("close", () => {
168
+ if (deadline !== undefined) clearTimeout(deadline)
169
+ options.signal?.removeEventListener("abort", abort)
170
+ })
171
+ request.end()
172
+ })
173
+ }
113
174
  inspectContainer(id, options) { return this.request("GET", `/containers/${encodeURIComponent(id)}/json`, undefined, false, options) }
114
175
  removeContainer(id, options) { return this.request("DELETE", `/containers/${encodeURIComponent(id)}?force=1&v=1`, undefined, false, options) }
115
176
  createVolume(name, labels = {}, options) {
@@ -119,10 +180,35 @@ export class DockerApi {
119
180
  const filters = {label: Object.entries(labels).map(([key, value]) => `${key}=${value}`)}
120
181
  return this.request("GET", `/volumes?filters=${encodeURIComponent(JSON.stringify(filters))}`, undefined, false, options)
121
182
  }
183
+ /** List all containers so a volume reference can be inspected fail-closed. */
184
+ listVolumeReferences(volume, options) {
185
+ if (typeof volume !== "string" || volume.length === 0) throw new Error("Docker volume reference is invalid")
186
+ return this.listContainers({}, true, options).then(async (containers) => {
187
+ if (!Array.isArray(containers)) throw new Error("Docker volume references unavailable")
188
+ const references = []
189
+ for (const container of containers) {
190
+ if (!container || typeof container.Id !== "string") throw new Error("Docker volume references unavailable")
191
+ const inspection = await this.inspectContainer(container.Id, options)
192
+ const mounts = Array.isArray(inspection?.Mounts) ? inspection.Mounts : []
193
+ if (mounts.some((mount) => mount?.Type === "volume" && mount?.Name === volume)) references.push(inspection)
194
+ }
195
+ return references
196
+ })
197
+ }
122
198
  inspectVolume(name, options) { return this.request("GET", `/volumes/${encodeURIComponent(name)}`, undefined, false, options) }
123
199
  removeVolume(name, options) { return this.request("DELETE", `/volumes/${encodeURIComponent(name)}?force=0`, undefined, false, options) }
124
200
  }
125
201
 
202
+ function createNetwork(request, name, labels, internal, options) {
203
+ return request("POST", "/networks/create", {
204
+ Name: name,
205
+ Internal: internal,
206
+ CheckDuplicate: true,
207
+ EnableIPv6: false,
208
+ Labels: {"org.threadwire.owner": "isolated-runtime", ...labels}
209
+ }, false, options)
210
+ }
211
+
126
212
  function dockerTarget(host, path) {
127
213
  const url = new URL(host)
128
214
  if (url.protocol === "unix:") return {socketPath: url.pathname, path}