threadwire 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Resolve native Kimi through the normal `PATH` (`kimi`) instead of requiring
6
+ `/usr/local/bin/kimi`; `THREADWIRE_KIMI_BIN` remains the explicit override.
7
+ - Resolve native Codex through `THREADWIRE_CODEX_BIN` or normal `PATH` lookup,
8
+ falling back to `codex` rather than a Threadwire-specific structural adapter.
9
+
10
+ - Add an optional trusted-controller Telegram relay handoff: the generated host launcher can securely read one bounded controller-only bot-token file after argument validation, export it only to Threadwire, and scrub its pointer before controller execution. Existing provider-boundary filtering keeps the token and pointer out of Docker provider argv/environment, task containers, source, prompts, and logs; omitting the option preserves the existing dotenv behavior.
11
+
12
+ ## 0.1.18 - 2026-08-06
13
+
14
+ - Autodiscover the direct Codex CLI for both worker runs and live capacity probes: after a valid `THREADWIRE_CODEX_BIN` and executable structural `/opt/data/libexec/threadwire/codex` adapter, resolve executable `codex` candidates in supplied `PATH` order while rejecting the `/opt/data/bin/codex` Threadwire front door and continuing lookup. Stale overrides name the selected fallback, and no safe candidate fails clearly without unchecked bare-command lookup.
15
+
5
16
  ## 0.1.17 - 2026-08-06
6
17
 
7
18
  - Persist a normalized `provider_result` evidence artifact before final notification drain, separating it from quota-limited raw prompt/provider-stream evidence and separating `provider_completed`/`provider_failed` from authenticated `delivery_pending`/`delivery_succeeded`/`delivery_failed` state. Only durably finalized raw handles enter normalized results. Exhausted Telegram Retry-After/backoff attempts no longer rewrite provider success or discard its continuation/evidence handles, and ingress evidence-handle notices now use the existing bounded notice queue instead of an unbounded direct send. Isolated completion rejection and deadline cancellation now durably publish `provider_failed` with already observed assistant, continuation, and raw evidence before propagating the execution failure. Delivery transitions recover the exact sealed artifact redaction policy across restart, preserve its durable reservation, and record setup failures as `delivery_failed`. Add the token-free `file:<absolute-path>` target for private, fsynced, byte-identical normalized stdout capture without message delivery; result/transcript and activity paths now reject final symlink and parent-directory aliases before opening either output. Delivery failure records lack of a confirmed response, not exactly-once Telegram semantics after an ambiguous network failure.
package/README.md CHANGED
@@ -30,21 +30,27 @@ installation:
30
30
  ```sh
31
31
  npx --yes threadwire@latest --help
32
32
 
33
- THREADWIRE_CODEX_BIN=/path/to/direct/codex \
34
- npx --yes threadwire@latest run \
35
- --provider codex \
36
- --target telegram:-1001234567890:42 \
37
- --cwd /path/to/repository \
38
- --prompt 'Inspect the project.'
33
+ npx --yes threadwire@latest run \
34
+ --provider codex \
35
+ --target telegram:-1001234567890:42 \
36
+ --cwd /path/to/repository \
37
+ --prompt 'Inspect the project.'
39
38
  ```
40
39
 
41
40
  Provide `THREADWIRE_TELEGRAM_BOT_TOKEN` through a trusted environment or secret
42
- manager; do not put the token in the command line. For Claude or OpenCode, use
43
- `THREADWIRE_CLAUDE_BIN` or `THREADWIRE_OPENCODE_BIN` respectively. The override
44
- must name the direct provider executable, never a Threadwire front-door shim,
45
- so the relay cannot recursively launch itself. Existing Threadwire hosts may
46
- omit the override when their reviewed `/opt/data/libexec/threadwire/*` adapters
47
- are installed.
41
+ manager; do not put the token in the command line. Codex first uses a valid
42
+ `THREADWIRE_CODEX_BIN`, then the first executable `codex` found by left-to-right
43
+ Unix lookup in the supplied `PATH`; if no safe candidate is found, it runs the
44
+ normal `codex` command. Relative and empty PATH
45
+ entries resolve from the worker's `--cwd`. Candidates are canonically resolved
46
+ and inspected before use; known Threadwire front doors such as
47
+ `/opt/data/bin/codex`, including aliases to them, are rejected while lookup
48
+ continues, preventing recursive relay. A stale override warns with the actual
49
+ selected fallback. Use
50
+ `THREADWIRE_CODEX_BIN` only for an intentional custom/container adapter. For
51
+ Claude or OpenCode, use `THREADWIRE_CLAUDE_BIN` or `THREADWIRE_OPENCODE_BIN`
52
+ respectively; those overrides must name direct provider executables, never
53
+ Threadwire front-door shims.
48
54
 
