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 +10 -0
- package/README.md +10 -1
- package/TELEGRAM-INGRESS.md +2 -0
- package/docs/container-runtime.md +39 -3
- package/docs/isolated-provider-runtime.md +90 -12
- package/package.json +1 -1
- package/scripts/verify-package.js +1 -0
- package/src/absolute-deadline.js +8 -5
- package/src/cli.js +14 -3
- package/src/docker-api.js +100 -14
- package/src/isolated-runtime-client.js +116 -44
- package/src/isolated-runtime.js +673 -64
- package/src/isolated-state.js +62 -11
- package/src/isolated-worker.js +231 -23
- package/src/kimi-model-broker-policy.js +16 -5
- package/src/kimi-model-broker.js +134 -92
- package/src/model-broker-policy.js +11 -8
- package/src/model-broker.js +14 -0
- package/src/providers/kimi.js +15 -3
- package/src/telegram-ingress/core.js +7 -5
- package/src/telegram-webhook.js +10 -0
- package/src/threadwire-binding.js +192 -0
- package/src/workspace-profile.js +31 -8
- package/threadwire.workspace-profiles.json +3 -2
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).
|
|
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
|
|
package/TELEGRAM-INGRESS.md
CHANGED
|
@@ -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
|
|
210
|
-
`kimi-auth`, `kimi-model-broker`, `kimi-relay-worker`, and
|
|
211
|
-
|
|
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
|
|
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
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
109
|
-
|
|
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.
|
|
157
|
-
|
|
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
|
|
180
|
-
existing `isolated-runtime` targets as immutable
|
|
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
package/src/absolute-deadline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
220
|
-
|
|
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 =
|
|
64
|
-
() => request.destroy(new Error(`Docker API ${method} ${path} timed out`)),
|
|
65
|
-
|
|
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(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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}
|