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 +11 -0
- package/README.md +42 -14
- package/docs/capacity-admission.md +9 -4
- package/docs/container-runtime.md +12 -1
- package/docs/isolated-provider-runtime.md +2 -2
- package/package.json +1 -1
- package/scripts/install-local-launcher.js +70 -22
- package/src/cli.js +2 -1
- package/src/provider-capacity-codex.js +3 -3
- package/src/providers/codex.js +10 -6
- package/src/providers/executable.js +74 -1
- package/src/providers/index.js +3 -3
- package/src/providers/kimi.js +1 -1
- package/src/telegram-ingress/core.js +1 -1
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
43
|
-
`
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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`,
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
|
161
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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 {
|
|
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 ??
|
|
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
|
}
|
package/src/providers/codex.js
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {codexExecutable} from "./executable.js"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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:
|
|
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
|
+
}
|
package/src/providers/index.js
CHANGED
|
@@ -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") {
|
package/src/providers/kimi.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
const EXECUTABLE = "
|
|
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({
|