49
55
  To install the same commands globally instead:
50
56
 
@@ -72,14 +78,36 @@ The target has the form `telegram:<chat-id>:<thread-id>` for topics, `telegram:<
72
78
 
73
79
  `--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>`.
74
80
 
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.
81
+ 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 `kimi` on `PATH` or set `THREADWIRE_KIMI_BIN`; a missing executable fails normally.
76
82
 
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. Terminal completion is explicit and provider-owned rather than derived only from process exit: native Kimi confirms a completed run with a final assistant response (assistant content without further tool calls) plus its session handoff record in the stream. Once both records are confirmed, Threadwire preserves the session continuation handle, terminates the attempt-owned provider process group (`SIGTERM`), and emits exactly one `delegated_result`. Success settles only once the entire owned group is gone: members that survive `SIGTERM` — including attempt-owned grandchildren — keep the grace-period escalation running and receive `SIGKILL` against the exact group before settlement, so no descendant is leaked even when the direct provider exits first. A provider that exits nonzero before its protocol confirms completion keeps its failure status, and long-running tool activity remains unbounded: there is no generic idle timeout, only caller cancellation or an explicit deadline. Parent `SIGINT`/`SIGTERM` is owned cancellation, not just forwarding: the signal reaches the exact attempt-owned group, surviving members are escalated to `SIGKILL` after the grace period, and settlement keeps the forwarded signal's exit status (143/130 on Linux) only after the owned tree is gone. Relay/consumer failure cleans up the same owned tree — even when the failure lands after the direct child closed and only descendants survive — and the worker runner also accepts an explicit `AbortSignal`: an abort terminates the exact attempt-owned group with the same grace/escalation and settles with the abort failure, never success. After any `SIGKILL`, settlement waits for bounded cleanup confirmation — the exact group is re-probed, or on platforms without process groups the direct child's close is required — and cleanup that cannot be confirmed within the bounded window settles as failure, never success; this confirmation bound is not an idle or tool-activity timeout.
83
+ Threadwire directly spawns the structurally separate terminal adapters `/opt/data/libexec/threadwire/codex`, `/opt/data/libexec/threadwire/claude`, `kimi` resolved from `PATH`, 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. Terminal completion is explicit and provider-owned rather than derived only from process exit: native Kimi confirms a completed run with a final assistant response (assistant content without further tool calls) plus its session handoff record in the stream. Once both records are confirmed, Threadwire preserves the session continuation handle, terminates the attempt-owned provider process group (`SIGTERM`), and emits exactly one `delegated_result`. Success settles only once the entire owned group is gone: members that survive `SIGTERM` — including attempt-owned grandchildren — keep the grace-period escalation running and receive `SIGKILL` against the exact group before settlement, so no descendant is leaked even when the direct provider exits first. A provider that exits nonzero before its protocol confirms completion keeps its failure status, and long-running tool activity remains unbounded: there is no generic idle timeout, only caller cancellation or an explicit deadline. Parent `SIGINT`/`SIGTERM` is owned cancellation, not just forwarding: the signal reaches the exact attempt-owned group, surviving members are escalated to `SIGKILL` after the grace period, and settlement keeps the forwarded signal's exit status (143/130 on Linux) only after the owned tree is gone. Relay/consumer failure cleans up the same owned tree — even when the failure lands after the direct child closed and only descendants survive — and the worker runner also accepts an explicit `AbortSignal`: an abort terminates the exact attempt-owned group with the same grace/escalation and settles with the abort failure, never success. After any `SIGKILL`, settlement waits for bounded cleanup confirmation — the exact group is re-probed, or on platforms without process groups the direct child's close is required — and cleanup that cannot be confirmed within the bounded window settles as failure, never success; this confirmation bound is not an idle or tool-activity timeout.
84
+
85
+ For Codex in ordinary project images, the fixed structural path described above
86
+ is only the preferred fallback after a valid override. When that adapter is not
87
+ executable, Threadwire uses the first inspected safe `codex` candidate from
88
+ `PATH` instead.
78
89
 
79
90
  ## Provider front-door shims
80
91
 
81
92
  `/opt/data/bin/codex`, `/opt/data/bin/claude`, and `/opt/data/bin/opencode-local-fleet` are Threadwire front-door relay shims. Install or refresh both them and their paired libexec adapters with `npm run install:provider-shims`; the installer generates all six scripts from the repo templates and never modifies runtime artifacts on its own. A worker invocation is routed through the `/opt/data/bin/threadwire` launcher so its activity streams to the caller's explicit Telegram destination; every non-worker mode runs the real provider CLI unchanged through the paired libexec adapter.
82
93
 
94
+ A trusted host controller can generate its launcher with an absolute,
95
+ controller-only Telegram bot-token file:
96
+
97
+ ```sh
98
+ node scripts/install-local-launcher.js /opt/data/bin/threadwire \
99
+ --telegram-bot-token-file /run/secrets/threadwire_telegram_bot_token
100
+ ```
101
+
102
+ After token-free argument validation, the launcher reads at most 8 KiB from a
103
+ regular, non-symlink file that is not group/other-writable, exports
104
+ `THREADWIRE_TELEGRAM_BOT_TOKEN` only to the Threadwire controller, and clears
105
+ the file pointer. Threadwire's existing provider boundary removes the token and
106
+ pointer before spawning any provider. The token file and value are never passed
107
+ through Docker argv/environment, mounted or copied into the task container or
108
+ source, added to prompts, or written to logs. Omitting the option preserves the
109
+ existing `TELEGRAM_BOT_TOKEN` dotenv lookup.
110
+
83
111
  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.
84
112
 
85
113
  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.
@@ -51,10 +51,15 @@ pin before relying on it.
51
51
 
52
52
  - **Codex** spawns `codex app-server` and runs a bounded two-request JSON-RPC
53
53
  exchange over stdio: `initialize`, then `account/rateLimits/read`. The
54
- executable resolves via `THREADWIRE_CODEX_BIN` or the default libexec
55
- adapter, exactly like the Codex worker provider; the anti-recursion default
56
- is unchanged, so native container callers point the existing safe override
57
- at the real CLI (for example `THREADWIRE_CODEX_BIN=/usr/local/bin/codex`).
54
+ executable resolves exactly like the Codex worker provider: a valid
55
+ `THREADWIRE_CODEX_BIN`, then the first executable `codex` found by
56
+ left-to-right Unix lookup in the supplied `PATH`; if no safe candidate is
57
+ found, it runs the normal `codex` command. Each PATH
58
+ candidate is canonically resolved and inspected before use; relative and
59
+ empty entries resolve from the probe's working directory. Known Threadwire
60
+ front doors, notably `/opt/data/bin/codex` and aliases to it, are skipped
61
+ while lookup continues. A stale explicit override warns with the selected
62
+ fallback.
58
63
  Only the quota projection of the result is parsed: `rateLimits.primary` maps
59
64
  to `short` and `rateLimits.secondary` to `long`; a `null` secondary yields a
60
65
  short-only snapshot, and a secondary shorter than the primary fails closed.
@@ -101,6 +101,17 @@ Codex state persists at `/home/threadwire/.codex`. The Codex adapter reads only
101
101
  `exec`, removes its pointer, and removes every ingress value and pointer from
102
102
  the provider environment. Threadwire repeats ingress filtering before spawn.
103
103
 
104
+ For a trusted host controller, `scripts/install-local-launcher.js` accepts
105
+ `--telegram-bot-token-file <absolute-file>`. The generated launcher validates
106
+ Threadwire arguments before credential access, then reads at most 8 KiB from a
107
+ regular, non-symlink, non-group/other-writable file. It exports the resulting
108
+ `THREADWIRE_TELEGRAM_BOT_TOKEN` only to the controller and clears the file
109
+ pointer before execution. Threadwire and the libexec adapters scrub Telegram
110
+ credentials and pointers before provider spawn, so neither the value nor file
111
+ pointer enters Docker provider argv/environment, a task container, source,
112
+ prompts, or logs. Without this installer option, the launcher retains its
113
+ existing `TELEGRAM_BOT_TOKEN` dotenv behavior.
114
+
104
115
  Docker secrets are ordinary files in the same container mount namespace. A
105
116
  provider running as the same UID can open another mounted secret file and can
106
117
  inspect same-UID process state permitted by the kernel, even though ingress
@@ -188,7 +199,7 @@ Kimi source is not a host worktree. The task controller creates one labelled sou
188
199
 
189
200
  The checked-in standard `runtime` image installs Codex only: it does not install
190
201
  Kimi or provide Kimi credentials. Native Kimi therefore requires a caller image
191
- or environment that provides `/usr/local/bin/kimi` (or sets
202
+ or environment that provides `kimi` on `PATH` (or sets
192
203
  `THREADWIRE_KIMI_BIN`); a missing executable fails normally. Never mount
193
204
  `threadwire-kimi-oauth`, its access/refresh tokens, or the auth home into the
194
205
  Threadwire runtime, supervisor, worker, workspace, or session-state volume.
@@ -157,8 +157,8 @@ logs; worker self-report is not the sole assertion.
157
157
 
158
158
  ## Optional isolated Kimi Code subscription boundary
159
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
160
+ Normal `threadwire run --provider kimi` is native execution: it resolves the
161
+ caller-provided `kimi` executable from `PATH`, or uses the path in
162
162
  `THREADWIRE_KIMI_BIN`. It does not select this isolated supervisor, broker, or
163
163
  model allowlist. The checked-in standard `runtime` image installs Codex only,
164
164
  so native Kimi needs a caller-supplied executable and fails normally when it is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "threadwire",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Stream Codex, Claude, Kimi Code, and OpenCode worker progress to an explicit Telegram destination",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -7,13 +7,13 @@ import {discardStagedFiles, replaceFile, stageFile, syncDirectory} from "./atomi
7
7
 
8
8
  const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")
9
9
 
10
- /** @param {string} targetPath @param {Parameters<typeof stageFile>[2]} [operations] */
11
- export async function installLocalLauncher(targetPath, operations = {}) {
10
+ /** @param {string} targetPath @param {Parameters<typeof stageFile>[2]} [operations] @param {{telegramBotTokenFile?: string | undefined}} [launcherOptions] */
11
+ export async function installLocalLauncher(targetPath, operations = {}, launcherOptions = {}) {
12
12
  const entrypointPath = resolve(repositoryRoot, "bin/threadwire.js")
13
13
  await mkdir(dirname(targetPath), {recursive: true})
14
14
  const stagedFiles = []
15
15
  try {
16
- const stagedFile = await stageFile(targetPath, launcherScript(entrypointPath), operations)
16
+ const stagedFile = await stageFile(targetPath, launcherScript(entrypointPath, undefined, launcherOptions), operations)
17
17
  stagedFiles.push(stagedFile)
18
18
  await replaceFile(stagedFile)
19
19
  await syncDirectory(dirname(targetPath))
@@ -23,11 +23,27 @@ export async function installLocalLauncher(targetPath, operations = {}) {
23
23
  }
24
24
 
25
25
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
26
- await installLocalLauncher(process.argv[2] ?? "/opt/data/bin/threadwire")
26
+ const arguments_ = process.argv.slice(2)
27
+ const firstArgument = arguments_[0]
28
+ let targetPath = "/opt/data/bin/threadwire"
29
+ if (firstArgument !== undefined && !firstArgument.startsWith("--")) {
30
+ targetPath = firstArgument
31
+ arguments_.shift()
32
+ }
33
+ let telegramBotTokenFile
34
+ while (arguments_.length > 0) {
35
+ const option = arguments_.shift()
36
+ if (option !== "--telegram-bot-token-file" || arguments_.length === 0) throw new Error(`Unknown or incomplete option: ${option}`)
37
+ telegramBotTokenFile = arguments_.shift()
38
+ }
39
+ if (telegramBotTokenFile !== undefined && !telegramBotTokenFile.startsWith("/")) throw new Error("Telegram bot-token file must be an absolute path")
40
+ await installLocalLauncher(targetPath, {}, {telegramBotTokenFile})
27
41
  }
28
42
 
29
- /** @param {string} entrypoint @param {string} [dotenvPath] */
30
- export function launcherScript(entrypoint, dotenvPath = "/opt/data/.env") {
43
+ /** @param {string} entrypoint @param {string} [dotenvPath] @param {{telegramBotTokenFile?: string | undefined}} [launcherOptions] */
44
+ export function launcherScript(entrypoint, dotenvPath = "/opt/data/.env", launcherOptions = {}) {
45
+ const telegramBotTokenFile = launcherOptions.telegramBotTokenFile
46
+ if (telegramBotTokenFile !== undefined && !telegramBotTokenFile.startsWith("/")) throw new Error("Telegram bot-token file must be an absolute path")
31
47
  return `#!/usr/bin/env bash
