threadwire 0.1.12 → 0.1.13
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 +15 -0
- package/README.md +7 -8
- package/TELEGRAM-INGRESS.md +7 -7
- package/docs/container-runtime.md +18 -34
- package/docs/isolated-provider-runtime.md +23 -20
- package/package.json +2 -3
- package/scripts/provider-shims/front-door.sh.template +1 -5
- package/scripts/verify-package.js +1 -3
- package/src/activity-log.js +1 -14
- package/src/cli.js +17 -55
- package/src/providers/index.js +1 -1
- package/src/providers/kimi.js +25 -5
- package/src/run-worker.js +5 -5
- package/src/telegram-ingress/config.js +14 -8
- package/src/telegram-ingress/core.js +27 -25
- package/src/telegram-ingress/http.js +0 -6
- package/src/telegram-webhook.js +11 -10
- package/src/workspace-profile.js +0 -212
- package/threadwire.workspace-profiles.json +0 -12
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.1.13 - 2026-08-01
|
|
6
|
+
|
|
5
7
|
- 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
8
|
- 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
9
|
- 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.
|
|
@@ -12,6 +14,19 @@
|
|
|
12
14
|
- 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
15
|
- 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
16
|
|
|
17
|
+
- Remove workspace profiles from normal provider execution. `--cwd` now passes
|
|
18
|
+
any caller-accessible directory directly to Codex, Claude, Kimi, and
|
|
19
|
+
OpenCode; no Git, source identity, task binding, or root-allowlist lookup is
|
|
20
|
+
performed. `--workspace-profile` is rejected—migrate invocations to
|
|
21
|
+
`--cwd <directory>`. Native Kimi is the default path, supports
|
|
22
|
+
`THREADWIRE_KIMI_BIN`, translates exact `--resume-session` values to Kimi's
|
|
23
|
+
native `--session` option, and passes compatible provider arguments while
|
|
24
|
+
Threadwire retains prompt and stream-output ownership. The optional isolated
|
|
25
|
+
Kimi path remains explicit and fail-closed without native fallback.
|
|
26
|
+
- Preserve only the explicit caller-owned native Kimi configuration contract,
|
|
27
|
+
report exactly one completed or failed terminal lifecycle outcome, and reject
|
|
28
|
+
provider option terminators that could neutralize Threadwire-owned arguments.
|
|
29
|
+
|
|
15
30
|
## 0.1.10 - 2026-07-28
|
|
16
31
|
|
|
17
32
|
- Accept official slash-qualified Kimi model aliases through workspace-free
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Threadwire
|
|
2
2
|
|
|
3
|
-
Threadwire is a Node.js CLI that launches a Codex, Claude, or OpenCode worker and streams a concise, human-readable activity feed to one explicitly selected Telegram destination: a direct message, normal chat, or forum topic. The default feed includes the assistant's response prose, lifecycle outcomes, and diagnostics. Provider tool-status messages are disabled by default.
|
|
3
|
+
Threadwire is a Node.js CLI that launches a Codex, Claude, Kimi, or OpenCode worker and streams a concise, human-readable activity feed to one explicitly selected Telegram destination: a direct message, normal chat, or forum topic. The default feed includes the assistant's response prose, lifecycle outcomes, and diagnostics. Provider tool-status messages are disabled by default.
|
|
4
4
|
|
|
5
5
|
For the container-native webhook deployment, including its named-volume worktree,
|
|
6
6
|
non-root Codex provider, Docker secrets, loopback TLS proxy, verification, and
|
|
@@ -8,8 +8,7 @@ rollback path, see [Container runtime](docs/container-runtime.md). The legacy
|
|
|
8
8
|
host launcher below remains supported and is separate from that deployment.
|
|
9
9
|
|
|
10
10
|
Relay-originated Codex writes are explicit and default off. `--relay-write`
|
|
11
|
-
|
|
12
|
-
and credential broker. The worker gets one writable task worktree, no upstream
|
|
11
|
+
selects the optional root-owned isolated runtime and credential broker. The worker gets one writable task worktree, no upstream
|
|
13
12
|
or Git credential, and no route except its run-scoped broker. Missing
|
|
14
13
|
prerequisites fail before prompt or credential reads with no native fallback.
|
|
15
14
|
The isolated runtime imposes no default full-run deadline: a healthy running
|
|
@@ -71,11 +70,11 @@ Use the local launcher. Install or refresh it after updating Threadwire with `np
|
|
|
71
70
|
|
|
72
71
|
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.
|
|
73
72
|
|
|
74
|
-
|
|
73
|
+
`--cwd` is the complete workspace contract for every provider. It may name any caller-accessible directory, including a non-Git directory. Threadwire passes it unchanged to the native provider and performs no profile, repository, source-identity, task-binding, or root-allowlist lookup. `--workspace-profile` has been removed and is rejected; migrate invocations by replacing it with `--cwd <directory>`.
|
|
75
74
|
|
|
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
|
|
75
|
+
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 and session options owned by Threadwire. Native Kimi uses the caller's compatible configuration. Threadwire maps its public `--resume-session <exact-id>` option to Kimi's native `--session <exact-id>` option and owns `--prompt` plus `--output-format stream-json`. The caller environment or image must provide the Kimi executable at `/usr/local/bin/kimi` or set `THREADWIRE_KIMI_BIN`; a missing executable fails normally.
|
|
77
76
|
|
|
78
|
-
Threadwire directly spawns the structurally separate terminal adapters `/opt/data/libexec/threadwire/codex`, `/opt/data/libexec/threadwire/claude`, or `/opt/data/libexec/threadwire/opencode-local-fleet` with an argv array and `shell: false`. These libexec adapters hold the real provider-CLI logic and are never on a worker's `PATH`, so relaying can never recurse back into the front-door shims. Each spawn also sets `THREADWIRE_ACTIVE=1` in the provider child environment. The executable can be overridden per provider with `THREADWIRE_CODEX_BIN`, `THREADWIRE_CLAUDE_BIN`, or `THREADWIRE_OPENCODE_BIN` for staged cutover and rollback. It does not impose a worker timeout. `SIGINT` and `SIGTERM` are forwarded to the worker as explicit cancellation.
|
|
77
|
+
Threadwire directly spawns the structurally separate terminal adapters `/opt/data/libexec/threadwire/codex`, `/opt/data/libexec/threadwire/claude`, `/usr/local/bin/kimi`, or `/opt/data/libexec/threadwire/opencode-local-fleet` with an argv array and `shell: false`. These libexec adapters hold the real provider-CLI logic and are never on a worker's `PATH`, so relaying can never recurse back into the front-door shims. Each spawn also sets `THREADWIRE_ACTIVE=1` in the provider child environment. The executable can be overridden per provider with `THREADWIRE_CODEX_BIN`, `THREADWIRE_CLAUDE_BIN`, `THREADWIRE_KIMI_BIN`, or `THREADWIRE_OPENCODE_BIN` for staged cutover and rollback. It does not impose a worker timeout. `SIGINT` and `SIGTERM` are forwarded to the worker as explicit cancellation.
|
|
79
78
|
|
|
80
79
|
## Provider front-door shims
|
|
81
80
|
|
|
@@ -83,11 +82,11 @@ Threadwire directly spawns the structurally separate terminal adapters `/opt/dat
|
|
|
83
82
|
|
|
84
83
|
The relay contract: the caller supplies `THREADWIRE_TARGET` (`telegram:<chat-id>` or `telegram:<chat-id>:<thread-id>`) and, optionally, `THREADWIRE_PROCESS_NUMBER`. The prompt arrives on a piped stdin; all native provider flags pass through after `--`. A positional prompt is never parsed. A missing `THREADWIRE_TARGET` fails closed (exit code 2) and a malformed one is rejected by the launcher's token-free preflight — the shim never invents a default destination and never silently downgrades a requested relay to a direct run. A shim relays only for a worker mode (`codex exec …`, `opencode-local-fleet run …`, or any non-interactive `claude …`); it drops the worker subcommand before relaying and Threadwire re-adds it around the prompt. Version and help queries, non-worker subcommands, interactive TTY sessions, and any invocation already inside a relayed run (`THREADWIRE_ACTIVE=1`, so nested provider workers stay local/native) run the real CLI directly through the libexec adapter.
|
|
85
84
|
|
|
86
|
-
Pass `--resume-session <exact-provider-session-id>` to continue a stored Codex, Claude, or OpenCode session. Threadwire preserves the provider's native resume ordering and streaming options and never uses implicit latest-session behavior.
|
|
85
|
+
Pass `--resume-session <exact-provider-session-id>` to continue a stored Codex, Claude, Kimi, or OpenCode session. Threadwire preserves the provider's native resume ordering and streaming options and never uses implicit latest-session behavior.
|
|
87
86
|
|
|
88
87
|
Pass `--transcript <path>` to capture the exact normalized, caller-facing JSONL protocol written to run stdout. The file is created privately (mode `0600`), must not already exist, and contains the same records in the same byte order as stdout. It never contains provider JSONL, reasoning, tool payloads, prompts, stderr, or credentials.
|
|
89
88
|
|
|
90
|
-
`--activity-log <path>` is different: it retains its append contract and writes only minimal safe lifecycle facts—
|
|
89
|
+
`--activity-log <path>` is different: it retains its append contract and writes only minimal safe lifecycle facts—provider start (`provider`, child `pid`) and available native session ID. It is local-only and never enters Telegram or normalized stdout.
|
|
91
90
|
|
|
92
91
|
Provider stdout is decoded as JSONL with a finite 1 MiB UTF-8 byte limit for each pending unterminated record. Exceeding it rejects the run, removes listeners and signal handlers, and terminates the provider without parsing or rendering the oversized content.
|
|
93
92
|
|
package/TELEGRAM-INGRESS.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Telegram Ingress for Threadwire
|
|
2
2
|
|
|
3
3
|
Standalone Threadwire webhook service that accepts Telegram Bot API updates.
|
|
4
|
-
Codex, Claude, and OpenCode
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
Codex, Claude, Kimi, and OpenCode use their native provider adapter by default.
|
|
5
|
+
An operator may explicitly configure the Kimi isolated client; that selected
|
|
6
|
+
path remains fail-closed and never falls back to native execution. Hermes is
|
|
7
|
+
not involved.
|
|
8
8
|
|
|
9
9
|
## Important bot ownership
|
|
10
10
|
|
|
@@ -83,9 +83,9 @@ Optional:
|
|
|
83
83
|
| `THREADWIRE_TELEGRAM_REQUEST_TIMEOUT_MS` | Deadline for each outbound Telegram request, including response parsing (default `30000`, positive safe integer no greater than `2147483647`) |
|
|
84
84
|
| `THREADWIRE_UPDATE_ID_CAPACITY` | Max retained completed `update_id` entries (default `10000`) |
|
|
85
85
|
| `THREADWIRE_UPDATE_ID_TTL_MS` | Retention TTL for completed `update_id` entries in ms (default `86400000`) |
|
|
86
|
-
| `THREADWIRE_KIMI_ISOLATED_RUNTIME_URL` |
|
|
87
|
-
| `THREADWIRE_KIMI_ISOLATED_RUNTIME_CONTROL_TOKEN` |
|
|
88
|
-
| `THREADWIRE_KIMI_TASK_BINDING` |
|
|
86
|
+
| `THREADWIRE_KIMI_ISOLATED_RUNTIME_URL` | Optional dedicated Kimi supervisor URL, used only with an explicit control token. |
|
|
87
|
+
| `THREADWIRE_KIMI_ISOLATED_RUNTIME_CONTROL_TOKEN` | Selects the optional dedicated Kimi isolation client. Without it, `/code kimi` runs natively. |
|
|
88
|
+
| `THREADWIRE_KIMI_TASK_BINDING` | Optional trusted binding used only by the explicitly selected Kimi isolation client; native Kimi never reads it. |
|
|
89
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. |
|
|
90
90
|
|
|
91
91
|
## Run
|
|
@@ -84,16 +84,13 @@ docker compose exec -T threadwire sh -c 'test "$(id -u):$(id -g)" = 10001:10001
|
|
|
84
84
|
|
|
85
85
|
Telegram commands derive their explicit destination from the authenticated
|
|
86
86
|
update. A credentialed direct smoke test must also name a target explicitly and
|
|
87
|
-
keeps both credentials mounted as secret files.
|
|
88
|
-
|
|
89
|
-
`/workspace/threadwire` worktree inside the current runtime, so `threadwire run`
|
|
90
|
-
selects that reviewed in-container workspace directly rather than deriving a
|
|
91
|
-
path from ambient host state or Telegram input:
|
|
87
|
+
keeps both credentials mounted as secret files. Direct execution supplies the
|
|
88
|
+
caller-accessible working directory explicitly:
|
|
92
89
|
|
|
93
90
|
```sh
|
|
94
91
|
node bin/threadwire.js run \
|
|
95
92
|
--provider codex --target telegram:-1001234567890:42 \
|
|
96
|
-
--workspace
|
|
93
|
+
--cwd /workspace/threadwire \
|
|
97
94
|
--prompt 'Inspect the container worktree.'
|
|
98
95
|
```
|
|
99
96
|
|
|
@@ -114,27 +111,11 @@ permissions that deny the provider, or move it to a separate container or
|
|
|
114
111
|
sandbox. The same-UID, same-container, capability-free design cannot truthfully
|
|
115
112
|
provide that stronger boundary.
|
|
116
113
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
The same reviewed profile file also governs authenticated webhook launches. The
|
|
124
|
-
default profile is selected server-side; Telegram message text never supplies a
|
|
125
|
-
raw path, container identifier, executable path, Git URL, or environment
|
|
126
|
-
assignment. Before every spawn, Threadwire verifies that the configured
|
|
127
|
-
workspace still exists, is still a Git worktree at the configured root, and
|
|
128
|
-
still has a stable revision/worktree identity. If the worktree is removed or
|
|
129
|
-
unhealthy, spawn fails before the provider starts so Telegram can retry after
|
|
130
|
-
remediation.
|
|
131
|
-
|
|
132
|
-
Provision profile changes and revision updates entirely inside the container
|
|
133
|
-
worktree. Edit `threadwire.workspace-profiles.json` in `/workspace/threadwire`,
|
|
134
|
-
commit the reviewed change in that worktree, and let the existing bootstrap and
|
|
135
|
-
rollback flow move the whole repository plus its `.threadwire-source-identity`
|
|
136
|
-
forward or backward together. Do not introduce host-only paths, Docker CLI
|
|
137
|
-
arguments, or Compose-generated container names into the profile file.
|
|
114
|
+
`--workspace-profile` is removed and rejected. Threadwire does not inspect Git,
|
|
115
|
+
source identity, task binding, or workspace allowlists for normal provider
|
|
116
|
+
launches. Telegram ingress uses its own process working directory unless its
|
|
117
|
+
operator supplies another caller-accessible directory through the embedding
|
|
118
|
+
configuration; Telegram message text never supplies a path.
|
|
138
119
|
|
|
139
120
|
## Verification and leakage audit
|
|
140
121
|
|
|
@@ -205,10 +186,13 @@ Never export or archive mounted secret files with workspace/provider backups.
|
|
|
205
186
|
|
|
206
187
|
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
188
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
189
|
+
The checked-in standard `runtime` image installs Codex only: it does not install
|
|
190
|
+
Kimi or provide Kimi credentials. Native Kimi therefore requires a caller image
|
|
191
|
+
or environment that provides `/usr/local/bin/kimi` (or sets
|
|
192
|
+
`THREADWIRE_KIMI_BIN`); a missing executable fails normally. Never mount
|
|
193
|
+
`threadwire-kimi-oauth`, its access/refresh tokens, or the auth home into the
|
|
194
|
+
Threadwire runtime, supervisor, worker, workspace, or session-state volume.
|
|
195
|
+
The optional isolated Kimi flow uses five immutable build targets:
|
|
212
196
|
`kimi-auth`, `kimi-model-broker`, `kimi-model-relay`, `kimi-relay-worker`, and
|
|
213
197
|
`isolated-runtime`.
|
|
214
198
|
The first two share UID/GID 10003 and only the OAuth volume; the credential-free
|
|
@@ -239,9 +223,9 @@ listener until an explicit nonempty bind is configured, so the sentinel is
|
|
|
239
223
|
never an operational default.
|
|
240
224
|
Only the worker port is published, on that bind — the admin endpoint stays on
|
|
241
225
|
the internal control network.
|
|
242
|
-
The
|
|
243
|
-
|
|
244
|
-
|
|
226
|
+
The optional Kimi supervisor is selected only by its dedicated control
|
|
227
|
+
token/client. Without that explicit selection, CLI and Telegram Kimi jobs use
|
|
228
|
+
the native child path and caller/provider configuration. Keep broker egress
|
|
245
229
|
restricted to official Kimi Code subscription/auth endpoints and do not provide
|
|
246
230
|
a Moonshot API key, custom base URL, Docker socket, or ingress secret.
|
|
247
231
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Isolated provider runtime
|
|
2
2
|
|
|
3
3
|
Relay-originated Codex writes are disabled unless the caller supplies both
|
|
4
|
-
`--relay-write` and
|
|
4
|
+
`--relay-write` and an explicitly selected supervisor. The write path never
|
|
5
5
|
falls back to the native provider launcher.
|
|
6
6
|
When the Telegram service is configured with isolated-runtime authority, Codex
|
|
7
7
|
webhook jobs use the same preflight/run boundary exclusively; an unavailable
|
|
@@ -155,7 +155,15 @@ by that script. E2E uses host-volume canaries and fake-upstream authorization
|
|
|
155
155
|
logs; worker self-report is not the sole assertion.
|
|
156
156
|
|
|
157
157
|
|
|
158
|
-
##
|
|
158
|
+
## Optional isolated Kimi Code subscription boundary
|
|
159
|
+
|
|
160
|
+
Normal `threadwire run --provider kimi` is native execution: it directly runs
|
|
161
|
+
the caller-provided Kimi executable at `/usr/local/bin/kimi`, or the path in
|
|
162
|
+
`THREADWIRE_KIMI_BIN`. It does not select this isolated supervisor, broker, or
|
|
163
|
+
model allowlist. The checked-in standard `runtime` image installs Codex only,
|
|
164
|
+
so native Kimi needs a caller-supplied executable and fails normally when it is
|
|
165
|
+
missing. The remaining section documents the separately configured, optional
|
|
166
|
+
isolated Kimi supervisor.
|
|
159
167
|
|
|
160
168
|
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
169
|
|
|
@@ -226,7 +234,8 @@ Its fixed entrypoint owns `--model`, optional exact `--session`, `--prompt`,
|
|
|
226
234
|
`--output-format stream-json`, and an empty skills directory. Static deny rules,
|
|
227
235
|
workspace/config ancestry checks, a sanitized private home, read-only root,
|
|
228
236
|
and the existing mount/inode checks remain mandatory. There is no native unsafe
|
|
229
|
-
fallback
|
|
237
|
+
fallback within the explicitly selected isolated path. Normal Kimi execution
|
|
238
|
+
uses the native adapter and may use `THREADWIRE_KIMI_BIN`.
|
|
230
239
|
|
|
231
240
|
Only exact pinned stream records are normalized: assistant string content,
|
|
232
241
|
validated tool name/key start and finish, and one exact
|
|
@@ -263,7 +272,8 @@ such as:
|
|
|
263
272
|
export THREADWIRE_ALLOWED_KIMI_MODELS='{"default":{"model":"kimi-for-coding","protocol":"kimi"},"kimi-code/kimi-for-coding":{"model":"kimi-for-coding","protocol":"kimi"}}'
|
|
264
273
|
```
|
|
265
274
|
|
|
266
|
-
`default` is the server-owned selection for
|
|
275
|
+
`default` is the server-owned selection for isolated supervisor jobs without a
|
|
276
|
+
model argument.
|
|
267
277
|
`kimi-code/kimi-for-coding` is the exact official provider-qualified alias for
|
|
268
278
|
explicit callers. Each key is an opaque allowlisted value, not a path, and
|
|
269
279
|
Threadwire never normalizes or strips its prefix. The `model` field is the
|
|
@@ -272,8 +282,8 @@ alias.
|
|
|
272
282
|
|
|
273
283
|
OAuth writes only the named `threadwire-kimi-oauth` volume at the official
|
|
274
284
|
`credentials/kimi-code.json` location. Stop the broker during interactive auth
|
|
275
|
-
so the CLI and broker cannot race a refresh, then run the opt-in,
|
|
276
|
-
auth service:
|
|
285
|
+
so the auth CLI and broker cannot race a refresh, then run the opt-in,
|
|
286
|
+
workspace-free auth service:
|
|
277
287
|
|
|
278
288
|
```sh
|
|
279
289
|
docker compose -f compose.yaml -f compose.relay-write.yaml stop kimi-isolated-runtime kimi-model-broker
|
|
@@ -288,20 +298,12 @@ The auth service has no workspace, ingress network, Docker socket, Threadwire
|
|
|
288
298
|
control token, or Telegram credential and uses Docker logging driver `none`.
|
|
289
299
|
`verify <provider-qualified-alias>` discards native output and prints only a
|
|
290
300
|
fixed success/failure; use it to confirm each exact official alias before
|
|
291
|
-
adding that alias and its independently verified wire model to the
|
|
292
|
-
allowlist.
|
|
293
|
-
|
|
294
|
-
```sh
|
|
295
|
-
node bin/threadwire.js run \
|
|
296
|
-
--provider kimi \
|
|
297
|
-
--target telegram:-1001234567890:42 \
|
|
298
|
-
--workspace-profile container-runtime \
|
|
299
|
-
--prompt 'Inspect the project.' \
|
|
300
|
-
-- --model 'kimi-code/kimi-for-coding'
|
|
301
|
-
```
|
|
301
|
+
adding that alias and its independently verified wire model to the isolated
|
|
302
|
+
supervisor allowlist. This operator configuration does not configure or select
|
|
303
|
+
the native CLI Kimi model.
|
|
302
304
|
|
|
303
|
-
API keys, Moonshot platform mode, custom base
|
|
304
|
-
and arbitrary aliases are unsupported.
|
|
305
|
+
For the isolated supervisor, API keys, Moonshot platform mode, custom base
|
|
306
|
+
URLs, discovery by ordinary jobs, and arbitrary aliases are unsupported.
|
|
305
307
|
|
|
306
308
|
For logout or account rotation, stop the Kimi runtime and broker, run
|
|
307
309
|
`--profile kimi-auth run --rm kimi-auth logout` or `login`, verify approved
|
|
@@ -322,7 +324,8 @@ payload, or synthetic grant.
|
|
|
322
324
|
|
|
323
325
|
Rollback by stopping `kimi-isolated-runtime` and `kimi-model-broker`, removing
|
|
324
326
|
the `kimi` Compose profile/control variables, and reverting the Threadwire image.
|
|
325
|
-
Kimi
|
|
327
|
+
Isolated Kimi requests then fail closed; native Kimi remains independent of
|
|
328
|
+
that optional deployment. Codex behavior is unaffected. Preserve the OAuth
|
|
326
329
|
and Kimi state volumes for a recoverable rollback. Remove them only as an
|
|
327
330
|
explicit credential/session teardown after OAuth revocation; never archive them
|
|
328
331
|
with workspace backups.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "threadwire",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Stream Codex, Claude, Kimi Code, and OpenCode worker progress to an explicit Telegram destination",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
@@ -20,8 +20,7 @@
|
|
|
20
20
|
"docs/",
|
|
21
21
|
"scripts/",
|
|
22
22
|
"src/",
|
|
23
|
-
"TELEGRAM-INGRESS.md"
|
|
24
|
-
"threadwire.workspace-profiles.json"
|
|
23
|
+
"TELEGRAM-INGRESS.md"
|
|
25
24
|
],
|
|
26
25
|
"bin": {
|
|
27
26
|
"threadwire-isolated-runtime": "bin/isolated-runtime.js",
|
|
@@ -58,15 +58,11 @@ threadwire_relay_arguments=(run --provider "$THREADWIRE_PROVIDER" --target "$THR
|
|
|
58
58
|
case "${THREADWIRE_RELAY_WRITE:-0}" in
|
|
59
59
|
0) ;;
|
|
60
60
|
1)
|
|
61
|
-
if [[ ! "${THREADWIRE_WORKSPACE_PROFILE:-}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then
|
|
62
|
-
printf '%s\n' 'threadwire: relay write capability requires a valid THREADWIRE_WORKSPACE_PROFILE' >&2
|
|
63
|
-
exit 2
|
|
64
|
-
fi
|
|
65
61
|
if [[ "$THREADWIRE_PROVIDER" != codex ]]; then
|
|
66
62
|
printf '%s\n' 'threadwire: relay write capability is only supported for codex' >&2
|
|
67
63
|
exit 2
|
|
68
64
|
fi
|
|
69
|
-
threadwire_relay_arguments+=(--
|
|
65
|
+
threadwire_relay_arguments+=(--relay-write)
|
|
70
66
|
;;
|
|
71
67
|
*)
|
|
72
68
|
printf '%s\n' 'threadwire: relay write capability must be enabled with THREADWIRE_RELAY_WRITE=1' >&2
|
|
@@ -79,9 +79,7 @@ const EXPECTED_FILES = [
|
|
|
79
79
|
"src/telegram-webhook.js",
|
|
80
80
|
"src/threadwire-binding.js",
|
|
81
81
|
"src/types.js",
|
|
82
|
-
"src/worker-control.js"
|
|
83
|
-
"src/workspace-profile.js",
|
|
84
|
-
"threadwire.workspace-profiles.json"
|
|
82
|
+
"src/worker-control.js"
|
|
85
83
|
]
|
|
86
84
|
|
|
87
85
|
/** @returns {Promise<void>} */
|
package/src/activity-log.js
CHANGED
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
import {closeSync, openSync, writeSync} from "node:fs"
|
|
4
4
|
|
|
5
5
|
const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/u
|
|
6
|
-
const PROFILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u
|
|
7
|
-
const REVISION_PATTERN = /^[0-9a-f]{40}$/u
|
|
8
|
-
const SOURCE_IDENTITY_PATTERN = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/u
|
|
9
6
|
|
|
10
7
|
export class ActivityLog {
|
|
11
8
|
/** @param {string} path */
|
|
@@ -14,16 +11,6 @@ export class ActivityLog {
|
|
|
14
11
|
this.closed = false
|
|
15
12
|
}
|
|
16
13
|
|
|
17
|
-
/** @param {string} profile @param {string} repositoryRoot @param {string} revision @param {string} sourceIdentity */
|
|
18
|
-
recordWorkspace(profile, repositoryRoot, revision, sourceIdentity) {
|
|
19
|
-
if (!PROFILE_PATTERN.test(profile)) throw new Error("Workspace profile is invalid")
|
|
20
|
-
if (typeof repositoryRoot !== "string" || repositoryRoot.length === 0) throw new Error("Workspace repository root is invalid")
|
|
21
|
-
if (!REVISION_PATTERN.test(revision) || !SOURCE_IDENTITY_PATTERN.test(sourceIdentity)) {
|
|
22
|
-
throw new Error("Workspace provenance is invalid")
|
|
23
|
-
}
|
|
24
|
-
this.write({type: "workspace-selected", profile, repositoryRoot, revision, sourceIdentity})
|
|
25
|
-
}
|
|
26
|
-
|
|
27
14
|
/** @param {"codex" | "claude" | "kimi" | "opencode"} provider @param {number} pid */
|
|
28
15
|
recordStarted(provider, pid) {
|
|
29
16
|
if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("Provider child PID is unavailable")
|
|
@@ -42,7 +29,7 @@ export class ActivityLog {
|
|
|
42
29
|
closeSync(this.fileDescriptor)
|
|
43
30
|
}
|
|
44
31
|
|
|
45
|
-
/** @param {{type: "
|
|
32
|
+
/** @param {{type: "provider-started", provider: "codex" | "claude" | "kimi" | "opencode", pid: number} | {type: "session-available", provider: "codex" | "claude" | "kimi" | "opencode", sessionId: string}} fact */
|
|
46
33
|
write(fact) {
|
|
47
34
|
if (this.closed) throw new Error("Activity log is closed")
|
|
48
35
|
writeSync(this.fileDescriptor, `${JSON.stringify(fact)}\n`)
|
package/src/cli.js
CHANGED
|
@@ -11,20 +11,16 @@ import {runWorker} from "./run-worker.js"
|
|
|
11
11
|
import {ActivityLog} from "./activity-log.js"
|
|
12
12
|
import {DelegatedResultAdmission, validateContinuationHandle} from "./delegated-result-admission.js"
|
|
13
13
|
import {buildProviderEnvironment, collectEvidenceRedactions, parseTelegramRequestTimeoutMs, resolveFileBackedSettings} from "./telegram-ingress/config.js"
|
|
14
|
-
import {resolveWorkspaceProfile} from "./workspace-profile.js"
|
|
15
14
|
import {WorkerControl} from "./worker-control.js"
|
|
16
15
|
import {EvidenceStore} from "./evidence-store.js"
|
|
17
16
|
import {ContextBudgetMetrics} from "./context-budget-metrics.js"
|
|
18
|
-
import {isolatedRuntimeClientFromEnvironment
|
|
19
|
-
import {parseTrustedThreadwireBinding} from "./threadwire-binding.js"
|
|
20
|
-
import {kimiSessionEnvelopeId} from "./providers/kimi.js"
|
|
17
|
+
import {isolatedRuntimeClientFromEnvironment} from "./isolated-runtime-client.js"
|
|
21
18
|
import {validateRelayWriteProviderArguments} from "./relay-write.js"
|
|
22
19
|
import {abortable} from "./absolute-deadline.js"
|
|
23
20
|
import {NormalizedOutput} from "./normalized-output.js"
|
|
24
21
|
|
|
25
22
|
const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
|
|
26
23
|
[--process-number <positive-integer>] [--cwd <directory>]
|
|
27
|
-
[--workspace-profile <name>]
|
|
28
24
|
[--relay-write]
|
|
29
25
|
[--tool-messages] [--max-output-length <positive-integer>]
|
|
30
26
|
[--resume-session <provider-session-id>] [--transcript <normalized-jsonl-path>]
|
|
@@ -34,7 +30,7 @@ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --ta
|
|
|
34
30
|
threadwire evidence read --handle <opaque-handle>
|
|
35
31
|
(--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)`
|
|
36
32
|
|
|
37
|
-
/** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, relayWrite: boolean,
|
|
33
|
+
/** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, relayWrite: boolean, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, transcript?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
|
|
38
34
|
/** @typedef {{evidenceRead: true, request: unknown}} EvidenceParsedArguments */
|
|
39
35
|
/**
|
|
40
36
|
* @typedef {{
|
|
@@ -47,9 +43,7 @@ const HELP = `Usage: threadwire run --provider <codex|claude|kimi|opencode> --ta
|
|
|
47
43
|
* evidenceStore?: EvidenceStore,
|
|
48
44
|
* evidenceOwnerScope?: {destinationId: string, runId: string},
|
|
49
45
|
* workerControlOptions?: Pick<ConstructorParameters<typeof WorkerControl>[0], "queueOptions">,
|
|
50
|
-
*
|
|
51
|
-
* isolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">,
|
|
52
|
-
* kimiIsolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">
|
|
46
|
+
* isolatedRuntimeClient?: Pick<import("./isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">
|
|
53
47
|
* }} MainDependencies
|
|
54
48
|
*/
|
|
55
49
|
|
|
@@ -63,7 +57,6 @@ export function parseArguments(arguments_) {
|
|
|
63
57
|
const providerArguments = separator < 0 ? [] : arguments_.slice(separator + 1)
|
|
64
58
|
/** @type {Partial<ParsedArguments>} */
|
|
65
59
|
const parsed = {providerArguments, cwd: process.cwd(), toolMessages: false, relayWrite: false}
|
|
66
|
-
let cwdProvided = false
|
|
67
60
|
let transcriptProvided = false
|
|
68
61
|
for (let index = 0; index < ownArguments.length;) {
|
|
69
62
|
const option = ownArguments[index]
|
|
@@ -86,8 +79,7 @@ export function parseArguments(arguments_) {
|
|
|
86
79
|
else if (option === "--target") parsed.target = value
|
|
87
80
|
else if (option === "--cwd") {
|
|
88
81
|
parsed.cwd = resolve(value)
|
|
89
|
-
|
|
90
|
-
} else if (option === "--workspace-profile") parsed.workspaceProfile = value
|
|
82
|
+
} else if (option === "--workspace-profile") throw new Error("--workspace-profile is no longer supported; use --cwd")
|
|
91
83
|
else if (option === "--prompt") parsed.prompt = value
|
|
92
84
|
else if (option === "--prompt-file") parsed.promptFile = resolve(value)
|
|
93
85
|
else if (option === "--process-number") parsed.processNumber = positiveInteger(value, "--process-number")
|
|
@@ -108,14 +100,9 @@ export function parseArguments(arguments_) {
|
|
|
108
100
|
}
|
|
109
101
|
if (!parsed.target) throw new Error("--target is required")
|
|
110
102
|
if (parsed.prompt !== undefined && parsed.promptFile !== undefined) throw new Error("Use exactly one prompt source")
|
|
111
|
-
if (parsed.workspaceProfile !== undefined && cwdProvided) {
|
|
112
|
-
throw new Error("--cwd and --workspace-profile are mutually exclusive")
|
|
113
|
-
}
|
|
114
103
|
if (parsed.transcript !== undefined && parsed.transcript === parsed.activityLog) {
|
|
115
104
|
throw new Error("--transcript and --activity-log must resolve to different paths")
|
|
116
105
|
}
|
|
117
|
-
if (parsed.relayWrite && parsed.workspaceProfile === undefined) throw new Error("--relay-write requires --workspace-profile")
|
|
118
|
-
if (parsed.provider === "kimi" && parsed.workspaceProfile === undefined) throw new Error("Kimi requires --workspace-profile")
|
|
119
106
|
if (parsed.relayWrite && parsed.provider !== "codex") throw new Error("--relay-write is only supported for codex")
|
|
120
107
|
return /** @type {ParsedArguments} */ (parsed)
|
|
121
108
|
}
|
|
@@ -200,35 +187,17 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
200
187
|
runAdmission = normalizedOutput === undefined ? undefined : new DelegatedResultAdmission({output: normalizedOutput, metrics})
|
|
201
188
|
const target = parseTelegramTarget(parsed.target)
|
|
202
189
|
if (parsed.relayWrite) validateRelayWriteProviderArguments(parsed.providerArguments)
|
|
203
|
-
const kimiBinding = parsed.provider === "kimi"
|
|
204
|
-
? parseTrustedThreadwireBinding(sourceEnvironment.THREADWIRE_KIMI_TASK_BINDING)
|
|
205
|
-
: undefined
|
|
206
|
-
const resolvedWorkspace = parsed.workspaceProfile === undefined
|
|
207
|
-
? undefined
|
|
208
|
-
: await resolveWorkspaceProfile(
|
|
209
|
-
{
|
|
210
|
-
provider: /** @type {"codex" | "claude" | "kimi" | "opencode"} */ (parsed.provider), profile: parsed.workspaceProfile,
|
|
211
|
-
...(kimiBinding === undefined ? {} : {binding: kimiBinding})
|
|
212
|
-
},
|
|
213
|
-
dependencies.workspaceProfileOperations
|
|
214
|
-
)
|
|
215
190
|
if (validateOnly) return 0
|
|
216
|
-
const usesIsolatedRuntime = parsed.relayWrite
|
|
191
|
+
const usesIsolatedRuntime = parsed.relayWrite
|
|
217
192
|
const isolatedRuntimeClient = usesIsolatedRuntime
|
|
218
|
-
?
|
|
219
|
-
? dependencies.kimiIsolatedRuntimeClient ?? kimiIsolatedRuntimeClientFromEnvironment(sourceEnvironment)
|
|
220
|
-
: dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
|
|
193
|
+
? dependencies.isolatedRuntimeClient ?? isolatedRuntimeClientFromEnvironment(sourceEnvironment)
|
|
221
194
|
: undefined
|
|
222
195
|
const isolatedPreflight = usesIsolatedRuntime
|
|
223
196
|
? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).preflight({
|
|
224
197
|
provider: parsed.provider,
|
|
225
|
-
profile:
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
: {
|
|
229
|
-
repositoryRoot: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).repositoryRoot,
|
|
230
|
-
cwd: /** @type {import("./workspace-profile.js").ResolvedWorkspaceProfile} */ (resolvedWorkspace).cwd
|
|
231
|
-
}),
|
|
198
|
+
profile: "explicit-cwd",
|
|
199
|
+
repositoryRoot: parsed.cwd,
|
|
200
|
+
cwd: parsed.cwd,
|
|
232
201
|
providerArguments: parsed.providerArguments,
|
|
233
202
|
...(parsed.resumeSession === undefined ? {} : {resumeSession: parsed.resumeSession})
|
|
234
203
|
})
|
|
@@ -268,18 +237,18 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
268
237
|
await boundedLaunch(evidence.append("prompt", promptEvidence), launchDeadline)
|
|
269
238
|
evidencePayloadBytes += Buffer.byteLength(promptEvidence, "utf8")
|
|
270
239
|
}
|
|
271
|
-
const providerEnvironment = buildProviderEnvironment(environment
|
|
240
|
+
const providerEnvironment = buildProviderEnvironment(environment, {
|
|
241
|
+
provider: parsed.provider,
|
|
242
|
+
isolated: usesIsolatedRuntime
|
|
243
|
+
})
|
|
272
244
|
const provider = createProvider(
|
|
273
245
|
parsed.provider,
|
|
274
246
|
parsed.providerArguments,
|
|
275
247
|
prompt,
|
|
276
248
|
parsed.resumeSession,
|
|
277
|
-
|
|
249
|
+
providerEnvironment
|
|
278
250
|
)
|
|
279
251
|
if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
|
|
280
|
-
if (parsed.workspaceProfile !== undefined) {
|
|
281
|
-
if (resolvedWorkspace === undefined) throw new Error("Workspace profile resolution failed")
|
|
282
|
-
}
|
|
283
252
|
const token = environment.THREADWIRE_TELEGRAM_BOT_TOKEN
|
|
284
253
|
if (!token) throw new Error("THREADWIRE_TELEGRAM_BOT_TOKEN is required")
|
|
285
254
|
const transport = (dependencies.transportFactory ?? createFetchTransport)(token, undefined, parseTelegramRequestTimeoutMs(environment))
|
|
@@ -292,20 +261,13 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
292
261
|
metrics
|
|
293
262
|
})
|
|
294
263
|
activity = parsed.activityLog === undefined ? undefined : new ActivityLog(parsed.activityLog)
|
|
295
|
-
if (activity && resolvedWorkspace) {
|
|
296
|
-
activity.recordWorkspace(
|
|
297
|
-
resolvedWorkspace.profile,
|
|
298
|
-
resolvedWorkspace.repositoryRoot,
|
|
299
|
-
resolvedWorkspace.revision,
|
|
300
|
-
resolvedWorkspace.sourceIdentity
|
|
301
|
-
)
|
|
302
|
-
}
|
|
303
264
|
/** @type {import("./run-worker.js").RunWorkerOptions} */
|
|
304
265
|
const workerOptions = {
|
|
305
266
|
executable: provider.executable,
|
|
306
267
|
arguments: provider.arguments,
|
|
307
|
-
cwd:
|
|
268
|
+
cwd: parsed.cwd,
|
|
308
269
|
environment: providerEnvironment,
|
|
270
|
+
provider: provider.name,
|
|
309
271
|
parse: provider.parse,
|
|
310
272
|
onEvent: (event) => {
|
|
311
273
|
validateNormalizedWorkerEvent(event)
|
|
@@ -320,7 +282,7 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
320
282
|
},
|
|
321
283
|
onRecord: (record) => {
|
|
322
284
|
metrics.recordParsedProviderRecord("provider_stdout")
|
|
323
|
-
const id =
|
|
285
|
+
const id = provider.sessionId(record)
|
|
324
286
|
if (id !== undefined) {
|
|
325
287
|
admission.setContinuationHandle(id)
|
|
326
288
|
activity?.recordSession(provider.name, id)
|
package/src/providers/index.js
CHANGED
|
@@ -27,7 +27,7 @@ export function createProvider(name, providerArguments, prompt, resumeSession, e
|
|
|
27
27
|
return {...command, name: "opencode", parse: createOpenCodeParser(), sessionId: createOpenCodeSessionId()}
|
|
28
28
|
}
|
|
29
29
|
if (name === "kimi") {
|
|
30
|
-
const command = buildKimiCommand(providerArguments, prompt, resumeSession)
|
|
30
|
+
const command = buildKimiCommand(providerArguments, prompt, resumeSession, environment)
|
|
31
31
|
return {...command, name: "kimi", parse: createKimiParser(), sessionId: createKimiSessionId()}
|
|
32
32
|
}
|
|
33
33
|
throw new Error(`--provider must be one of: ${PROVIDERS.join(", ")}`)
|
package/src/providers/kimi.js
CHANGED
|
@@ -20,14 +20,24 @@ export function validateKimiProviderArguments(providerArguments) {
|
|
|
20
20
|
throw new Error("Threadwire owns Kimi prompt, output, session, model configuration, permissions, and extensions")
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
const OWNED_ARGUMENTS = new Set([
|
|
24
|
+
"--prompt", "-p", "--output-format", "--session", "-S", "--resume-session", "--resume", "-r"
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Native Kimi uses caller-owned provider configuration. Threadwire owns only
|
|
29
|
+
* the prompt, stream output, and explicit resume handle.
|
|
30
|
+
* @param {string[]} providerArguments @param {string} prompt @param {string | undefined} [resumeSession] @param {NodeJS.ProcessEnv} [environment]
|
|
31
|
+
*/
|
|
32
|
+
export function buildKimiCommand(providerArguments, prompt, resumeSession, environment = process.env) {
|
|
33
|
+
rejectNativeOwnedArguments(providerArguments)
|
|
26
34
|
if (resumeSession !== undefined && !SESSION_PATTERN.test(resumeSession)) throw new Error("Kimi session ID is invalid")
|
|
27
35
|
return {
|
|
28
|
-
executable: EXECUTABLE,
|
|
36
|
+
executable: environment.THREADWIRE_KIMI_BIN?.length ? environment.THREADWIRE_KIMI_BIN : EXECUTABLE,
|
|
29
37
|
arguments: [
|
|
30
|
-
|
|
38
|
+
...providerArguments,
|
|
39
|
+
// Threadwire's public resume option maps to Kimi Code's native session option.
|
|
40
|
+
// Kimi Code 0.29.2 does not provide a --resume-session flag.
|
|
31
41
|
...(resumeSession === undefined ? [] : ["--session", resumeSession]),
|
|
32
42
|
"--prompt", prompt,
|
|
33
43
|
"--output-format", "stream-json"
|
|
@@ -35,6 +45,16 @@ export function buildKimiCommand(providerArguments, prompt, resumeSession) {
|
|
|
35
45
|
}
|
|
36
46
|
}
|
|
37
47
|
|
|
48
|
+
/** @param {string[]} arguments_ */
|
|
49
|
+
function rejectNativeOwnedArguments(arguments_) {
|
|
50
|
+
if (arguments_.includes("--")) throw new Error("Kimi provider argument terminator is not allowed")
|
|
51
|
+
if (arguments_.some((argument) => OWNED_ARGUMENTS.has(argument)
|
|
52
|
+
|| argument.startsWith("--prompt=") || argument.startsWith("--output-format=")
|
|
53
|
+
|| argument.startsWith("--session=") || argument.startsWith("--resume-session=") || argument.startsWith("--resume="))) {
|
|
54
|
+
throw new Error("Threadwire owns Kimi prompt, streaming output, and session options")
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
38
58
|
/** @returns {(record: unknown) => string | undefined} */
|
|
39
59
|
export function createKimiSessionId() {
|
|
40
60
|
let emitted = false
|
package/src/run-worker.js
CHANGED
|
@@ -9,7 +9,7 @@ const DEFAULT_MAX_STDOUT_RECORD_BYTES = 1_048_576
|
|
|
9
9
|
const DEFAULT_TERMINATION_GRACE_PERIOD_MS = 5_000
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* @typedef {{executable: string, arguments: string[], cwd: string, environment?: NodeJS.ProcessEnv, parse: (record: unknown) => import("./types.js").WorkerEvent[], onEvent: (event: import("./types.js").WorkerEvent) => void | Promise<void>, onSpawn?: (pid: number) => void, onRecord?: (record: unknown) => void | Promise<void>, onStdoutChunk?: (chunk: Buffer) => void | Promise<void>, onStderrChunk?: (chunk: Buffer) => void | Promise<void>, spawnImplementation?: typeof nodeSpawn, maxStdoutRecordBytes?: number, terminationGracePeriodMs?: number, setTimer?: (callback: () => void, milliseconds: number) => unknown, clearTimer?: (handle: unknown) => void, platform?: NodeJS.Platform, killProcess?: (pid: number, signal: NodeJS.Signals) => boolean}} RunWorkerOptions
|
|
12
|
+
* @typedef {{executable: string, arguments: string[], cwd: string, environment?: NodeJS.ProcessEnv, provider?: string, parse: (record: unknown) => import("./types.js").WorkerEvent[], onEvent: (event: import("./types.js").WorkerEvent) => void | Promise<void>, onSpawn?: (pid: number) => void, onRecord?: (record: unknown) => void | Promise<void>, onStdoutChunk?: (chunk: Buffer) => void | Promise<void>, onStderrChunk?: (chunk: Buffer) => void | Promise<void>, spawnImplementation?: typeof nodeSpawn, maxStdoutRecordBytes?: number, terminationGracePeriodMs?: number, setTimer?: (callback: () => void, milliseconds: number) => unknown, clearTimer?: (handle: unknown) => void, platform?: NodeJS.Platform, killProcess?: (pid: number, signal: NodeJS.Signals) => boolean}} RunWorkerOptions
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
/** @param {RunWorkerOptions} options @returns {Promise<number>} */
|
|
@@ -25,7 +25,7 @@ export function runWorker(options) {
|
|
|
25
25
|
const child = spawnImplementation(options.executable, options.arguments, {
|
|
26
26
|
cwd: options.cwd,
|
|
27
27
|
detached: useDetachedProcessGroup,
|
|
28
|
-
env: childEnvironment(options.environment ?? process.env),
|
|
28
|
+
env: childEnvironment(options.environment ?? process.env, options.provider),
|
|
29
29
|
shell: false,
|
|
30
30
|
stdio: ["ignore", "pipe", "pipe"]
|
|
31
31
|
})
|
|
@@ -214,9 +214,9 @@ export class StdoutRecordTooLargeError extends Error {
|
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
/** @param {NodeJS.ProcessEnv} environment */
|
|
218
|
-
export function childEnvironment(environment = process.env) {
|
|
219
|
-
const childEnvironment = buildProviderEnvironment(environment)
|
|
217
|
+
/** @param {NodeJS.ProcessEnv} environment @param {string | undefined} [provider] */
|
|
218
|
+
export function childEnvironment(environment = process.env, provider) {
|
|
219
|
+
const childEnvironment = buildProviderEnvironment(environment, provider === undefined ? {} : {provider})
|
|
220
220
|
// Mark the provider child tree active so a nested provider CLI invoked from
|
|
221
221
|
// the /opt/data/bin front-door shim runs its libexec adapter directly instead
|
|
222
222
|
// of relaying again. Threadwire always spawns the libexec adapter, never the
|
|
@@ -222,32 +222,38 @@ export function parseTelegramRequestTimeoutMs(environment) {
|
|
|
222
222
|
/**
|
|
223
223
|
* Build the environment passed to the provider child. Keeps operational host
|
|
224
224
|
* variables needed by providers, but never forwards Telegram or ingress secrets.
|
|
225
|
+
* Kimi's native CLI has a deliberately narrow configuration contract. Its
|
|
226
|
+
* credentials and state directory are never useful to another provider or to
|
|
227
|
+
* an isolated Kimi worker.
|
|
225
228
|
* @param {NodeJS.ProcessEnv} source
|
|
229
|
+
* @param {{provider?: string, isolated?: boolean}} [options]
|
|
226
230
|
* @returns {NodeJS.ProcessEnv}
|
|
227
231
|
*/
|
|
228
|
-
export function buildProviderEnvironment(source) {
|
|
232
|
+
export function buildProviderEnvironment(source, options = {}) {
|
|
233
|
+
const preservesNativeKimiConfiguration = options.provider === "kimi" && options.isolated !== true
|
|
229
234
|
/** @type {NodeJS.ProcessEnv} */
|
|
230
235
|
const environment = {}
|
|
231
236
|
for (const [key, value] of Object.entries(source)) {
|
|
232
237
|
if (value === undefined) continue
|
|
233
|
-
if (isIngressSecretKey(key)) continue
|
|
238
|
+
if (isIngressSecretKey(key, preservesNativeKimiConfiguration)) continue
|
|
234
239
|
environment[key] = value
|
|
235
240
|
}
|
|
236
241
|
return environment
|
|
237
242
|
}
|
|
238
243
|
|
|
239
|
-
/** @param {string} key */
|
|
240
|
-
function isIngressSecretKey(key) {
|
|
244
|
+
/** @param {string} key @param {boolean} preservesNativeKimiConfiguration */
|
|
245
|
+
function isIngressSecretKey(key, preservesNativeKimiConfiguration) {
|
|
246
|
+
if (key === "THREADWIRE_KIMI_BIN") return !preservesNativeKimiConfiguration
|
|
247
|
+
if (key === "KIMI_API_KEY" || key === "MOONSHOT_API_KEY" || key === "KIMI_CODE_HOME") {
|
|
248
|
+
return !preservesNativeKimiConfiguration
|
|
249
|
+
}
|
|
241
250
|
if (FILE_BACKED_SETTINGS.some((name) => key === `${name}_FILE`)) return true
|
|
242
251
|
if (key === "THREADWIRE_REQUIRE_CODEX_ISOLATION") return true
|
|
243
252
|
if (
|
|
244
253
|
key.startsWith("THREADWIRE_ISOLATED_RUNTIME_")
|
|
245
254
|
|| key.startsWith("THREADWIRE_MODEL_BROKER_")
|
|
246
255
|
|| key.startsWith("THREADWIRE_KIMI_")
|
|
247
|
-
|| key
|
|
248
|
-
|| key === "KIMI_API_KEY"
|
|
249
|
-
|| key === "MOONSHOT_API_KEY"
|
|
250
|
-
|| key === "KIMI_CODE_HOME"
|
|
256
|
+
|| key.startsWith("KIMI_")
|
|
251
257
|
|| key === "THREADWIRE_RELAY_WORKER_IMAGE"
|
|
252
258
|
|| key === "THREADWIRE_ALLOWED_WORKTREE_ROOTS"
|
|
253
259
|
|| key === "THREADWIRE_WORKTREE_VOLUME"
|
|
@@ -10,7 +10,6 @@ import {kimiSessionEnvelopeId} from "../providers/kimi.js"
|
|
|
10
10
|
import {Relay} from "../relay.js"
|
|
11
11
|
import {runWorker} from "../run-worker.js"
|
|
12
12
|
import {WorkerControl} from "../worker-control.js"
|
|
13
|
-
import {resolveWorkspaceProfile} from "../workspace-profile.js"
|
|
14
13
|
import {buildProviderEnvironment, collectEvidenceRedactions} from "./config.js"
|
|
15
14
|
import {parseCodeCommand, parseEvidenceCommand} from "./command.js"
|
|
16
15
|
|
|
@@ -27,9 +26,9 @@ import {parseCodeCommand, parseEvidenceCommand} from "./command.js"
|
|
|
27
26
|
* Relay?: typeof Relay,
|
|
28
27
|
* processNumber?: number,
|
|
29
28
|
* providerEnvironment?: NodeJS.ProcessEnv,
|
|
29
|
+
* cwd?: string,
|
|
30
30
|
* kimiBinding?: unknown,
|
|
31
|
-
*
|
|
32
|
-
* activity?: Pick<import("../activity-log.js").ActivityLog, "recordWorkspace" | "recordStarted" | "recordSession" | "close">,
|
|
31
|
+
* activity?: Pick<import("../activity-log.js").ActivityLog, "recordStarted" | "recordSession" | "close">,
|
|
33
32
|
* evidenceStore?: import("../evidence-store.js").EvidenceStore,
|
|
34
33
|
* isolatedRuntimeClient?: Pick<import("../isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">,
|
|
35
34
|
* kimiIsolatedRuntimeClient?: Pick<import("../isolated-runtime-client.js").IsolatedRuntimeClient, "preflight" | "run">,
|
|
@@ -97,9 +96,6 @@ export function interpretUpdate(update, config) {
|
|
|
97
96
|
*/
|
|
98
97
|
export async function dispatchWorker(job, config, dependencies = {}) {
|
|
99
98
|
if ("type" in job) throw new Error("Worker dispatch requires a worker job")
|
|
100
|
-
if (job.provider === "kimi" && dependencies.kimiIsolatedRuntimeClient === undefined) {
|
|
101
|
-
throw new Error("Kimi isolated runtime is required")
|
|
102
|
-
}
|
|
103
99
|
if (job.provider === "codex" && config.requireCodexIsolation === true
|
|
104
100
|
&& dependencies.isolatedRuntimeClient === undefined) {
|
|
105
101
|
throw new Error("Codex isolated runtime is required")
|
|
@@ -112,32 +108,26 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
112
108
|
const NoticeQueueImpl = dependencies.NoticeQueue ?? NoticeQueue
|
|
113
109
|
const RelayImpl = dependencies.Relay ?? Relay
|
|
114
110
|
const processNumber = dependencies.processNumber ?? process.pid
|
|
115
|
-
|
|
116
|
-
const providerEnvironment = buildProviderEnvironment(dependencies.providerEnvironment ?? {})
|
|
111
|
+
const cwd = dependencies.cwd ?? process.cwd()
|
|
117
112
|
const kimiBinding = job.provider === "kimi" ? dependencies.kimiBinding : undefined
|
|
118
|
-
const workspace = await resolveWorkspaceProfile(
|
|
119
|
-
{provider: job.provider, ...(kimiBinding === undefined ? {} : {binding: kimiBinding})},
|
|
120
|
-
dependencies.workspaceProfileOperations
|
|
121
|
-
)
|
|
122
113
|
const isolatedRuntimeClient = job.provider === "kimi"
|
|
123
114
|
? dependencies.kimiIsolatedRuntimeClient
|
|
124
115
|
: job.provider === "codex" ? dependencies.isolatedRuntimeClient : undefined
|
|
125
|
-
|
|
116
|
+
// Explicit only: never fall back to ambient process.env (avoids secret leakage).
|
|
117
|
+
const providerEnvironment = buildProviderEnvironment(dependencies.providerEnvironment ?? {}, {
|
|
118
|
+
provider: job.provider,
|
|
119
|
+
isolated: isolatedRuntimeClient !== undefined
|
|
120
|
+
})
|
|
121
|
+
const earlyKimiPreflight = job.provider === "kimi" && isolatedRuntimeClient !== undefined
|
|
126
122
|
? await /** @type {NonNullable<typeof isolatedRuntimeClient>} */ (isolatedRuntimeClient).preflight({
|
|
127
123
|
provider: "kimi",
|
|
128
|
-
profile:
|
|
124
|
+
profile: "explicit-isolation",
|
|
129
125
|
binding: kimiBinding,
|
|
130
126
|
providerArguments: []
|
|
131
127
|
})
|
|
132
128
|
: undefined
|
|
133
129
|
|
|
134
130
|
const provider = createProviderImpl(job.provider, [], job.prompt, undefined, providerEnvironment)
|
|
135
|
-
dependencies.activity?.recordWorkspace(
|
|
136
|
-
workspace.profile,
|
|
137
|
-
workspace.repositoryRoot,
|
|
138
|
-
workspace.revision,
|
|
139
|
-
workspace.sourceIdentity
|
|
140
|
-
)
|
|
141
131
|
const transport = createFetchTransportImpl(config.botToken, undefined, config.telegramRequestTimeoutMs)
|
|
142
132
|
const sender = createTelegramSenderImpl(job.target, transport)
|
|
143
133
|
const control = new WorkerControlImpl({
|
|
@@ -164,10 +154,10 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
164
154
|
try {
|
|
165
155
|
const preflight = earlyKimiPreflight ?? await isolatedRuntimeClient.preflight({
|
|
166
156
|
provider: job.provider,
|
|
167
|
-
profile:
|
|
157
|
+
profile: "explicit-isolation",
|
|
168
158
|
...(job.provider === "kimi"
|
|
169
159
|
? {binding: kimiBinding}
|
|
170
|
-
: {repositoryRoot:
|
|
160
|
+
: {repositoryRoot: cwd, cwd}),
|
|
171
161
|
providerArguments: []
|
|
172
162
|
})
|
|
173
163
|
const exitCode = await isolatedRuntimeClient.run({
|
|
@@ -241,8 +231,9 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
241
231
|
const running = runWorkerImpl({
|
|
242
232
|
executable: provider.executable,
|
|
243
233
|
arguments: provider.arguments,
|
|
244
|
-
cwd
|
|
234
|
+
cwd,
|
|
245
235
|
environment: providerEnvironment,
|
|
236
|
+
provider: provider.name,
|
|
246
237
|
parse: provider.parse,
|
|
247
238
|
onEvent: async (event) => control.accept(event),
|
|
248
239
|
onSpawn: (pid) => {
|
|
@@ -262,11 +253,22 @@ export async function dispatchWorker(job, config, dependencies = {}) {
|
|
|
262
253
|
// reported. Always notify settlement so concurrency slots are released.
|
|
263
254
|
evidenceTransferred = true
|
|
264
255
|
void (async () => {
|
|
256
|
+
/** @type {Error | undefined} */
|
|
257
|
+
let nativeKimiExitFailure
|
|
265
258
|
try {
|
|
266
259
|
try {
|
|
267
|
-
await running
|
|
260
|
+
const exitCode = await running
|
|
261
|
+
if (job.provider === "kimi" && spawnGate.settled) {
|
|
262
|
+
nativeKimiExitFailure = exitCode === 0 ? undefined : new Error(`Kimi worker exited with status ${exitCode}`)
|
|
263
|
+
await control.accept({
|
|
264
|
+
type: "lifecycle",
|
|
265
|
+
phase: exitCode === 0 ? "completed" : "failed",
|
|
266
|
+
summary: exitCode === 0 ? "Kimi worker completed" : "Kimi worker failed"
|
|
267
|
+
})
|
|
268
|
+
if (nativeKimiExitFailure !== undefined) throw nativeKimiExitFailure
|
|
269
|
+
}
|
|
268
270
|
} catch (error) {
|
|
269
|
-
const failure = error instanceof Error ? error : new Error(safeFailureMessage(error))
|
|
271
|
+
const failure = nativeKimiExitFailure ?? (error instanceof Error ? error : new Error(safeFailureMessage(error)))
|
|
270
272
|
if (spawnGate.settled) {
|
|
271
273
|
reportBackgroundFailure(failure)
|
|
272
274
|
} else {
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import {createConcurrencyLimiter} from "./concurrency.js"
|
|
4
4
|
import {dispatchEvidenceRead, dispatchWorker, interpretUpdate, secretsEqual} from "./core.js"
|
|
5
5
|
import {createUpdateGuard, parseUpdateId} from "./update-guard.js"
|
|
6
|
-
import {WorkspaceProviderMismatchError} from "../workspace-profile.js"
|
|
7
6
|
|
|
8
7
|
export const MAX_WEBHOOK_BODY_BYTES = 65_536
|
|
9
8
|
export const WEBHOOK_PATH = "/webhook"
|
|
@@ -159,11 +158,6 @@ async function handleRequest(request, response, config, dependencies) {
|
|
|
159
158
|
// Safety release for failures before the observer is installed (e.g. provider setup).
|
|
160
159
|
// Idempotent with onWorkerSettled via slotHeld.
|
|
161
160
|
releaseSlot()
|
|
162
|
-
if (error instanceof WorkspaceProviderMismatchError) {
|
|
163
|
-
dependencies.updateGuard.complete(updateId)
|
|
164
|
-
send(response, 200)
|
|
165
|
-
return
|
|
166
|
-
}
|
|
167
161
|
dependencies.updateGuard.release(updateId)
|
|
168
162
|
const message = error instanceof Error ? error.message : "Worker launch failed"
|
|
169
163
|
dependencies.onOperationalError?.(message)
|
package/src/telegram-webhook.js
CHANGED
|
@@ -17,7 +17,7 @@ import {parseTrustedThreadwireBinding} from "./threadwire-binding.js"
|
|
|
17
17
|
* onOperationalError?: (message: string) => void,
|
|
18
18
|
* onWorkerFailure?: (message: string) => void,
|
|
19
19
|
* providerEnvironment?: NodeJS.ProcessEnv,
|
|
20
|
-
* activity?: Pick<ActivityLog, "
|
|
20
|
+
* activity?: Pick<ActivityLog, "recordStarted" | "recordSession" | "close">,
|
|
21
21
|
* evidenceStore?: EvidenceStore,
|
|
22
22
|
* handlerDependencies?: import("./telegram-ingress/http.js").WebhookDependencies
|
|
23
23
|
* }} [options]
|
|
@@ -25,14 +25,6 @@ import {parseTrustedThreadwireBinding} from "./threadwire-binding.js"
|
|
|
25
25
|
*/
|
|
26
26
|
export async function startTelegramWebhook(options = {}) {
|
|
27
27
|
const environment = await resolveIngressEnvironment(options.env ?? process.env)
|
|
28
|
-
// Capture this trusted topology attestation before anything derives a
|
|
29
|
-
// provider environment. The parsed object is passed only to Kimi preflight.
|
|
30
|
-
// Codex-only Compose renders the variable as an empty string; a blank value
|
|
31
|
-
// means "absent", while a malformed non-blank value still fails closed.
|
|
32
|
-
const rawKimiBinding = environment.THREADWIRE_KIMI_TASK_BINDING
|
|
33
|
-
const kimiBinding = rawKimiBinding === undefined || rawKimiBinding.trim().length === 0
|
|
34
|
-
? undefined
|
|
35
|
-
: parseTrustedThreadwireBinding(rawKimiBinding)
|
|
36
28
|
const config = parseIngressConfig(environment)
|
|
37
29
|
const createServerImpl = options.createServerImpl ?? createServer
|
|
38
30
|
const onOperationalError = options.onOperationalError ?? defaultOperationalError
|
|
@@ -42,7 +34,10 @@ export async function startTelegramWebhook(options = {}) {
|
|
|
42
34
|
// Strip ingress/Telegram secrets before they enter the worker dependency chain.
|
|
43
35
|
// Never pass ambient process.env through unfiltered.
|
|
44
36
|
const providerEnvironment = buildProviderEnvironment(
|
|
45
|
-
options.providerEnvironment ?? environment
|
|
37
|
+
options.providerEnvironment ?? environment,
|
|
38
|
+
// dispatchWorker applies the final provider/path-aware filter. Retain only
|
|
39
|
+
// the narrow native Kimi candidate configuration until it can do so.
|
|
40
|
+
{provider: "kimi"}
|
|
46
41
|
)
|
|
47
42
|
const isolatedConfigured = typeof environment.THREADWIRE_ISOLATED_RUNTIME_CONTROL_TOKEN === "string"
|
|
48
43
|
&& environment.THREADWIRE_ISOLATED_RUNTIME_CONTROL_TOKEN.trim().length > 0
|
|
@@ -52,6 +47,12 @@ export async function startTelegramWebhook(options = {}) {
|
|
|
52
47
|
&& environment.THREADWIRE_KIMI_ISOLATED_RUNTIME_CONTROL_TOKEN.trim().length > 0
|
|
53
48
|
const kimiIsolatedRuntimeClient = options.handlerDependencies?.kimiIsolatedRuntimeClient
|
|
54
49
|
?? (kimiIsolatedConfigured ? kimiIsolatedRuntimeClientFromEnvironment(environment) : undefined)
|
|
50
|
+
// A task binding belongs solely to an explicitly selected isolated Kimi
|
|
51
|
+
// supervisor. Native Kimi neither reads nor validates this topology input.
|
|
52
|
+
const rawKimiBinding = environment.THREADWIRE_KIMI_TASK_BINDING
|
|
53
|
+
const kimiBinding = kimiIsolatedRuntimeClient === undefined || rawKimiBinding === undefined || rawKimiBinding.trim().length === 0
|
|
54
|
+
? undefined
|
|
55
|
+
: parseTrustedThreadwireBinding(rawKimiBinding)
|
|
55
56
|
const activity = options.activity ?? options.handlerDependencies?.activity ?? new ActivityLog("/var/lib/threadwire/activity/threadwire.jsonl")
|
|
56
57
|
const evidenceStore = options.evidenceStore ?? options.handlerDependencies?.evidenceStore ?? await EvidenceStore.open({
|
|
57
58
|
root: config.evidenceRoot ?? "/var/lib/threadwire/evidence"
|
package/src/workspace-profile.js
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
|
|
3
|
-
import {execFile as nodeExecFile} from "node:child_process"
|
|
4
|
-
import {readFile} from "node:fs/promises"
|
|
5
|
-
import {dirname, join, resolve, sep} from "node:path"
|
|
6
|
-
import {fileURLToPath} from "node:url"
|
|
7
|
-
import {promisify} from "node:util"
|
|
8
|
-
import {PROVIDERS} from "./providers/index.js"
|
|
9
|
-
import {parseThreadwireBinding} from "./threadwire-binding.js"
|
|
10
|
-
|
|
11
|
-
const execFile = promisify(nodeExecFile)
|
|
12
|
-
const DEFAULT_PROFILES_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "threadwire.workspace-profiles.json")
|
|
13
|
-
const REVISION_PATTERN = /^[0-9a-f]{40}$/u
|
|
14
|
-
const SOURCE_IDENTITY_PATTERN = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/u
|
|
15
|
-
const PROFILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @typedef {{repositoryRoot: string, cwd: string, providers: ("codex" | "claude" | "kimi" | "opencode")[], bindings?: {kimi: "threadwire-v1"}}} WorkspaceProfile
|
|
19
|
-
* @typedef {{version: 1 | 2, defaultProfile: string, profiles: Record<string, WorkspaceProfile>}} WorkspaceProfilesConfig
|
|
20
|
-
* @typedef {{profile: string, repositoryRoot: string, cwd: string, revision: string, sourceIdentity: string, binding?: unknown}} ResolvedWorkspaceProfile
|
|
21
|
-
* @typedef {{repositoryRoot: string, revision: string, sourceIdentity: string}} GitProvenance
|
|
22
|
-
* @typedef {{
|
|
23
|
-
* readProfiles?: () => Promise<unknown>,
|
|
24
|
-
* directoryExists?: (path: string) => Promise<boolean>,
|
|
25
|
-
* resolveGitProvenance?: (cwd: string) => Promise<GitProvenance>
|
|
26
|
-
* }} WorkspaceProfileOperations
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
|
-
/** @param {unknown} value @returns {WorkspaceProfilesConfig} */
|
|
30
|
-
export function parseWorkspaceProfiles(value) {
|
|
31
|
-
if (!isRecord(value) || (value.version !== 1 && value.version !== 2) || typeof value.defaultProfile !== "string" || !isRecord(value.profiles)) {
|
|
32
|
-
throw new Error("Workspace profile configuration is invalid")
|
|
33
|
-
}
|
|
34
|
-
assertExactKeys(value, ["version", "defaultProfile", "profiles"])
|
|
35
|
-
/** @type {Record<string, WorkspaceProfile>} */
|
|
36
|
-
const profiles = {}
|
|
37
|
-
for (const [name, profile] of Object.entries(value.profiles)) {
|
|
38
|
-
if (!PROFILE_NAME_PATTERN.test(name) || !isRecord(profile)) throw new Error("Workspace profile configuration is invalid")
|
|
39
|
-
assertExactKeys(profile, value.version === 2 ? ["repositoryRoot", "cwd", "providers", "bindings"] : ["repositoryRoot", "cwd", "providers"])
|
|
40
|
-
const repositoryRoot = absoluteNormalizedPath(profile.repositoryRoot)
|
|
41
|
-
const cwd = absoluteAbsolutePath(profile.cwd)
|
|
42
|
-
if (cwd !== resolve(cwd) || !pathWithin(repositoryRoot, resolve(cwd))) {
|
|
43
|
-
throw new Error(`Workspace profile ${name} cwd must stay within its repository root`)
|
|
44
|
-
}
|
|
45
|
-
const providers = parseProviders(profile.providers)
|
|
46
|
-
let bindings
|
|
47
|
-
if (value.version === 2 && Object.hasOwn(profile, "bindings")) {
|
|
48
|
-
if (!isRecord(profile.bindings)) throw new Error("Workspace profile configuration is invalid")
|
|
49
|
-
assertExactKeys(profile.bindings, ["kimi"])
|
|
50
|
-
if (profile.bindings.kimi !== "threadwire-v1") throw new Error("Workspace profile configuration is invalid")
|
|
51
|
-
bindings = /** @type {{kimi: "threadwire-v1"}} */ ({kimi: "threadwire-v1"})
|
|
52
|
-
}
|
|
53
|
-
profiles[name] = {repositoryRoot, cwd: resolve(cwd), providers, ...(bindings === undefined ? {} : {bindings})}
|
|
54
|
-
}
|
|
55
|
-
if (!Object.hasOwn(profiles, value.defaultProfile)) {
|
|
56
|
-
throw new Error("Workspace profile configuration is invalid")
|
|
57
|
-
}
|
|
58
|
-
return {version: value.version, defaultProfile: value.defaultProfile, profiles}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Resolve a reviewed workspace profile to a validated in-container workspace.
|
|
63
|
-
* @param {{provider: "codex" | "claude" | "kimi" | "opencode", profile?: string, binding?: unknown}} selection
|
|
64
|
-
* @param {WorkspaceProfileOperations} [operations]
|
|
65
|
-
* @returns {Promise<ResolvedWorkspaceProfile>}
|
|
66
|
-
*/
|
|
67
|
-
export async function resolveWorkspaceProfile(selection, operations = {}) {
|
|
68
|
-
const raw = await (operations.readProfiles ?? readProfiles)()
|
|
69
|
-
const config = parseWorkspaceProfiles(raw)
|
|
70
|
-
const profileName = selection.profile ?? config.defaultProfile
|
|
71
|
-
const profile = config.profiles[profileName]
|
|
72
|
-
if (!profile) throw new Error("--workspace-profile must name a configured workspace profile")
|
|
73
|
-
if (!profile.providers.includes(selection.provider)) {
|
|
74
|
-
throw new WorkspaceProviderMismatchError(profileName, selection.provider)
|
|
75
|
-
}
|
|
76
|
-
if (selection.provider === "kimi") {
|
|
77
|
-
if (config.version !== 2 || profile.bindings?.kimi !== "threadwire-v1" || selection.binding === undefined) {
|
|
78
|
-
throw new Error("Kimi requires Threadwire binding schema v1")
|
|
79
|
-
}
|
|
80
|
-
let binding
|
|
81
|
-
try { binding = parseThreadwireBinding(selection.binding) } catch { throw new Error("Kimi requires Threadwire binding schema v1") }
|
|
82
|
-
return {
|
|
83
|
-
profile: profileName,
|
|
84
|
-
repositoryRoot: binding.source.target,
|
|
85
|
-
cwd: binding.runtime.workdir,
|
|
86
|
-
revision: binding.source.revision,
|
|
87
|
-
sourceIdentity: binding.context.digests.content.slice("sha256:".length),
|
|
88
|
-
binding
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
const directoryExists = operations.directoryExists ?? defaultDirectoryExists
|
|
92
|
-
if (!await directoryExists(profile.repositoryRoot) || !await directoryExists(profile.cwd)) {
|
|
93
|
-
throw new Error(`Workspace profile ${profileName} workspace is unavailable`)
|
|
94
|
-
}
|
|
95
|
-
let provenance
|
|
96
|
-
try {
|
|
97
|
-
provenance = await (operations.resolveGitProvenance ?? defaultResolveGitProvenance)(profile.cwd)
|
|
98
|
-
} catch {
|
|
99
|
-
throw new Error(`Workspace profile ${profileName} workspace is unavailable`)
|
|
100
|
-
}
|
|
101
|
-
if (provenance.repositoryRoot !== profile.repositoryRoot) {
|
|
102
|
-
throw new Error(`Workspace profile ${profileName} workspace is unavailable`)
|
|
103
|
-
}
|
|
104
|
-
return {
|
|
105
|
-
profile: profileName,
|
|
106
|
-
repositoryRoot: profile.repositoryRoot,
|
|
107
|
-
cwd: profile.cwd,
|
|
108
|
-
revision: provenance.revision,
|
|
109
|
-
sourceIdentity: provenance.sourceIdentity
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export class WorkspaceProviderMismatchError extends Error {
|
|
114
|
-
/** @param {string} profileName @param {string} provider */
|
|
115
|
-
constructor(profileName, provider) {
|
|
116
|
-
super(`Workspace profile ${profileName} does not allow provider ${provider}`)
|
|
117
|
-
this.name = "WorkspaceProviderMismatchError"
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** @returns {Promise<unknown>} */
|
|
122
|
-
async function readProfiles() {
|
|
123
|
-
return JSON.parse(await readFile(DEFAULT_PROFILES_PATH, "utf8"))
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** @param {string} path @returns {Promise<boolean>} */
|
|
127
|
-
async function defaultDirectoryExists(path) {
|
|
128
|
-
try {
|
|
129
|
-
const metadata = await import("node:fs/promises").then(({stat}) => stat(path))
|
|
130
|
-
return metadata.isDirectory()
|
|
131
|
-
} catch {
|
|
132
|
-
return false
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** @param {string} cwd @returns {Promise<GitProvenance>} */
|
|
137
|
-
async function defaultResolveGitProvenance(cwd) {
|
|
138
|
-
const repositoryRoot = await gitText(cwd, ["rev-parse", "--show-toplevel"])
|
|
139
|
-
const revision = await gitText(cwd, ["rev-parse", "HEAD"])
|
|
140
|
-
const sourceIdentity = await readSourceIdentity(repositoryRoot, cwd)
|
|
141
|
-
if (!REVISION_PATTERN.test(revision) || !SOURCE_IDENTITY_PATTERN.test(sourceIdentity)) throw new Error("invalid workspace provenance")
|
|
142
|
-
return {repositoryRoot, revision, sourceIdentity}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** @param {string} cwd @param {string[]} arguments_ */
|
|
146
|
-
async function gitText(cwd, arguments_) {
|
|
147
|
-
const {stdout} = await execFile("git", ["-C", cwd, ...arguments_], {encoding: "utf8"})
|
|
148
|
-
return stdout.trim()
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** @param {string} repositoryRoot @param {string} cwd */
|
|
152
|
-
async function readSourceIdentity(repositoryRoot, cwd) {
|
|
153
|
-
try {
|
|
154
|
-
return (await readFile(join(repositoryRoot, ".threadwire-source-identity"), "utf8")).trim()
|
|
155
|
-
} catch {
|
|
156
|
-
return gitText(cwd, ["rev-parse", "HEAD^{tree}"])
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** @param {unknown} value */
|
|
161
|
-
function parseProviders(value) {
|
|
162
|
-
if (!Array.isArray(value) || value.length === 0) throw new Error("Workspace profile configuration is invalid")
|
|
163
|
-
/** @type {("codex" | "claude" | "kimi" | "opencode")[]} */
|
|
164
|
-
const providers = []
|
|
165
|
-
for (const entry of value) {
|
|
166
|
-
if (typeof entry !== "string") {
|
|
167
|
-
throw new Error("Workspace profile configuration is invalid")
|
|
168
|
-
}
|
|
169
|
-
const provider = /** @type {(typeof PROVIDERS)[number]} */ (entry)
|
|
170
|
-
if (!PROVIDERS.includes(provider) || providers.includes(provider)) {
|
|
171
|
-
throw new Error(typeof entry === "string" ? `Workspace profile configuration contains unknown provider ${entry}` : "Workspace profile configuration is invalid")
|
|
172
|
-
}
|
|
173
|
-
providers.push(provider)
|
|
174
|
-
}
|
|
175
|
-
return providers
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** @param {unknown} value */
|
|
179
|
-
function absoluteNormalizedPath(value) {
|
|
180
|
-
if (typeof value !== "string") throw new Error("Workspace profile configuration is invalid")
|
|
181
|
-
const normalized = resolve(value)
|
|
182
|
-
if (!normalized.startsWith(sep) || value !== normalized) {
|
|
183
|
-
throw new Error("Workspace profile configuration is invalid")
|
|
184
|
-
}
|
|
185
|
-
return normalized
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** @param {unknown} value */
|
|
189
|
-
function absoluteAbsolutePath(value) {
|
|
190
|
-
if (typeof value !== "string") throw new Error("Workspace profile configuration is invalid")
|
|
191
|
-
const normalized = resolve(value)
|
|
192
|
-
if (!normalized.startsWith(sep)) throw new Error("Workspace profile configuration is invalid")
|
|
193
|
-
return value
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** @param {string} root @param {string} child */
|
|
197
|
-
function pathWithin(root, child) {
|
|
198
|
-
return child === root || child.startsWith(`${root}${sep}`)
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
202
|
-
function isRecord(value) {
|
|
203
|
-
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/** @param {Record<string, unknown>} value @param {string[]} allowedKeys */
|
|
207
|
-
function assertExactKeys(value, allowedKeys) {
|
|
208
|
-
const allowed = new Set(allowedKeys)
|
|
209
|
-
for (const key of Object.keys(value)) {
|
|
210
|
-
if (!allowed.has(key)) throw new Error("Workspace profile configuration is invalid")
|
|
211
|
-
}
|
|
212
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 2,
|
|
3
|
-
"defaultProfile": "container-runtime",
|
|
4
|
-
"profiles": {
|
|
5
|
-
"container-runtime": {
|
|
6
|
-
"repositoryRoot": "/workspace/threadwire",
|
|
7
|
-
"cwd": "/workspace/threadwire",
|
|
8
|
-
"providers": ["codex", "kimi"],
|
|
9
|
-
"bindings": {"kimi": "threadwire-v1"}
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
}
|