@staix/agent-hub 0.7.8 → 0.7.9
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 +4 -0
- package/README.md +2 -2
- package/docs/operations.md +21 -9
- package/docs/quickstart.md +1 -1
- package/docs/security.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +23 -0
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +9 -6
- package/src/adapters/claude-channel.ts +9 -5
- package/src/cli/init.ts +6 -2
- package/src/cli/main.ts +12 -5
- package/src/hub/checks.ts +1 -17
- package/src/hub/config-trust.ts +84 -0
- package/src/hub/daemon.ts +52 -34
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,10 @@ Issue and pull request numbers in the entries for 0.7.7 and earlier refer to the
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.7.9
|
|
8
|
+
|
|
9
|
+
- A committed `.agenthub/config.json` can no longer choose what the hub runs, which files it sends as credentials, where task text goes, or how far the local worker's sandbox reaches: those machine-local fields (launch commands, checks, the legacy MLX binary and model directory, gateway URLs and key files, the memory endpoint, sandbox widening) apply only from a file git confirms nobody committed, such as the new `.agenthub/config.local.json`, and are otherwise ignored with a log line and a doctor row. A tracked file is recognised by identity, which also closes a way past the 0.7.8 completion-check gate: a committed file under a spelling macOS opens as `config.json` but git does not match (such as `ſ` for `s`) counted as uncommitted (#17).
|
|
10
|
+
|
|
7
11
|
## 0.7.8
|
|
8
12
|
|
|
9
13
|
- README, the operations guide and the upgrade-recovery spec no longer describe protocol 10 and the queue commands as unreleased (#9).
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Cod
|
|
|
4
4
|
hub-owned local-LLM worker collaborate as peers in independent project directories, with
|
|
5
5
|
task-aware model routing (Switchyard) in front of a self-hosted gateway (OmniRoute).
|
|
6
6
|
|
|
7
|
-
Status: 0.7.
|
|
7
|
+
Status: 0.7.9, control protocol 10. Durable delivery records distinguish queued
|
|
8
8
|
work from uncertain execution. The [smoke checklist](docs/smoke.md) records
|
|
9
9
|
verified paths and remaining prerequisites.
|
|
10
10
|
|
|
@@ -28,7 +28,7 @@ cd <your project> && ahub init && ahub up && ahub tail
|
|
|
28
28
|
Or install the same version from GitHub:
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
bun add -g github:STAIxBWLB/agent-hub#v0.7.
|
|
31
|
+
bun add -g github:STAIxBWLB/agent-hub#v0.7.9 && ahub setup
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
The installed commands remain `ahub` and `agent-hub`.
|
package/docs/operations.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Operations guide
|
|
2
2
|
|
|
3
|
-
This guide describes ahub 0.7.
|
|
3
|
+
This guide describes ahub 0.7.9 and control protocol 10. Live verification
|
|
4
4
|
results and remaining prerequisites are recorded separately in [the smoke ledger](smoke.md).
|
|
5
5
|
|
|
6
6
|
## Install and start
|
|
@@ -22,6 +22,19 @@ Run it after an upgrade when those blocks need refreshing. `ahub setup`
|
|
|
22
22
|
updates the shared Claude plugin. Keep `ahub tail` open when a local worker
|
|
23
23
|
may request an approval.
|
|
24
24
|
|
|
25
|
+
`.agenthub/config.json` can be committed and shared. The fields that choose
|
|
26
|
+
what the hub runs, which files it sends as credentials, where task text goes,
|
|
27
|
+
or how far the local worker's sandbox reaches (`kimi_cmd`, `codex_bin`,
|
|
28
|
+
`pi.cmd`, `checks`, `mlx.bin`, `mlx.runtimeDir`, `mlx.modelPath`, `omniroute.urls`,
|
|
29
|
+
`omniroute.access_hosts`, the `omniroute` key files, `memory.worker_url`,
|
|
30
|
+
`local.read_allow`, `local.bash_network`) are machine-local: they apply only
|
|
31
|
+
from a file git confirms nobody committed. Put them in
|
|
32
|
+
`.agenthub/config.local.json` (`ahub init` adds it to `.gitignore`), which is
|
|
33
|
+
read after `config.json`; outside a git repository they keep their defaults, and
|
|
34
|
+
an empty value always means the default.
|
|
35
|
+
A committed value is ignored with a line in `hub.log`, a note from `ahub
|
|
36
|
+
codex` and `ahub models`, and a row in `ahub doctor`.
|
|
37
|
+
|
|
25
38
|
Managed launchers attach native peers to the project daemon:
|
|
26
39
|
|
|
27
40
|
```bash
|
|
@@ -64,9 +77,8 @@ marks such a task done, the tool answers at once, the hub runs the command in th
|
|
|
64
77
|
project root (one at a time; what it left in its process group is killed when it
|
|
65
78
|
exits, at the timeout or on shutdown), and then either
|
|
66
79
|
sends the task to review with the command, exit code and output tail, or keeps it
|
|
67
|
-
with its owner and tells it what failed.
|
|
68
|
-
|
|
69
|
-
choose commands the hub runs, and outside a git repository it runs none. Keys that
|
|
80
|
+
with its owner and tells it what failed. `checks` is machine-local (see Install
|
|
81
|
+
and start): a committed file must not choose commands the hub runs. Keys that
|
|
70
82
|
are not task classes are ignored with a log line. A hub stopped mid-check leaves
|
|
71
83
|
the task in progress (`check interrupted`); the owner marks it done again.
|
|
72
84
|
|
|
@@ -214,8 +226,8 @@ source and carries every recovery fix released up to it. Protocol 8 and older
|
|
|
214
226
|
project directory, without replacing the global CLI first:
|
|
215
227
|
|
|
216
228
|
```bash
|
|
217
|
-
bunx --package @staix/agent-hub@0.7.
|
|
218
|
-
bunx --package @staix/agent-hub@0.7.
|
|
229
|
+
bunx --package @staix/agent-hub@0.7.9 ahub upgrade --to 0.7.9 --dry-run
|
|
230
|
+
bunx --package @staix/agent-hub@0.7.9 ahub upgrade --to 0.7.9 --yes
|
|
219
231
|
```
|
|
220
232
|
|
|
221
233
|
| Running now | Coordinator to use |
|
|
@@ -237,19 +249,19 @@ The coordinator verifies and retains the exact target package, preserves its
|
|
|
237
249
|
own source, and promotes the global CLI only after restored projects pass
|
|
238
250
|
readback.
|
|
239
251
|
|
|
240
|
-
Once the installed CLI is 0.7.
|
|
252
|
+
Once the installed CLI is 0.7.9, review the current project or all registered
|
|
241
253
|
projects first:
|
|
242
254
|
|
|
243
255
|
```bash
|
|
244
256
|
ahub restart --dry-run
|
|
245
|
-
ahub upgrade --to 0.7.
|
|
257
|
+
ahub upgrade --to 0.7.9 --dry-run
|
|
246
258
|
```
|
|
247
259
|
|
|
248
260
|
Apply only after reviewing the plan:
|
|
249
261
|
|
|
250
262
|
```bash
|
|
251
263
|
ahub restart --yes
|
|
252
|
-
ahub upgrade --to 0.7.
|
|
264
|
+
ahub upgrade --to 0.7.9 --yes
|
|
253
265
|
ahub recovery status <operation-id>
|
|
254
266
|
ahub recovery resume <operation-id>
|
|
255
267
|
ahub recovery abort <operation-id>
|
package/docs/quickstart.md
CHANGED
package/docs/security.md
CHANGED
|
@@ -7,7 +7,7 @@ agent-hub connects agents that can each run commands. This page says what the hu
|
|
|
7
7
|
- **Other agents' text is untrusted.** Every message that crosses from one peer to another is framed as untrusted input (a channel tag with `meta.source` for Claude, a fixed header line plus a standing instruction for the others). A message body cannot forge the hub's own headers: such lines are quoted (`sanitize`). Replies inherit a hop count capped at 3, so agents cannot ping-pong forever; neither a digest nor a steer can reset it.
|
|
8
8
|
- **The control link is loopback plus a secret.** The daemon and the Codex proxy bind 127.0.0.1 only. The control WebSocket requires a per-run token (`.agenthub/state/control-token`, mode 600), and both servers refuse any request that carries an `Origin` header: any web page can open a WebSocket to localhost, and browsers always send `Origin`. External clients cannot claim the console user's id or a hub-managed peer's id.
|
|
9
9
|
- **Permission prompts stay on.** `ahub claude` and `ahub codex` add nothing that weakens the agents' own prompts. Kimi's and `local`'s permission requests are relayed to the console and cancelled after `approvals.timeout_s` (default 120 s) of silence; the macOS notification for a waiting request carries the peer and the tool name only. The one exception is the hub's own tools (`hub_send` and the task tools, matched by exact name): Kimi's requests for them are approved once without a prompt and logged by name, the same trust Codex gets through `approval_mode` in the hub's config. They touch no file and run no process, and every call passes the hub's own checks. The match relies on the agent putting the tool name in the request's `title`, as Kimi does; an ACP agent that titles calls with model-written text must not be configured as `kimi_cmd`. A payload longer than the console shows is marked as cut and never offers a session-wide grant. `--unattended` turns prompts off, says so loudly, and is never the default.
|
|
10
|
-
- **
|
|
10
|
+
- **A committed config cannot choose launch commands, credential files, data endpoints or a wider sandbox.** The machine-local fields (`kimi_cmd`, `codex_bin`, `pi.cmd`, `checks`, `mlx.bin`, `mlx.runtimeDir`, `mlx.modelPath`, `omniroute.urls`, `omniroute.access_hosts`, the `omniroute` key files, `memory.worker_url`, `local.read_allow`, `local.bash_network`) apply only from a config file git confirms nobody committed: `.agenthub/config.json` or `.agenthub/config.local.json`, matched by file identity so no other spelling the file system accepts slips past, and `.agenthub` itself not a committed symlink or submodule. Without a repository, or when git fails, they keep their defaults; an empty value always means the default. "Untracked" is answered by the repository that contains the project: a checkout copied or extracted into an unrelated repository, or into an ignored directory of one, is trusted like your own files. So a cloned repository cannot choose a launch command, a completion check, a gateway to send a key file to, a memory endpoint, or a wider sandbox. A command in `checks` runs as you, outside the local worker's sandbox, like a git hook. Nothing in `routing.toml` or in task text is ever run. `routing.toml` and the other shared fields still come from the checkout, and they matter: `routing.toml` picks the models the local worker and the hub's inference use at your gateway and can turn the PII constraint off, and roles and budget shape who does what. Review them in a repository you do not trust.
|
|
11
11
|
- **The local worker is boxed in.** Paths are resolved through symlinks and must stay inside the project; a secrets denylist (`.env*`, keys, credential files, the hub's own state) applies to its file tools, its git arguments and its memory capture alike; `.git` and `.agenthub` are not writable. Writes, edits, shell commands and mutating git wait for approval, and the approver sees what will be written or run, with control characters escaped. Everything it executes runs under the macOS sandbox, attended or not: no writes outside the project, its git dir and temp; the home directory is unreadable except toolchains; no `.git/hooks` or `.git/config` writes; no network, loopback included. Without the sandbox there is no `bash` tool.
|
|
12
12
|
- **PII has an enforced path.** A task matching `signals.pii_patterns` goes to `local` or to nobody; its text is absent from other peers' envelopes, the console stream, the log and the board listing; the console user reviews it; `local` answers such a turn to the console only, keeps it out of its history, refuses it when the only gateway is off campus, and may not save notes or spin off tasks during it. Nothing about it is sent to claude-mem, whose observer is a cloud model.
|
|
13
13
|
- **Secrets stay where they are read.** The gateway key and Cloudflare Access values are read inside the gateway client and go only into request headers: never into logs, errors, envelopes, tool output, memory, or the generated Switchyard config (the key travels by environment variable name). Subscription logins of Claude, Codex and Kimi are never proxied or pooled.
|
|
@@ -816,3 +816,26 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
|
|
|
816
816
|
(`check finished late`); if it is in progress with the same owner again, that
|
|
817
817
|
owner is asked to mark it done again. A check interrupted by a hub stop records
|
|
818
818
|
`check interrupted`, is not resumed, and the owner marks the task done again.
|
|
819
|
+
|
|
820
|
+
## Amendment: machine-local config fields (issue #17)
|
|
821
|
+
|
|
822
|
+
- Machine-local fields: `kimi_cmd`, `codex_bin`, `pi.cmd`, `checks`, `mlx.bin`,
|
|
823
|
+
`mlx.runtimeDir`, `mlx.modelPath`, `omniroute.urls`, `omniroute.access_hosts`,
|
|
824
|
+
`omniroute.api_key_file`, `omniroute.cf_client_id_file`,
|
|
825
|
+
`omniroute.cf_client_secret_file`, `memory.worker_url`, `local.read_allow`,
|
|
826
|
+
`local.bash_network` (`MACHINE_LOCAL` in `src/hub/config-trust.ts`).
|
|
827
|
+
- `loadConfig` reads `.agenthub/config.json`, then `.agenthub/config.local.json`
|
|
828
|
+
over it block by block. In each file, an empty machine-local value is dropped
|
|
829
|
+
(it means the default); one set to anything but its default applies only when
|
|
830
|
+
git confirms that file is untracked, matching tracked files under `.agenthub`
|
|
831
|
+
by identity (dev and inode) rather than by name, and `.agenthub` not a committed
|
|
832
|
+
symlink or submodule; otherwise it is dropped and the config's `ignored` list
|
|
833
|
+
says which and why. No repository or a git error refuses. A file that sets none
|
|
834
|
+
of them asks git nothing, so a committed copy of the template is quiet.
|
|
835
|
+
The containing repository answers "untracked": a checkout copied into an
|
|
836
|
+
unrelated repository is trusted (documented limit).
|
|
837
|
+
- The Claude channel reads `roles` from both files in the same order.
|
|
838
|
+
- This replaces the checks-only gate of issue #7: the daemon logs `ignored` at
|
|
839
|
+
start, `ahub codex` and `ahub models` print it, `ahub doctor` shows a row.
|
|
840
|
+
`ahub init` adds `.agenthub/config.local.json` to `.gitignore`.
|
|
841
|
+
- `routing.toml` and the shared fields still come from the checkout.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.9",
|
|
4
4
|
"description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Young Joon Lee",
|
|
@@ -15727,7 +15727,7 @@ class ControlClient {
|
|
|
15727
15727
|
// package.json
|
|
15728
15728
|
var package_default = {
|
|
15729
15729
|
name: "@staix/agent-hub",
|
|
15730
|
-
version: "0.7.
|
|
15730
|
+
version: "0.7.9",
|
|
15731
15731
|
description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
|
|
15732
15732
|
license: "MIT",
|
|
15733
15733
|
type: "module",
|
|
@@ -15834,11 +15834,14 @@ var projectRoot2 = process.env.AGENTHUB_PROJECT_DIR ?? process.cwd();
|
|
|
15834
15834
|
var peerId = process.env.AGENTHUB_PEER_ID ?? "claude";
|
|
15835
15835
|
var toolsOnly = process.env.AGENTHUB_MODE === "tools";
|
|
15836
15836
|
function roles() {
|
|
15837
|
-
|
|
15838
|
-
|
|
15839
|
-
|
|
15840
|
-
|
|
15841
|
-
|
|
15837
|
+
const read = (name) => {
|
|
15838
|
+
try {
|
|
15839
|
+
return JSON.parse(readFileSync3(join3(projectRoot2, ".agenthub", name), "utf8")).roles ?? {};
|
|
15840
|
+
} catch {
|
|
15841
|
+
return {};
|
|
15842
|
+
}
|
|
15843
|
+
};
|
|
15844
|
+
return { ...DEFAULT_ROLES, ...read("config.json"), ...read("config.local.json") };
|
|
15842
15845
|
}
|
|
15843
15846
|
var MAX_RECONNECT_DELAY_MS = 30000;
|
|
15844
15847
|
var TERMINAL_CLOSES = {
|
|
@@ -21,11 +21,15 @@ const peerId = process.env.AGENTHUB_PEER_ID ?? "claude";
|
|
|
21
21
|
const toolsOnly = process.env.AGENTHUB_MODE === "tools";
|
|
22
22
|
|
|
23
23
|
function roles(): Record<string, string[]> {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
// The machine's own file overrides the shared one, as in loadConfig (issue #17).
|
|
25
|
+
const read = (name: string) => {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(join(projectRoot, ".agenthub", name), "utf8")).roles ?? {};
|
|
28
|
+
} catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
return { ...DEFAULT_ROLES, ...read("config.json"), ...read("config.local.json") };
|
|
29
33
|
}
|
|
30
34
|
const MAX_RECONNECT_DELAY_MS = 30_000;
|
|
31
35
|
/** Close codes a retry cannot fix: bad token, wrong project, reserved or taken id, wire version. */
|
package/src/cli/init.ts
CHANGED
|
@@ -59,7 +59,11 @@ export function init(cwd: string): string[] {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
const ignore = join(cwd, ".gitignore");
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
let lines = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
|
|
63
|
+
// The machine's own config: only a file nobody committed may choose commands and credential files (issue #17).
|
|
64
|
+
for (const entry of [".agenthub/state/", ".agenthub/config.local.json"]) {
|
|
65
|
+
if (!lines.split("\n").includes(entry)) lines = `${lines}${lines && !lines.endsWith("\n") ? "\n" : ""}${entry}\n`;
|
|
66
|
+
}
|
|
67
|
+
write(ignore, lines);
|
|
64
68
|
return changed;
|
|
65
69
|
}
|
package/src/cli/main.ts
CHANGED
|
@@ -96,6 +96,12 @@ try {
|
|
|
96
96
|
} else selected = projectContext(process.cwd());
|
|
97
97
|
} catch (error) { fail((error as Error).message); }
|
|
98
98
|
const cwd = selected.root;
|
|
99
|
+
/** The project config, saying on stderr which machine-local fields it had to ignore (issue #17). */
|
|
100
|
+
const projectConfig = () => {
|
|
101
|
+
const config = loadConfig(cwd);
|
|
102
|
+
for (const line of config.ignored ?? []) console.error(`note: ${line}; only a config file nobody committed may set it`);
|
|
103
|
+
return config;
|
|
104
|
+
};
|
|
99
105
|
const stateDir = selected.stateDir;
|
|
100
106
|
try { process.chdir(cwd); } catch { fail(`project directory is unavailable: ${cwd}`); }
|
|
101
107
|
const unattendedEnv = process.env.AGENTHUB_UNATTENDED === "1";
|
|
@@ -472,7 +478,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
472
478
|
if (!res.ok) fail(res.error);
|
|
473
479
|
const control = readControl(stateDir);
|
|
474
480
|
if (control?.instanceId) await recordTerminalLaunch("codex", cwd, stateDir, control.instanceId);
|
|
475
|
-
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin:
|
|
481
|
+
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, proxyUrl: res.proxyUrl, codexBin: projectConfig().codex_bin });
|
|
476
482
|
if (launch0.warning) console.error(launch0.warning);
|
|
477
483
|
exec(launch.cmd, launch.args);
|
|
478
484
|
},
|
|
@@ -517,7 +523,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
517
523
|
|
|
518
524
|
models: async () => {
|
|
519
525
|
const action = args[0] ?? "status";
|
|
520
|
-
const configured =
|
|
526
|
+
const configured = projectConfig().mlx;
|
|
521
527
|
const runtimeDir = configured.runtimeDir ? resolve(cwd, configured.runtimeDir) : join(homedir(), ".agenthub", "runtimes", "mlx");
|
|
522
528
|
const modelPath = configured.modelPath ? resolve(cwd, configured.modelPath) : join(homedir(), ".agenthub", "models", "qwen3-8b-mlx");
|
|
523
529
|
const mlxOptions = configured.provider === "ollama" ? configured : { ...configured, runtimeDir, modelPath };
|
|
@@ -757,13 +763,14 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
757
763
|
row(plugin.state === "current", "claude plugin", plugin.state === "missing" ? "missing: run ahub setup" : plugin.state === "current" ? `agent-hub@agent-hub ${plugin.version}` : `agent-hub@agent-hub is stale (${plugin.why}): run ahub setup`);
|
|
758
764
|
|
|
759
765
|
const config = loadConfig(cwd);
|
|
766
|
+
row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
|
|
760
767
|
const omni = new OmniRoute(config.omniroute);
|
|
761
768
|
const gateway = await omni.base();
|
|
762
|
-
row(!!gateway, "omniroute", gateway ? `${new URL(gateway).host} healthy` : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? "no candidate reachable (VPN off?); ahub local cannot run" : "not configured: set omniroute.urls in .agenthub/config.json (any OpenAI-compatible gateway); ahub local cannot run");
|
|
763
|
-
row(!!omni.apiKey(), "omniroute key", omni.apiKey() ? "present" : "missing: set OMNIROUTE_API_KEY or omniroute.api_key_file in .agenthub/config.json");
|
|
769
|
+
row(!!gateway, "omniroute", gateway ? `${new URL(gateway).host} healthy` : config.omniroute.urls.length || process.env.AGENTHUB_OMNIROUTE_URL ? "no candidate reachable (VPN off?); ahub local cannot run" : "not configured: set omniroute.urls in .agenthub/config.local.json (any OpenAI-compatible gateway); ahub local cannot run");
|
|
770
|
+
row(!!omni.apiKey(), "omniroute key", omni.apiKey() ? "present" : "missing: set OMNIROUTE_API_KEY or omniroute.api_key_file in .agenthub/config.local.json");
|
|
764
771
|
const sy = spawnSync(process.env.AGENTHUB_SWITCHYARD_BIN ?? "switchyard-server", ["--version"], { encoding: "utf8" });
|
|
765
772
|
row(sy.status === 0 ? true : undefined, "switchyard", sy.status === 0 ? sy.stdout.trim() : "not installed: ahub local uses fixed_model on OmniRoute (cargo install --locked switchyard-server)");
|
|
766
|
-
const mlxConfig =
|
|
773
|
+
const mlxConfig = config.mlx;
|
|
767
774
|
const mlx = await inspectMlx({ ...mlxConfig, runtimeDir: mlxConfig.runtimeDir ? resolve(cwd, mlxConfig.runtimeDir) : undefined, modelPath: mlxConfig.modelPath ? resolve(cwd, mlxConfig.modelPath) : undefined });
|
|
768
775
|
row(mlx.state === "ready" || mlx.state === "stopped", "pi mlx", `${mlx.state}${mlx.model ? ` (${mlx.model})` : ""}${mlx.lastError ? `: ${mlx.lastError}` : ""}`);
|
|
769
776
|
|
package/src/hub/checks.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
2
|
import { childEnv } from "./child-process.ts";
|
|
3
3
|
|
|
4
4
|
export interface CheckResult {
|
|
@@ -16,22 +16,6 @@ const KEEP_CHARS = 64 * 1024;
|
|
|
16
16
|
/** How long output may still arrive after the command exited; a descendant that left the group can hold the pipes. */
|
|
17
17
|
const DRAIN_MS = 500;
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* Why the completion checks of issue #7 must not run, or undefined when git confirms nobody committed the config.
|
|
21
|
-
* A command a cloned repository ships would otherwise run on the owner's machine the first time an agent marks a task
|
|
22
|
-
* done. Unknown is not untracked: no repository, no git, or a git error all refuse. The directory itself counts too (a
|
|
23
|
-
* committed symlink or submodule at `.agenthub`), and so does any spelling of the path, since macOS opens either.
|
|
24
|
-
*/
|
|
25
|
-
export function checksRefusal(cwd: string): string | undefined {
|
|
26
|
-
const r = spawnSync("git", ["-C", cwd, "ls-files", "-s", "-z", "--", ":(icase).agenthub"], { encoding: "utf8", env: childEnv(process.env) });
|
|
27
|
-
if (r.status !== 0) return "git could not confirm that .agenthub/config.json is untracked";
|
|
28
|
-
const tracked = r.stdout.split("\0").some((row) => {
|
|
29
|
-
const path = row.slice(row.indexOf("\t") + 1).toLowerCase();
|
|
30
|
-
return path === ".agenthub" || path === ".agenthub/config.json";
|
|
31
|
-
});
|
|
32
|
-
return tracked ? ".agenthub/config.json is committed to git" : undefined;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
19
|
/** Runs one check command in the project root, in its own process group so a timeout stops what it started. */
|
|
36
20
|
export function runCheck(command: string, cwd: string, timeoutMs: number, running?: Set<() => void>): Promise<CheckResult> {
|
|
37
21
|
return new Promise((resolve) => {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { childEnv } from "./child-process.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fields of the project config that choose what the hub runs, which files it sends as credentials, where task text
|
|
8
|
+
* goes, or how far the local worker's sandbox reaches (issue #17). Only a file git confirms nobody committed may set
|
|
9
|
+
* them; a cloned repository would otherwise choose them for the owner's machine.
|
|
10
|
+
*/
|
|
11
|
+
export const MACHINE_LOCAL = [
|
|
12
|
+
"kimi_cmd",
|
|
13
|
+
"codex_bin",
|
|
14
|
+
"pi.cmd",
|
|
15
|
+
"checks",
|
|
16
|
+
"mlx.bin",
|
|
17
|
+
"mlx.runtimeDir",
|
|
18
|
+
"mlx.modelPath", // the legacy runtime runs code a model directory names (mlx-lm's model_file)
|
|
19
|
+
"omniroute.urls",
|
|
20
|
+
"omniroute.access_hosts",
|
|
21
|
+
"omniroute.api_key_file",
|
|
22
|
+
"omniroute.cf_client_id_file",
|
|
23
|
+
"omniroute.cf_client_secret_file",
|
|
24
|
+
"memory.worker_url",
|
|
25
|
+
"local.read_allow",
|
|
26
|
+
"local.bash_network",
|
|
27
|
+
] as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Why `.agenthub/<name>` must not set machine-local fields, or undefined when git confirms nobody committed it.
|
|
31
|
+
* Unknown is not untracked: no repository, no git, or a git error all refuse. The directory itself counts too (a
|
|
32
|
+
* committed symlink or submodule at `.agenthub`). A tracked file is matched by identity, not by name: macOS opens
|
|
33
|
+
* spellings git keeps apart (letter case, and folds such as "ſ" for "s").
|
|
34
|
+
*/
|
|
35
|
+
export function configRefusal(cwd: string, name: string): string | undefined {
|
|
36
|
+
const r = spawnSync("git", ["-C", cwd, "ls-files", "-s", "-z", "--", ":(icase).agenthub"], { encoding: "utf8", env: childEnv(process.env) });
|
|
37
|
+
if (r.status !== 0) return `git could not confirm that .agenthub/${name} is untracked`;
|
|
38
|
+
let opened: { dev: number; ino: number } | undefined;
|
|
39
|
+
try {
|
|
40
|
+
opened = statSync(join(cwd, ".agenthub", name));
|
|
41
|
+
} catch {
|
|
42
|
+
// not there: only the directory entry can still matter
|
|
43
|
+
}
|
|
44
|
+
const tracked = r.stdout.split("\0").filter(Boolean).some((row) => {
|
|
45
|
+
const path = row.slice(row.indexOf("\t") + 1);
|
|
46
|
+
if (path.toLowerCase() === ".agenthub") return true;
|
|
47
|
+
try {
|
|
48
|
+
const s = statSync(join(cwd, path));
|
|
49
|
+
return !!opened && s.dev === opened.dev && s.ino === opened.ino;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
return tracked ? `.agenthub/${name} is committed to git` : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const valueAt = (o: unknown, path: string): unknown => path.split(".").reduce<unknown>((v, k) => (v && typeof v === "object" ? (v as Record<string, unknown>)[k] : undefined), o);
|
|
58
|
+
/** An empty value chooses nothing (the template writes `""` for the key files), so it is never reported. */
|
|
59
|
+
const chooses = (v: unknown) => v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0);
|
|
60
|
+
|
|
61
|
+
const drop = (file: Record<string, unknown>, path: string) => {
|
|
62
|
+
const [head, key] = path.split(".") as [string, string?];
|
|
63
|
+
const block = file[head];
|
|
64
|
+
if (!key) delete file[head];
|
|
65
|
+
else if (block && typeof block === "object") delete (block as Record<string, unknown>)[key];
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Drops the machine-local fields `file` sets to anything but the default unless git vouches for it, and says which
|
|
70
|
+
* went and why. An empty value always goes, trusted or not: it means the default, never "here" (an empty runtimeDir
|
|
71
|
+
* once resolved to the project root). A committed copy of the template sets them to defaults and asks git nothing.
|
|
72
|
+
*/
|
|
73
|
+
export function stripUntrusted(file: Record<string, unknown>, defaults: object, cwd: string, name: string): string | undefined {
|
|
74
|
+
for (const p of MACHINE_LOCAL) if (valueAt(file, p) !== undefined && !chooses(valueAt(file, p))) drop(file, p);
|
|
75
|
+
const set = MACHINE_LOCAL.filter((p) => {
|
|
76
|
+
const v = valueAt(file, p);
|
|
77
|
+
return v !== undefined && JSON.stringify(v) !== JSON.stringify(valueAt(defaults, p));
|
|
78
|
+
});
|
|
79
|
+
if (!set.length) return undefined;
|
|
80
|
+
const why = configRefusal(cwd, name);
|
|
81
|
+
if (!why) return undefined;
|
|
82
|
+
for (const p of set) drop(file, p);
|
|
83
|
+
return `${set.join(", ")} in .agenthub/${name} ignored: ${why}`;
|
|
84
|
+
}
|
package/src/hub/daemon.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { join, resolve } from "node:path";
|
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
6
|
import { childEnv } from "./child-process.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { runCheck } from "./checks.ts";
|
|
8
|
+
import { stripUntrusted } from "./config-trust.ts";
|
|
8
9
|
import type { ServerWebSocket } from "bun";
|
|
9
10
|
import { AcpPeer, type PermissionRequest } from "../adapters/acp.ts";
|
|
10
11
|
import { CodexPeer } from "../adapters/codex-appserver.ts";
|
|
@@ -62,6 +63,8 @@ export interface HubConfig {
|
|
|
62
63
|
tasks: { release_after_min: number };
|
|
63
64
|
/** A command per task class run when the owner marks the task done, and its timeout (issue #7). */
|
|
64
65
|
checks: { timeout_s: number; [cls: string]: string | number };
|
|
66
|
+
/** Machine-local fields a config file set but git could not vouch for, and why (issue #17). */
|
|
67
|
+
ignored?: string[];
|
|
65
68
|
}
|
|
66
69
|
export const DEFAULT_CONFIG: HubConfig = {
|
|
67
70
|
watchdog_ms: DEFAULT_WATCHDOG_MS,
|
|
@@ -90,39 +93,54 @@ export { stateDirFor };
|
|
|
90
93
|
const RESERVED_IDS = new Set([USER, "codex", "kimi", "local", "pi", "hub", DIGEST]);
|
|
91
94
|
const PEER_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
92
95
|
|
|
96
|
+
/** The shared project config, then the machine's own file, which overrides it block by block (issue #17). */
|
|
97
|
+
const CONFIG_FILES = ["config.json", "config.local.json"] as const;
|
|
98
|
+
const CONFIG_BLOCKS = ["memory", "roles", "budget", "inference", "omniroute", "local", "pi", "approvals", "tasks", "checks", "mlx"];
|
|
99
|
+
|
|
93
100
|
export function loadConfig(cwd: string): HubConfig {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
101
|
+
const ignored: string[] = [];
|
|
102
|
+
const files = CONFIG_FILES.flatMap((name) => {
|
|
103
|
+
let file: Record<string, any>; // parsed JSON, checked field by field below
|
|
104
|
+
try {
|
|
105
|
+
file = JSON.parse(readFileSync(join(cwd, ".agenthub", name), "utf8"));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
108
|
+
throw error;
|
|
100
109
|
}
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
local: { ...DEFAULT_CONFIG.local, ...file.local },
|
|
113
|
-
pi: { ...DEFAULT_CONFIG.pi, ...file.pi },
|
|
114
|
-
approvals: {
|
|
115
|
-
timeout_s: file.approvals?.timeout_s ?? DEFAULT_CONFIG.approvals.timeout_s,
|
|
116
|
-
notify: typeof file.approvals?.notify === "boolean" ? file.approvals.notify : process.platform === "darwin",
|
|
117
|
-
},
|
|
118
|
-
tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks },
|
|
119
|
-
checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
|
|
120
|
-
mlx,
|
|
121
|
-
};
|
|
122
|
-
} catch (error) {
|
|
123
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return DEFAULT_CONFIG;
|
|
124
|
-
throw error;
|
|
110
|
+
const why = stripUntrusted(file, DEFAULT_CONFIG, cwd, name);
|
|
111
|
+
if (why) ignored.push(why);
|
|
112
|
+
return [file];
|
|
113
|
+
});
|
|
114
|
+
if (!files.length) return DEFAULT_CONFIG;
|
|
115
|
+
const file = files.reduce((a, b) => ({ ...a, ...b, ...Object.fromEntries(CONFIG_BLOCKS.filter((k) => a[k] !== undefined && b[k] !== undefined).map((k) => [k, { ...a[k], ...b[k] }])) }));
|
|
116
|
+
delete file.ignored; // the hub's own record, never a file's
|
|
117
|
+
if (file.mlx != null && (typeof file.mlx !== "object" || Array.isArray(file.mlx))) throw new Error("mlx configuration must be an object");
|
|
118
|
+
if (file.mlx?.provider !== undefined && !["ollama", "legacy"].includes(file.mlx.provider)) throw new Error("mlx.provider must be ollama or legacy");
|
|
119
|
+
if (file.mlx?.provider === undefined && (file.mlx?.modelPath || file.mlx?.runtimeDir || file.mlx?.bin || file.mlx?.port)) {
|
|
120
|
+
throw new Error("legacy MLX configuration requires explicit mlx.provider=legacy; migrate to provider=ollama to avoid Python serving");
|
|
125
121
|
}
|
|
122
|
+
const mlx = { ...(file.mlx?.provider === "legacy" ? { provider: "legacy" as const, maxInputTokens: 16_000, maxTokens: 2048 } : DEFAULT_CONFIG.mlx), ...file.mlx };
|
|
123
|
+
if (typeof mlx.runtimeDir === "string" && mlx.runtimeDir) mlx.runtimeDir = resolve(cwd, mlx.runtimeDir);
|
|
124
|
+
if (typeof mlx.modelPath === "string" && mlx.modelPath) mlx.modelPath = resolve(cwd, mlx.modelPath);
|
|
125
|
+
return {
|
|
126
|
+
...DEFAULT_CONFIG,
|
|
127
|
+
...file,
|
|
128
|
+
memory: { ...DEFAULT_CONFIG.memory, ...file.memory },
|
|
129
|
+
roles: { ...DEFAULT_CONFIG.roles, ...file.roles },
|
|
130
|
+
budget: { ...DEFAULT_CONFIG.budget, ...file.budget },
|
|
131
|
+
inference: { ...DEFAULT_CONFIG.inference, ...file.inference },
|
|
132
|
+
omniroute: { ...DEFAULT_CONFIG.omniroute, ...file.omniroute },
|
|
133
|
+
local: { ...DEFAULT_CONFIG.local, ...file.local },
|
|
134
|
+
pi: { ...DEFAULT_CONFIG.pi, ...file.pi },
|
|
135
|
+
approvals: {
|
|
136
|
+
timeout_s: file.approvals?.timeout_s ?? DEFAULT_CONFIG.approvals.timeout_s,
|
|
137
|
+
notify: typeof file.approvals?.notify === "boolean" ? file.approvals.notify : process.platform === "darwin",
|
|
138
|
+
},
|
|
139
|
+
tasks: { ...DEFAULT_CONFIG.tasks, ...file.tasks },
|
|
140
|
+
checks: { ...DEFAULT_CONFIG.checks, ...file.checks },
|
|
141
|
+
mlx,
|
|
142
|
+
...(ignored.length ? { ignored } : {}),
|
|
143
|
+
};
|
|
126
144
|
}
|
|
127
145
|
|
|
128
146
|
export interface DaemonOptions {
|
|
@@ -326,9 +344,9 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
326
344
|
const checkCommands = Object.fromEntries(Object.entries(config.checks).filter(([k, v]) => (CLASSES as readonly string[]).includes(k) && typeof v === "string" && v.trim())) as Record<string, string>;
|
|
327
345
|
const strayChecks = Object.keys(config.checks).filter((k) => k !== "timeout_s" && !(k in checkCommands));
|
|
328
346
|
if (strayChecks.length) log(`checks ignored for ${strayChecks.join(", ")}: not a task class with a command (classes: ${CLASSES.join(", ")})`);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const checksAllowed = Object.keys(checkCommands).length > 0
|
|
347
|
+
// Commands and the other machine-local fields came only from a file git vouched for (loadConfig, issue #17).
|
|
348
|
+
for (const line of config.ignored ?? []) log(`${line}; only a config file nobody committed may set it`);
|
|
349
|
+
const checksAllowed = Object.keys(checkCommands).length > 0;
|
|
332
350
|
const checkTimeoutS = typeof config.checks.timeout_s === "number" && config.checks.timeout_s >= 1 ? Math.min(config.checks.timeout_s, 3600) : 600;
|
|
333
351
|
const runningChecks = new Set<() => void>();
|
|
334
352
|
let checksClosed = false; // set on stop: a check still queued then must not start and outlive the hub
|