32
48
  # Generated by Threadwire's install:local-launcher script. Do not source the
33
49
  # shared dotenv: it contains unrelated credentials.
@@ -35,8 +51,9 @@ set -euo pipefail
35
51
 
36
52
  readonly THREADWIRE_ENTRYPOINT=${shellLiteral(entrypoint)}
37
53
  readonly THREADWIRE_DOTENV_PATH=${shellLiteral(dotenvPath)}
54
+ threadwire_telegram_bot_token_file=${shellLiteral(telegramBotTokenFile ?? "")}
38
55
 
39
- unset TELEGRAM_BOT_TOKEN THREADWIRE_TELEGRAM_BOT_TOKEN
56
+ unset TELEGRAM_BOT_TOKEN THREADWIRE_TELEGRAM_BOT_TOKEN THREADWIRE_TELEGRAM_BOT_TOKEN_FILE
40
57
 
41
58
  # Scope validation-only strictly to this launcher's own preflight below: clear any
42
59
  # inherited value so it can never leak into the real invocation and let it exit
@@ -71,27 +88,58 @@ while [[ "$#" -gt 0 ]]; do
71
88
  done
72
89
 
73
90
  if [[ "$threadwire_target" == file:* ]]; then
91
+ unset threadwire_telegram_bot_token_file
74
92
  exec node "$THREADWIRE_ENTRYPOINT" "\${threadwire_arguments[@]}"
75
93
  fi
76
94
 
77
- if [[ ! -r "$THREADWIRE_DOTENV_PATH" ]]; then
95
+ telegram_bot_token=""
96
+ if [[ -n "$threadwire_telegram_bot_token_file" ]]; then
97
+ threadwire_token_read_status=0
98
+ telegram_bot_token=$(node - "$threadwire_telegram_bot_token_file" <<'NODE'
99
+ const fs = require("node:fs")
100
+ const maximumBytes = 8192
101
+ let descriptor
102
+ try {
103
+ descriptor = fs.openSync(process.argv[2], fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | fs.constants.O_NOFOLLOW)
104
+ const metadata = fs.fstatSync(descriptor)
105
+ if (!metadata.isFile() || metadata.size === 0 || metadata.size > maximumBytes || (metadata.mode & 0o077) !== 0) throw new Error("invalid")
106
+ const buffer = Buffer.alloc(maximumBytes + 1)
107
+ const bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, 0)
108
+ if (bytesRead > maximumBytes) throw new Error("invalid")
109
+ const value = buffer.subarray(0, bytesRead).toString("utf8").replace(/[\\r\\n]+$/, "")
110
+ if (value.length === 0 || /[\\r\\n\\0]/u.test(value)) throw new Error("invalid")
111
+ process.stdout.write(value)
112
+ } catch {
113
+ process.stderr.write("threadwire: Telegram bot-token file is invalid.\\n")
114
+ process.exitCode = 2
115
+ } finally {
116
+ if (descriptor !== undefined) fs.closeSync(descriptor)
117
+ }
118
+ NODE
119
+ ) || threadwire_token_read_status=$?
120
+ unset threadwire_telegram_bot_token_file THREADWIRE_TELEGRAM_BOT_TOKEN_FILE
121
+ if [[ "$threadwire_token_read_status" -ne 0 ]]; then
122
+ exit "$threadwire_token_read_status"
123
+ fi
124
+ elif [[ ! -r "$THREADWIRE_DOTENV_PATH" ]]; then
125
+ unset threadwire_telegram_bot_token_file
78
126
  printf 'threadwire: required dotenv is not readable: %s\\n' "$THREADWIRE_DOTENV_PATH" >&2
79
127
  exit 2
80
- fi
81
-
82
- telegram_bot_token=""
83
- while IFS= read -r line || [[ -n "$line" ]]; do
84
- case "$line" in
85
- TELEGRAM_BOT_TOKEN=*) telegram_bot_token="\${line#TELEGRAM_BOT_TOKEN=}" ;;
86
- "export TELEGRAM_BOT_TOKEN="*) telegram_bot_token="\${line#export TELEGRAM_BOT_TOKEN=}" ;;
128
+ else
129
+ unset threadwire_telegram_bot_token_file
130
+ while IFS= read -r line || [[ -n "$line" ]]; do
131
+ case "$line" in
132
+ TELEGRAM_BOT_TOKEN=*) telegram_bot_token="\${line#TELEGRAM_BOT_TOKEN=}" ;;
133
+ "export TELEGRAM_BOT_TOKEN="*) telegram_bot_token="\${line#export TELEGRAM_BOT_TOKEN=}" ;;
134
+ esac
135
+ done < "$THREADWIRE_DOTENV_PATH"
136
+
137
+ telegram_bot_token="\${telegram_bot_token%$'\\r'}"
138
+ case "$telegram_bot_token" in
139
+ \\"*\\") telegram_bot_token="\${telegram_bot_token:1:\${#telegram_bot_token}-2}" ;;
140
+ \\'*\\') telegram_bot_token="\${telegram_bot_token:1:\${#telegram_bot_token}-2}" ;;
87
141
  esac
88
- done < "$THREADWIRE_DOTENV_PATH"
89
-
90
- telegram_bot_token="\${telegram_bot_token%$'\\r'}"
91
- case "$telegram_bot_token" in
92
- \\"*\\") telegram_bot_token="\${telegram_bot_token:1:\${#telegram_bot_token}-2}" ;;
93
- \\'*\\') telegram_bot_token="\${telegram_bot_token:1:\${#telegram_bot_token}-2}" ;;
94
- esac
142
+ fi
95
143
 
96
144
  if [[ -z "$telegram_bot_token" ]]; then
97
145
  printf 'threadwire: TELEGRAM_BOT_TOKEN is required in %s\\n' "$THREADWIRE_DOTENV_PATH" >&2
package/src/cli.js CHANGED
@@ -415,7 +415,8 @@ export async function main(arguments_, dependencies = {}) {
415
415
  parsed.providerArguments,
416
416
  prompt,
417
417
  parsed.resumeSession,
418
- providerEnvironment
418
+ providerEnvironment,
419
+ {cwd: parsed.cwd}
419
420
  )
420
421
  if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
421
422
  if (destination.type === "telegram") {
@@ -1,10 +1,9 @@
1
1
  // @ts-check
2
2
 
3
3
  import {spawn} from "node:child_process"
4
- import {providerExecutable} from "./providers/executable.js"
4
+ import {codexExecutable} from "./providers/executable.js"
5
5
  import {CapacityProbeError, DEFAULT_CAPACITY_TIMEOUT_MS, normalizeCodexRateLimits} from "./provider-capacity.js"
6
6
 
7
- const DEFAULT_EXECUTABLE = "/opt/data/libexec/threadwire/codex"
8
7
  const MAX_LINE_BYTES = 65_536
9
8
  const MAX_LINES = 1_024
10
9
 
@@ -17,6 +16,7 @@ const MAX_LINES = 1_024
17
16
  * @param {{
18
17
  * env?: NodeJS.ProcessEnv,
19
18
  * executable?: string | undefined,
19
+ * executableResolution?: Parameters<typeof codexExecutable>[1],
20
20
  * spawnImplementation?: typeof spawn | undefined,
21
21
  * timeoutMs?: number
22
22
  * }} options
@@ -26,7 +26,7 @@ export async function probeCodexCapacity(options) {
26
26
  const environment = options.env ?? process.env
27
27
  const spawnImplementation = options.spawnImplementation ?? spawn
28
28
  const timeoutMs = options.timeoutMs ?? DEFAULT_CAPACITY_TIMEOUT_MS
29
- const executable = options.executable ?? providerExecutable("THREADWIRE_CODEX_BIN", DEFAULT_EXECUTABLE, environment)
29
+ const executable = options.executable ?? codexExecutable(environment, options.executableResolution)
30
30
  const result = await appServerExchange(spawnImplementation, executable, environment, timeoutMs)
31
31
  return normalizeCodexRateLimits(result)
32
32
  }
@@ -1,16 +1,20 @@
1
1
  // @ts-check
2
2
 
3
- import {providerExecutable} from "./executable.js"
3
+ import {codexExecutable} from "./executable.js"
4
4
 
5
- const DEFAULT_EXECUTABLE = "/opt/data/libexec/threadwire/codex"
6
-
7
- /** @param {string[]} providerArguments @param {string} prompt @param {string | undefined} [resumeSession] */
8
- export function buildCodexCommand(providerArguments, prompt, resumeSession, environment = process.env) {
5
+ /**
6
+ * @param {string[]} providerArguments
7
+ * @param {string} prompt
8
+ * @param {string | undefined} [resumeSession]
9
+ * @param {NodeJS.ProcessEnv} [environment]
10
+ * @param {Parameters<typeof codexExecutable>[1]} [executableResolution]
11
+ */
12
+ export function buildCodexCommand(providerArguments, prompt, resumeSession, environment = process.env, executableResolution = {}) {
9
13
  rejectOwnedArguments(providerArguments)
10
14
  const arguments_ = resumeSession === undefined
11
15
  ? ["exec", "--json", ...providerArguments, prompt]
12
16
  : ["exec", "resume", "--json", ...providerArguments, resumeSession, prompt]
13
- return {executable: providerExecutable("THREADWIRE_CODEX_BIN", DEFAULT_EXECUTABLE, environment), arguments: arguments_}
17
+ return {executable: codexExecutable(environment, executableResolution), arguments: arguments_}
14
18
  }
15
19
 
16
20
  /** @param {string[]} arguments_ */
@@ -1,6 +1,12 @@
1
1
  // @ts-check
2
2
 
3
- import {accessSync, constants, statSync} from "node:fs"
3
+ import {accessSync, constants, realpathSync, statSync} from "node:fs"
4
+ import {resolve} from "node:path"
5
+
6
+ const CODEX_FRONT_DOOR_EXECUTABLES = new Set(["/opt/data/bin/codex"])
7
+
8
+ /** @typedef {(path: string) => boolean} ExecutableProbe */
9
+ /** @typedef {{canonicalize?: ((path: string) => string) | undefined, cwd?: string | undefined, isExecutable?: ExecutableProbe | undefined, warn?: ((warning: string) => void) | undefined}} ExecutableResolutionOptions */
4
10
 
5
11
  /**
6
12
  * Resolve the terminal provider executable Threadwire spawns. The default is the
@@ -27,3 +33,70 @@ export function providerExecutable(overrideVariable, defaultPath, environment =
27
33
  }
28
34
  return defaultPath
29
35
  }
36
+
37
+ /**
38
+ * Resolve Codex without consulting a bare command through spawn. A valid
39
+ * override wins, followed by executable PATH candidates in Unix lookup order.
40
+ * Known Threadwire front doors are skipped so a normal direct-CLI lookup cannot
41
+ * recurse into Threadwire.
42
+ * @param {NodeJS.ProcessEnv} [environment]
43
+ * @param {ExecutableResolutionOptions} [options]
44
+ * @returns {string}
45
+ */
46
+ export function codexExecutable(environment = process.env, options = {}) {
47
+ const isExecutable = options.isExecutable ?? executableFile
48
+ const warning = options.warn ?? console.error
49
+ const canonicalize = options.canonicalize ?? realpathSync
50
+ const cwd = options.cwd ?? process.cwd()
51
+ const override = environment.THREADWIRE_CODEX_BIN
52
+ if (override !== undefined && override.length > 0 && isExecutable(override)) return override
53
+
54
+ const fallback = discoveredCodexExecutable(environment, cwd, isExecutable, canonicalize) ?? "codex"
55
+ if (override !== undefined && override.length > 0) {
56
+ warning(`threadwire: THREADWIRE_CODEX_BIN=${override} is not executable, falling back to ${fallback}`)
57
+ }
58
+ return fallback
59
+ }
60
+
61
+ /**
62
+ * @param {NodeJS.ProcessEnv} environment
63
+ * @param {string} cwd
64
+ * @param {ExecutableProbe} isExecutable
65
+ * @param {(path: string) => string} canonicalize
66
+ * @returns {string | undefined}
67
+ */
68
+ function discoveredCodexExecutable(environment, cwd, isExecutable, canonicalize) {
69
+ if (environment.PATH === undefined) return undefined
70
+ for (const directory of environment.PATH.split(":")) {
71
+ const candidate = resolve(cwd, directory, "codex")
72
+ if (CODEX_FRONT_DOOR_EXECUTABLES.has(candidate)) continue
73
+ const canonicalPath = canonicalExecutable(candidate, isExecutable, canonicalize)
74
+ if (canonicalPath !== undefined && !CODEX_FRONT_DOOR_EXECUTABLES.has(canonicalPath)) return candidate
75
+ }
76
+ return undefined
77
+ }
78
+
79
+ /**
80
+ * @param {string} path
81
+ * @param {ExecutableProbe} isExecutable
82
+ * @param {(path: string) => string} canonicalize
83
+ * @returns {string | undefined}
84
+ */
85
+ function canonicalExecutable(path, isExecutable, canonicalize) {
86
+ if (!isExecutable(path)) return undefined
87
+ try {
88
+ return canonicalize(path)
89
+ } catch {
90
+ return undefined
91
+ }
92
+ }
93
+
94
+ /** @type {ExecutableProbe} */
95
+ function executableFile(path) {
96
+ try {
97
+ accessSync(path, constants.X_OK)
98
+ return statSync(path).isFile()
99
+ } catch {
100
+ return false
101
+ }
102
+ }
@@ -13,10 +13,10 @@ import {buildOpenCodeCommand, createOpenCodeParser, createOpenCodeSessionId} fro
13
13
  /** @type {readonly ["codex", "claude", "kimi", "opencode"]} */
14
14
  export const PROVIDERS = ["codex", "claude", "kimi", "opencode"]
15
15
 
16
- /** @param {string} name @param {string[]} providerArguments @param {string} prompt @param {string | undefined} resumeSession @param {NodeJS.ProcessEnv} [environment] @returns {Provider} */
17
- export function createProvider(name, providerArguments, prompt, resumeSession, environment = process.env) {
16
+ /** @param {string} name @param {string[]} providerArguments @param {string} prompt @param {string | undefined} resumeSession @param {NodeJS.ProcessEnv} [environment] @param {Parameters<typeof buildCodexCommand>[4]} [codexExecutableResolution] @returns {Provider} */
17
+ export function createProvider(name, providerArguments, prompt, resumeSession, environment = process.env, codexExecutableResolution = {}) {
18
18
  if (name === "codex") {
19
- const command = buildCodexCommand(providerArguments, prompt, resumeSession, environment)
19
+ const command = buildCodexCommand(providerArguments, prompt, resumeSession, environment, codexExecutableResolution)
20
20
  return {...command, name: "codex", parse: parseCodexEvent, sessionId: codexSessionId, health: extractProviderHealth}
21
21
  }
22
22
  if (name === "claude") {
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
 
3
- const EXECUTABLE = "/usr/local/bin/kimi"
3
+ const EXECUTABLE = "kimi"
4
4
  const ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,63}$/u
5
5
  const SESSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,511}$/u
6
6
  const TOOL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u
@@ -149,7 +149,7 @@ export async function dispatchWorker(job, config, dependencies = {}) {
149
149
  const deliveryIdentity = randomUUID()
150
150
  const admission = new DelegatedResultAdmission({output: {write: () => {}}})
151
151
  try {
152
- provider = createProviderImpl(job.provider, [], job.prompt, undefined, providerEnvironment)
152
+ provider = createProviderImpl(job.provider, [], job.prompt, undefined, providerEnvironment, {cwd})
153
153
  const transport = createFetchTransportImpl(config.botToken, undefined, config.telegramRequestTimeoutMs)
154
154
  sender = createTelegramSenderImpl(job.target, transport)
155
155
  control = new WorkerControlImpl({