@staix/agent-hub 0.11.0 → 0.12.0
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 +6 -0
- package/README.md +2 -2
- package/docs/operations.md +32 -19
- package/docs/quickstart.md +1 -1
- package/docs/security.md +1 -1
- package/docs/smoke.md +27 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +4 -2
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/adapters/local-worker.ts +2 -2
- package/src/cli/main.ts +1 -0
- package/src/hub/config-trust.ts +0 -1
- package/src/hub/daemon.ts +20 -7
- package/src/local/proxy.ts +30 -3
- package/src/local/sandbox.ts +22 -33
- package/templates/config.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Issue and pull request numbers in the entries for 0.7.7 and earlier refer to the previous repository, archived on 2026-09-30 when this repository's history was rewritten; the one exception is the open smoke-check issue, formerly #12, which moved here as #1. Numbers in newer entries refer to this repository.
|
|
4
4
|
|
|
5
|
+
## 0.12.0
|
|
6
|
+
|
|
7
|
+
- The egress proxy logs every refusal: an oversized request header and an unreachable listed host now leave a `network: refused` line too, the latter with its error code, never the error text; a failure after the tunnel opened closes the connection instead of writing an HTTP answer into it (#81).
|
|
8
|
+
- Behind NAT64 with the well-known prefix `64:ff9b::/96`, the egress proxy judges an answer by the IPv4 address it carries, so a listed name that resolves to an internal IPv4 host through DNS64 is refused; the local-use prefix `64:ff9b:1::/48` is refused outright, and a scoped IPv6 address (`fe80::1%en0`) counts as internal. A network-specific NAT64 prefix is not recognised (#82).
|
|
9
|
+
- `local.sandbox: "allow-default"` is removed: the local worker's and Pi's commands always run under the deny-default sandbox, and a project that still sets it starts normally with a note in `hub.log` and `ahub doctor` (paths a toolchain needs go in `local.read_allow`). `local.bash_network: "direct"` still works until 0.13.0, with the same note naming that release (#83).
|
|
10
|
+
|
|
5
11
|
## 0.11.0
|
|
6
12
|
|
|
7
13
|
- Task operations settle model-written arguments before anything reaches the board: an `owner` or `peer` that is not a peer id, or a task id that is not a whole number, is refused with a clear message instead of leaving an ownerless task behind after a database error; `null` and an empty owner mean no owner (#70).
|
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
|
+
Status: 0.12.0, 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.
|
|
31
|
+
bun add -g github:STAIxBWLB/agent-hub#v0.12.0 && 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.
|
|
3
|
+
This guide describes ahub 0.12.0 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
|
|
@@ -48,16 +48,23 @@ port 443 unless an entry names one) to the hosts in `local.network_allow`, and
|
|
|
48
48
|
the profile denies every other connection, direct egress and other loopback
|
|
49
49
|
ports (claude-mem's, the Codex app-server's) included. A listed name also
|
|
50
50
|
covers its subdomains; a name that resolves to a loopback or private address is
|
|
51
|
-
refused, and so is plain HTTP.
|
|
52
|
-
|
|
51
|
+
refused, and so is plain HTTP. Behind NAT64 with the well-known prefix
|
|
52
|
+
`64:ff9b::/96`, the IPv4 address inside the answer is what counts; the local-use
|
|
53
|
+
prefix `64:ff9b:1::/48` is refused outright, so on a network whose DNS64 uses it
|
|
54
|
+
an IPv4-only host such as github.com is refused too; use `"direct"` there (it is
|
|
55
|
+
removed in 0.13.0). A network-specific NAT64 prefix is not recognised. Every
|
|
56
|
+
refusal is a `network: refused` line in `hub.log`, by host where one is known
|
|
57
|
+
(with the method for plain HTTP, and the error code when a listed host is
|
|
58
|
+
unreachable), never a path, query or header. The default list holds the npm, PyPI, crates.io and Go module
|
|
53
59
|
registries and GitHub's code hosts; set `local.network_allow` in
|
|
54
60
|
`config.local.json` to replace it. `"direct"` keeps the open network of 0.10 and
|
|
55
|
-
earlier
|
|
61
|
+
earlier until 0.13.0, which removes it; while it is set, `hub.log` and `ahub
|
|
62
|
+
doctor` say so. With network on, commands may also read the public CA
|
|
56
63
|
bundles and Python's `certifi/cacert.pem`, which the `*.pem` key deny would
|
|
57
64
|
otherwise hide.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
65
|
+
The allow-default profile of 0.9 and earlier was removed in 0.12.0: a
|
|
66
|
+
`local.sandbox` setting is ignored (`"allow-default"` with a note in `hub.log`
|
|
67
|
+
and `ahub doctor`), and a toolchain the profile lacks goes in `local.read_allow`. Newly closed outside home: `/Applications`
|
|
61
68
|
(an app's bundled CLI), `/nix`, `/Volumes` and `/Users/Shared`. A toolchain
|
|
62
69
|
there, or elsewhere in your home (a CI tool cache, a version manager the profile
|
|
63
70
|
does not list), needs its directory in `local.read_allow`, for example
|
|
@@ -442,8 +449,8 @@ source and carries every recovery fix released up to it. Protocol 8 and older
|
|
|
442
449
|
project directory, without replacing the global CLI first:
|
|
443
450
|
|
|
444
451
|
```bash
|
|
445
|
-
bunx --package @staix/agent-hub@0.
|
|
446
|
-
bunx --package @staix/agent-hub@0.
|
|
452
|
+
bunx --package @staix/agent-hub@0.12.0 ahub upgrade --to 0.12.0 --dry-run
|
|
453
|
+
bunx --package @staix/agent-hub@0.12.0 ahub upgrade --to 0.12.0 --yes
|
|
447
454
|
```
|
|
448
455
|
|
|
449
456
|
| Running now | Coordinator to use |
|
|
@@ -466,28 +473,29 @@ no blocker, and `--yes` stops at staging ("target protocol requires a newer
|
|
|
466
473
|
coordinator") with an operation left to clear by `ahub recovery abort <id>`. An
|
|
467
474
|
older 0.7.x CLI may lack recovery fixes released after it. The
|
|
468
475
|
[smoke ledger](smoke.md) records dry-runs from real 0.6.4 and 0.7.5 hubs (issue
|
|
469
|
-
#75) and these applied upgrades: one with the 0.7.0 coordinator
|
|
470
|
-
0.9.0 coordinator from a running 0.8.1 hub with tasks and a budget pause
|
|
471
|
-
|
|
472
|
-
|
|
476
|
+
#75) and these applied upgrades: one with the 0.7.0 coordinator; one with the
|
|
477
|
+
0.9.0 coordinator from a running 0.8.1 hub with tasks and a budget pause; one
|
|
478
|
+
with the 0.10.0 coordinator from a 0.9.0 hub (one completion check running and
|
|
479
|
+
one queued); and one with the 0.11.0 coordinator from a 0.10.0 hub with
|
|
480
|
+
`local.bash_network` on.
|
|
473
481
|
|
|
474
482
|
The coordinator verifies and retains the exact target package, preserves its
|
|
475
483
|
own source, and promotes the global CLI only after restored projects pass
|
|
476
484
|
readback.
|
|
477
485
|
|
|
478
|
-
Once the installed CLI is 0.
|
|
486
|
+
Once the installed CLI is 0.12.0, review the current project or all registered
|
|
479
487
|
projects first:
|
|
480
488
|
|
|
481
489
|
```bash
|
|
482
490
|
ahub restart --dry-run
|
|
483
|
-
ahub upgrade --to 0.
|
|
491
|
+
ahub upgrade --to 0.12.0 --dry-run
|
|
484
492
|
```
|
|
485
493
|
|
|
486
494
|
Apply only after reviewing the plan:
|
|
487
495
|
|
|
488
496
|
```bash
|
|
489
497
|
ahub restart --yes
|
|
490
|
-
ahub upgrade --to 0.
|
|
498
|
+
ahub upgrade --to 0.12.0 --yes
|
|
491
499
|
ahub recovery status <operation-id>
|
|
492
500
|
ahub recovery resume <operation-id>
|
|
493
501
|
ahub recovery abort <operation-id>
|
|
@@ -513,7 +521,7 @@ to 0 to opt out.
|
|
|
513
521
|
0.10.0 changes every project's local worker: its commands run under the
|
|
514
522
|
deny-default sandbox described above, and a toolchain outside the listed
|
|
515
523
|
directories needs `local.read_allow`; `"local": { "sandbox": "allow-default" }`
|
|
516
|
-
in `config.local.json`
|
|
524
|
+
in `config.local.json` restored the old profile until 0.12.0. Off unless set:
|
|
517
525
|
`review.adaptive`, `recovery.auto_resume_after_crash` and `capabilities`. A hub
|
|
518
526
|
before 0.10.0 keeps no session record, so a crash of one is not reported as such
|
|
519
527
|
by the next start.
|
|
@@ -523,10 +531,15 @@ Pi's commands reach the network only through the hub's egress proxy, to the
|
|
|
523
531
|
hosts in `local.network_allow` (package registries and GitHub's code hosts by
|
|
524
532
|
default). `local.network_allow` is machine-local and replaces the default list:
|
|
525
533
|
a project that needs another host sets it in `config.local.json` with the
|
|
526
|
-
defaults it still needs. `"direct"` keeps the open network of 0.10.0
|
|
527
|
-
|
|
534
|
+
defaults it still needs. `"direct"` keeps the open network of 0.10.0 until
|
|
535
|
+
0.13.0. Each command gets a temp dir of its own, and under deny-default the
|
|
528
536
|
shared temp dirs are closed.
|
|
529
537
|
|
|
538
|
+
0.12.0 removes `local.sandbox: "allow-default"`: a project that still sets it
|
|
539
|
+
runs under the deny-default sandbox, and `hub.log` and `ahub doctor` say so.
|
|
540
|
+
`local.bash_network: "direct"` still works until 0.13.0, and both name it while
|
|
541
|
+
it is set.
|
|
542
|
+
|
|
530
543
|
The 0.7.0 transition stages the verified package and runs a retained
|
|
531
544
|
coordinator from the source tree. It accepts a verified protocol-9 source and
|
|
532
545
|
moves to a protocol-10 target. The source journal, queued envelopes, tasks,
|
package/docs/quickstart.md
CHANGED
package/docs/security.md
CHANGED
|
@@ -12,7 +12,7 @@ agent-hub connects agents that can each run commands. This page says what the hu
|
|
|
12
12
|
- **Snapshots stay in your repository.** Per-turn snapshots (issue #33) are git objects in the project's own object store, written through a temporary index; nothing is referenced, pushed or copied elsewhere, and `git gc` prunes them. They hold what the work tree held, including untracked files that are not ignored, so keep secrets in ignored files. They carry the repository's own permissions, and nothing caps their disk use but `git gc`. A turn of a peer holding an open PII task is not snapshotted; a PII file left in the project is snapshotted by later turns like any other file. `ahub undo` restores only files whose current content is exactly what the turn left.
|
|
13
13
|
- **The edit hook reads, never decides.** `ahub check-path --hook` (issue #32) reads hub.db and returns context for Claude and a line for you; it sets no permission decision, so your permission rules stay in charge. It names other owners' task ids, titles and states, which then reach Claude's model; PII tasks are left out.
|
|
14
14
|
- **The session record holds identities only.** `.agenthub/state/sessions.json` (issue #37, mode 600) keeps each attached peer's recovery metadata: launch options, session and thread ids, Pi's session file path. No message or task text; loss notices name deliveries by id, sender and public task title.
|
|
15
|
-
- **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. Since 0.10 the profile starts from deny default (issue #39): commands run and read only the system, toolchain and project directories (and the project's git dir and the selected developer dir; with network on, the public CA bundles); no writes outside the project, its git dir and a temp dir of its own (`TMPDIR`, made for each command and removed when it ends; issue #63); no `.git/hooks` or `.git/config` writes; no network, loopback included, unless `local.bash_network`; with it, only through the hub's egress proxy to the hosts in `local.network_allow` (issue #65), which refuses names that resolve to internal addresses, so claude-mem and the Codex app-server stay out of reach (`"direct"` opens everything,
|
|
15
|
+
- **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. Since 0.10 the profile starts from deny default (issue #39): commands run and read only the system, toolchain and project directories (and the project's git dir and the selected developer dir; with network on, the public CA bundles); no writes outside the project, its git dir and a temp dir of its own (`TMPDIR`, made for each command and removed when it ends; issue #63); no `.git/hooks` or `.git/config` writes; no network, loopback included, unless `local.bash_network`; with it, only through the hub's egress proxy to the hosts in `local.network_allow` (issue #65), which refuses names that resolve to internal addresses (behind NAT64 with the well-known prefix, by the IPv4 address the answer carries; a network-specific prefix is not recognised), so claude-mem and the Codex app-server stay out of reach (`"direct"` opens everything, until 0.13.0). One channel stays open: `trustd`, which TLS clients need, can fetch a certificate's AIA or OCSP URL on a command's behalf, outside the proxy. The allow-default profile of 0.9 and earlier was removed in 0.12.0 (issue #83); a `local.sandbox` setting is ignored with a note. The shared temp dirs (the user's and `/private/tmp`) are closed. Without the sandbox there is no `bash` tool.
|
|
16
16
|
- **Capabilities are enforced, not suggested.** `capabilities` (issue #39) is checked by the daemon where task operations and messages arrive, so a peer cannot get round it by phrasing. A peer can never answer a permission request: the control link takes `permit` from the console role only, and a message that quotes a permit command is just text. Both bind the hub's own tool paths: a vendor agent with its own shell in the project (Codex, Claude, Kimi) can read `.agenthub/state/control-token` and connect as the console, which only the local worker's and Pi's sandbox prevents.
|
|
17
17
|
- **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.
|
|
18
18
|
- **Free text is screened too.** On an ordinary task, a done summary (with its check output: a match withholds the whole note), a review note, an unmet item or a budget handoff that matches a PII pattern (issue #69) is not saved to claude-mem, and every peer, `local` included, gets a stub naming `ahub task show <id>`; the board keeps the text, `hub.log` notes the withholding by task id only, and `ahub ask` shows such a note only when its model is reached on campus. `hub_remember` refuses a match; what a vendor agent writes to its own memory is not screened.
|
package/docs/smoke.md
CHANGED
|
@@ -964,3 +964,30 @@ was attached. Times are UTC.
|
|
|
964
964
|
check.
|
|
965
965
|
- The coordinator promoted the global CLI to 0.10.0, and `ahub setup --yes`
|
|
966
966
|
installed the 0.10.0 plugin.
|
|
967
|
+
|
|
968
|
+
## 0.10.0 to 0.11.0 attended upgrade (2026-10-01)
|
|
969
|
+
|
|
970
|
+
A scratch project (a git work tree initialized by the 0.10.0 `ahub init`) ran a
|
|
971
|
+
0.10.0 hub with `"local": { "bash_network": true }` in
|
|
972
|
+
`.agenthub/config.local.json`, two tasks (#1 with the path spelled `./a.txt`),
|
|
973
|
+
and an open budget pause: a peer had attached once and gone offline, and `ahub
|
|
974
|
+
budget set` fed it a 95% reading. No peer was attached at the upgrade. Times are
|
|
975
|
+
UTC, from `hub.log` and the operation record.
|
|
976
|
+
|
|
977
|
+
- 0.11.0 was published at 08:43:45 (the registry's `time` field); a
|
|
978
|
+
cache-busting registry read, observed at the console at 08:43:58, showed it
|
|
979
|
+
as `latest`. `bunx --package @staix/agent-hub@0.11.0 ahub
|
|
980
|
+
upgrade --to 0.11.0 --dry-run` then listed one project, source 0.10.0, protocol
|
|
981
|
+
10, no blockers.
|
|
982
|
+
- `--yes` created the operation at 08:44:19.4. The source committed at 08:44:19.5
|
|
983
|
+
and stopped; the 0.11.0 hub started its egress proxy (13 allowed hosts) at
|
|
984
|
+
08:44:19.7, found the pause still open with its reset time, and was up at
|
|
985
|
+
08:44:19.7. The operation, global install and plugin install included,
|
|
986
|
+
completed at 08:44:27.7.
|
|
987
|
+
- The tasks' refs and history and the pause row's columns, dumped with `sqlite3`
|
|
988
|
+
before and after (the latter once the 0.11.0 hub was up), were byte-identical: #1 still reads `./a.txt` (0.11.0 normalizes paths only on new
|
|
989
|
+
writes).
|
|
990
|
+
- Through the proxy, `curl` reached registry.npmjs.org (200, observed at the
|
|
991
|
+
console: the proxy logs no successes) and was refused example.com, logged as `network: refused example.com:443 (example.com:443 is
|
|
992
|
+
not in local.network_allow)`.
|
|
993
|
+
- After `ahub setup --yes`, `claude plugin list` showed the 0.11.0 plugin.
|
|
@@ -1099,7 +1099,9 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
|
|
|
1099
1099
|
the flag on, network is allowed as in 0.9 and earlier. Issue #65 builds it
|
|
1100
1100
|
(amendment below).
|
|
1101
1101
|
- `local.sandbox` ("deny-default" | "allow-default") is machine-local, since
|
|
1102
|
-
"allow-default" widens the sandbox.
|
|
1102
|
+
"allow-default" widens the sandbox. Removed in 0.12.0 (issue #83): the field is
|
|
1103
|
+
ignored with a note in `hub.log` and `ahub doctor`, and is no longer
|
|
1104
|
+
machine-local because nothing reads it.
|
|
1103
1105
|
- Capabilities: `propose`, `assign` (a proposal naming another peer as owner),
|
|
1104
1106
|
`remember`, `important`. A peer not listed in `capabilities` has all of them,
|
|
1105
1107
|
which keeps today's behaviour, and a listed peer whose value is not a list has
|
|
@@ -1125,5 +1127,5 @@ session modes (`default`, `plan`, `auto`, `yolo`) but nothing per server or tool
|
|
|
1125
1127
|
- Decisions recorded in the issue: the default list holds the npm, Yarn, PyPI,
|
|
1126
1128
|
crates.io and Go module registries and GitHub's code hosts, so a project that
|
|
1127
1129
|
already had network on keeps installing packages; `"direct"` keeps the open
|
|
1128
|
-
network of 0.10 and earlier
|
|
1130
|
+
network of 0.10 and earlier until 0.13.0 (issue #83).
|
|
1129
1131
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
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.
|
|
15730
|
+
version: "0.12.0",
|
|
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",
|
|
@@ -17,7 +17,7 @@ export interface LocalOptions {
|
|
|
17
17
|
route?: string;
|
|
18
18
|
/** Model id sent straight to OmniRoute when the sidecar is absent, unhealthy or fails a call. */
|
|
19
19
|
fixedModel: string;
|
|
20
|
-
tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: SandboxNetwork; readAllow?: string[]
|
|
20
|
+
tools: { deny: string[]; permit: ToolContext["permit"]; bashNetwork?: SandboxNetwork; readAllow?: string[] };
|
|
21
21
|
capture?: Capture;
|
|
22
22
|
/** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
|
|
23
23
|
taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
|
|
@@ -67,7 +67,7 @@ export class LocalPeer extends BasePeer {
|
|
|
67
67
|
private readonly opts: LocalOptions,
|
|
68
68
|
) {
|
|
69
69
|
super(id, opts.watchdogMs);
|
|
70
|
-
this.sandboxProfile = profile(opts.cwd, opts.tools.bashNetwork ?? false, opts.tools.readAllow, opts.tools.deny
|
|
70
|
+
this.sandboxProfile = profile(opts.cwd, opts.tools.bashNetwork ?? false, opts.tools.readAllow, opts.tools.deny);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
recoveryMetadata(): Record<string, unknown> {
|
package/src/cli/main.ts
CHANGED
|
@@ -892,6 +892,7 @@ const commands: Record<string, () => Promise<void> | void> = {
|
|
|
892
892
|
|
|
893
893
|
const config = loadConfig(cwd);
|
|
894
894
|
row(!config.ignored, "config", config.ignored ? `${config.ignored.join("; ")} (move them to .agenthub/config.local.json)` : "no machine-local field ignored");
|
|
895
|
+
for (const line of config.retired ?? []) row(false, "retired setting", line); // issue #83
|
|
895
896
|
const omni = new OmniRoute(config.omniroute);
|
|
896
897
|
const gateway = await omni.base();
|
|
897
898
|
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");
|
package/src/hub/config-trust.ts
CHANGED
package/src/hub/daemon.ts
CHANGED
|
@@ -63,9 +63,8 @@ export interface HubConfig {
|
|
|
63
63
|
omniroute: OmniRouteConfig;
|
|
64
64
|
pi: { enabled: boolean; auto_start: boolean; cmd: string[]; backend: "auto" | "dgx" | "mlx"; dgx_coding: string; dgx_fast: string; max_steps: number };
|
|
65
65
|
mlx: Pick<MlxOptions, "provider" | "host" | "runtimeDir" | "modelPath" | "port" | "model" | "sourceModel" | "contextWindow" | "maxInputTokens" | "maxTokens" | "maxConcurrency">;
|
|
66
|
-
/** `
|
|
67
|
-
|
|
68
|
-
local: { deny: string[]; bash_network: boolean | "direct"; network_allow: string[]; max_steps: number; read_allow: string[]; sandbox: "deny-default" | "allow-default" };
|
|
66
|
+
/** `bash_network`: true is network through the egress proxy to `network_allow` (#65); "direct" is everything, until 0.13.0 (#83). */
|
|
67
|
+
local: { deny: string[]; bash_network: boolean | "direct"; network_allow: string[]; max_steps: number; read_allow: string[] };
|
|
69
68
|
/** Pending permission requests: how long they wait, and whether the desktop is told (issue #5). */
|
|
70
69
|
approvals: { timeout_s: number; notify: boolean };
|
|
71
70
|
/** An owner offline this long loses its open tasks back to routing; 0 turns it off (issue #6). */
|
|
@@ -84,6 +83,8 @@ export interface HubConfig {
|
|
|
84
83
|
capabilities: Record<string, string[]>;
|
|
85
84
|
/** Machine-local fields a config file set but git could not vouch for, and why (issue #17). */
|
|
86
85
|
ignored?: string[];
|
|
86
|
+
/** Settings that were removed or are about to be (issue #83): what each does now, for `hub.log` and `ahub doctor`. */
|
|
87
|
+
retired?: string[];
|
|
87
88
|
}
|
|
88
89
|
export const DEFAULT_CONFIG: HubConfig = {
|
|
89
90
|
watchdog_ms: DEFAULT_WATCHDOG_MS,
|
|
@@ -99,7 +100,7 @@ export const DEFAULT_CONFIG: HubConfig = {
|
|
|
99
100
|
omniroute: DEFAULT_OMNIROUTE,
|
|
100
101
|
pi: { enabled: false, auto_start: false, cmd: ["pi"], backend: "auto", dgx_coding: "coding", dgx_fast: "fast", max_steps: 30 },
|
|
101
102
|
mlx: { provider: "ollama", model: "agenthub-fast-mlx:4b-8k", sourceModel: "qwen3.5:4b-mlx", contextWindow: 8192, maxInputTokens: 6000, maxTokens: 2048, maxConcurrency: 1 },
|
|
102
|
-
local: { deny: [], bash_network: false, network_allow: DEFAULT_NETWORK_ALLOW, max_steps: 30, read_allow: []
|
|
103
|
+
local: { deny: [], bash_network: false, network_allow: DEFAULT_NETWORK_ALLOW, max_steps: 30, read_allow: [] },
|
|
103
104
|
// Off here, so tests and a hub without a config file stay silent; a project's config defaults it on for macOS.
|
|
104
105
|
approvals: { timeout_s: 120, notify: false },
|
|
105
106
|
tasks: { release_after_min: 30 },
|
|
@@ -139,6 +140,16 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
139
140
|
if (!files.length) return DEFAULT_CONFIG;
|
|
140
141
|
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] }])) }));
|
|
141
142
|
delete file.ignored; // the hub's own record, never a file's
|
|
143
|
+
delete file.retired;
|
|
144
|
+
// Escape hatches with a stated end (issue #83). A removed one is ignored, never an error: an old config.local.json
|
|
145
|
+
// must not stop a hub from starting.
|
|
146
|
+
const retired: string[] = [];
|
|
147
|
+
if (file.local?.sandbox !== undefined) {
|
|
148
|
+
if (file.local.sandbox === "allow-default") retired.push('local.sandbox "allow-default" was removed in 0.12.0: the deny-default sandbox applies (local.read_allow adds paths)');
|
|
149
|
+
file.local = { ...file.local };
|
|
150
|
+
delete file.local.sandbox;
|
|
151
|
+
}
|
|
152
|
+
if (file.local?.bash_network === "direct") retired.push('local.bash_network "direct" (the open network) goes in 0.13.0: set it to true and list the hosts in local.network_allow');
|
|
142
153
|
if (file.mlx != null && (typeof file.mlx !== "object" || Array.isArray(file.mlx))) throw new Error("mlx configuration must be an object");
|
|
143
154
|
if (file.mlx?.provider !== undefined && !["ollama", "legacy"].includes(file.mlx.provider)) throw new Error("mlx.provider must be ollama or legacy");
|
|
144
155
|
if (file.mlx?.provider === undefined && (file.mlx?.modelPath || file.mlx?.runtimeDir || file.mlx?.bin || file.mlx?.port)) {
|
|
@@ -170,6 +181,7 @@ export function loadConfig(cwd: string): HubConfig {
|
|
|
170
181
|
capabilities: { ...file.capabilities },
|
|
171
182
|
mlx,
|
|
172
183
|
...(ignored.length ? { ignored } : {}),
|
|
184
|
+
...(retired.length ? { retired } : {}),
|
|
173
185
|
};
|
|
174
186
|
}
|
|
175
187
|
|
|
@@ -338,7 +350,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
338
350
|
})) as unknown as LimitsConfig;
|
|
339
351
|
const limiter = new Limiter(limits);
|
|
340
352
|
// The local worker's and Pi's commands reach the network only through this proxy (issue #65); "direct" keeps the
|
|
341
|
-
// open network of 0.10 and earlier
|
|
353
|
+
// open network of 0.10 and earlier until 0.13.0 (issue #83), anything else means none.
|
|
342
354
|
let egress: EgressProxy | undefined;
|
|
343
355
|
if (config.local.bash_network === true) {
|
|
344
356
|
const allow = Array.isArray(config.local.network_allow) ? config.local.network_allow.filter((h): h is string => typeof h === "string") : DEFAULT_NETWORK_ALLOW;
|
|
@@ -435,6 +447,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
435
447
|
if (strayChecks.length) log(`checks ignored for ${strayChecks.join(", ")}: not a task class with a command (classes: ${CLASSES.join(", ")})`);
|
|
436
448
|
// Commands and the other machine-local fields came only from a file git vouched for (loadConfig, issue #17).
|
|
437
449
|
for (const line of config.ignored ?? []) log(`${line}; only a config file nobody committed may set it`);
|
|
450
|
+
for (const line of config.retired ?? []) log(`config: ${line}`);
|
|
438
451
|
const checksAllowed = Object.keys(checkCommands).length > 0;
|
|
439
452
|
const checkTimeoutS = typeof config.checks.timeout_s === "number" && config.checks.timeout_s >= 1 ? Math.min(config.checks.timeout_s, 3600) : 600;
|
|
440
453
|
const runningChecks = new Set<() => void>();
|
|
@@ -1216,7 +1229,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1216
1229
|
let piReply: Envelope | undefined;
|
|
1217
1230
|
const ctx: ToolContext = {
|
|
1218
1231
|
cwd: opts.cwd, deny: config.local.deny,
|
|
1219
|
-
sandboxProfile: profile(opts.cwd, sandboxNetwork, config.local.read_allow, config.local.deny
|
|
1232
|
+
sandboxProfile: profile(opts.cwd, sandboxNetwork, config.local.read_allow, config.local.deny),
|
|
1220
1233
|
sandboxEnv: proxyEnv(sandboxNetwork),
|
|
1221
1234
|
permit: (title) => onPermission({ peer: "pi", title, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }, { optionId: "deny", name: "Deny", kind: "reject_once" }] }).then((picked) => picked === "allow" && pi.acceptingTools && bus.peers.get("pi") === pi),
|
|
1222
1235
|
send: (text, to) => {
|
|
@@ -1308,7 +1321,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
1308
1321
|
omni,
|
|
1309
1322
|
...(sidecar && route ? { sidecar, route } : {}),
|
|
1310
1323
|
fixedModel: args.model ?? routing.local.fixed_model,
|
|
1311
|
-
tools: { deny: config.local.deny, bashNetwork: sandboxNetwork, readAllow: config.local.read_allow,
|
|
1324
|
+
tools: { deny: config.local.deny, bashNetwork: sandboxNetwork, readAllow: config.local.read_allow, permit },
|
|
1312
1325
|
...(capture ? { capture } : {}),
|
|
1313
1326
|
taskTool: (name, a, turn) => taskOp("local", name, a, true, turn.pii),
|
|
1314
1327
|
turnPolicy: (envs) => tasks.turnPolicy(envs),
|
package/src/local/proxy.ts
CHANGED
|
@@ -47,11 +47,29 @@ export function allowed(list: string[], host: string, port: number): boolean {
|
|
|
47
47
|
const INTERNAL = new BlockList();
|
|
48
48
|
// IPv4 nets as leading octets: the package check refuses private address literals in published files.
|
|
49
49
|
for (const [a, b, bits] of [[0, 0, 8], [10, 0, 8], [100, 64, 10], [127, 0, 8], [169, 254, 16], [172, 16, 12], [192, 168, 16], [198, 18, 15], [224, 0, 3]] as const) INTERNAL.addSubnet(`${a}.${b}.0.0`, bits, "ipv4");
|
|
50
|
-
|
|
50
|
+
// RFC 8215's local-use NAT64 prefix puts the IPv4 address where the operator's prefix length says: refused outright.
|
|
51
|
+
for (const [net, bits] of [["::", 96], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8], ["64:ff9b:1::", 48]] as const) INTERNAL.addSubnet(net, bits, "ipv6");
|
|
52
|
+
// The well-known NAT64 prefix (issue #82): with DNS64, a name whose only record is an internal IPv4 address comes back
|
|
53
|
+
// as 64:ff9b::<that address>, and the NAT64 gateway carries the connection there. Its IPv4 address is what counts.
|
|
54
|
+
const NAT64 = new BlockList();
|
|
55
|
+
NAT64.addSubnet("64:ff9b::", 96, "ipv6");
|
|
56
|
+
|
|
57
|
+
/** The IPv4 address in the last 32 bits of an IPv6 address, whatever its spelling. */
|
|
58
|
+
function lastIPv4(v6: string): string {
|
|
59
|
+
const [head = "", tail] = new URL(`http://[${v6}]`).hostname.slice(1, -1).split("::");
|
|
60
|
+
const h = head ? head.split(":") : [];
|
|
61
|
+
const t = tail ? tail.split(":") : [];
|
|
62
|
+
const all = tail === undefined ? h : [...h, ...Array<string>(8 - h.length - t.length).fill("0"), ...t];
|
|
63
|
+
const [a = 0, b = 0] = all.slice(6).map((g) => parseInt(g, 16));
|
|
64
|
+
return `${a >> 8}.${a & 255}.${b >> 8}.${b & 255}`;
|
|
65
|
+
}
|
|
51
66
|
|
|
52
67
|
/** Loopback, private, link-local, site-local, unspecified, carrier-grade NAT, benchmark, multicast or reserved. */
|
|
53
68
|
export function isInternal(address: string): boolean {
|
|
69
|
+
// Only a non-global address carries a zone (`fe80::1%en0`, `::1%lo0`), and BlockList cannot parse one: internal.
|
|
70
|
+
if (address.includes("%")) return true;
|
|
54
71
|
const family = isIP(address);
|
|
72
|
+
if (family === 6 && NAT64.check(address, "ipv6")) return isInternal(lastIPv4(address));
|
|
55
73
|
return family !== 0 && INTERNAL.check(address, family === 6 ? "ipv6" : "ipv4");
|
|
56
74
|
}
|
|
57
75
|
|
|
@@ -75,7 +93,8 @@ export function startEgressProxy(opts: { allow: string[]; log: (line: string) =>
|
|
|
75
93
|
head = Buffer.concat([head, chunk]);
|
|
76
94
|
const end = head.indexOf("\r\n\r\n");
|
|
77
95
|
if (end < 0) {
|
|
78
|
-
|
|
96
|
+
// Once: later chunks of the same request must not refuse (and log) again. Nothing of the header is logged.
|
|
97
|
+
if (head.length > 8192) client.off("data", onData), refuse(client, 431, "request header too large", "a request");
|
|
79
98
|
return;
|
|
80
99
|
}
|
|
81
100
|
client.off("data", onData);
|
|
@@ -114,14 +133,22 @@ export function startEgressProxy(opts: { allow: string[]; log: (line: string) =>
|
|
|
114
133
|
const upstream = connect({ host: address, port, allowHalfOpen: true });
|
|
115
134
|
sockets.add(upstream);
|
|
116
135
|
upstream.on("close", () => sockets.delete(upstream));
|
|
136
|
+
let open = false;
|
|
117
137
|
upstream.once("connect", () => {
|
|
138
|
+
open = true;
|
|
118
139
|
client.write(`${http} 200 Connection Established\r\n\r\n`);
|
|
119
140
|
if (rest.length) upstream.write(rest);
|
|
120
141
|
client.pipe(upstream);
|
|
121
142
|
upstream.pipe(client);
|
|
122
143
|
client.resume();
|
|
123
144
|
});
|
|
124
|
-
|
|
145
|
+
// Before the tunnel opens a failure is a refusal, logged with the error code only (never its text). After, the
|
|
146
|
+
// stream is the client's TLS: an HTTP answer would corrupt it, so the client is just closed.
|
|
147
|
+
upstream.on("error", (e: NodeJS.ErrnoException) => {
|
|
148
|
+
if (open || !client.writable || client.destroyed) return void client.destroy();
|
|
149
|
+
const code = /^E[A-Z]{2,20}$/.test(e.code ?? "") ? `, ${e.code}` : "";
|
|
150
|
+
refuse(client, 403, `${host}:${port} is unreachable${code}`, `${host}:${port}`);
|
|
151
|
+
});
|
|
125
152
|
client.on("close", () => upstream.destroy());
|
|
126
153
|
};
|
|
127
154
|
|
package/src/local/sandbox.ts
CHANGED
|
@@ -59,21 +59,20 @@ const NETWORK_MACH_SERVICES = ["com.apple.dnssd.service", "com.apple.trustd", "c
|
|
|
59
59
|
const PROXY_MACH_SERVICES = ["com.apple.trustd", "com.apple.trustd.agent"];
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* Network for the commands: none, direct (`local.bash_network: "direct"`, everything,
|
|
62
|
+
* Network for the commands: none, direct (`local.bash_network: "direct"`, everything, until 0.13.0), or only
|
|
63
63
|
* the hub's egress proxy on a loopback port (#65), which opens allowlisted hosts.
|
|
64
64
|
*/
|
|
65
65
|
export type SandboxNetwork = boolean | { proxyPort: number };
|
|
66
66
|
|
|
67
67
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
68
|
+
* Starts from `(deny default)` (issue #39) and allows only what commands need: running and reading the system,
|
|
69
|
+
* toolchain and project directories, writing the project and a temp dir of their own. The allow-default profile of
|
|
70
|
+
* 0.9 and earlier was removed in 0.12.0 (issue #83).
|
|
71
71
|
*/
|
|
72
|
-
export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = []
|
|
72
|
+
export function profile(cwd: string, network: SandboxNetwork, readAllow: string[] = [], deny: string[] = []): string {
|
|
73
73
|
const home = homedir();
|
|
74
74
|
const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
|
|
75
75
|
const root = realPath(cwd);
|
|
76
|
-
const tmp = realPath(tmpdir());
|
|
77
76
|
const gitDirs = externalGitDirs(root);
|
|
78
77
|
const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
|
|
79
78
|
const dev = developerDir();
|
|
@@ -81,38 +80,28 @@ export function profile(cwd: string, network: SandboxNetwork, readAllow: string[
|
|
|
81
80
|
const subpaths = (paths: string[]) => paths.map((p) => `(subpath ${q(p)})`).join(" ");
|
|
82
81
|
const globals = (names: string[]) => names.map((n) => `(global-name ${q(n)})`).join(" ");
|
|
83
82
|
const proxy = typeof network === "object" ? `(allow network-outbound (remote ip ${q(`localhost:${network.proxyPort}`)}))` : undefined;
|
|
84
|
-
const start =
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
]
|
|
101
|
-
: [
|
|
102
|
-
"(allow default)",
|
|
103
|
-
...(network === true ? [] : ["(deny network*)", ...(proxy ? [proxy] : [])]),
|
|
104
|
-
// Home is default-deny for reads: whatever a command reads can end up in the model's answer, and that answer
|
|
105
|
-
// is shared with agents that run on cloud subscriptions (~/.claude.json, app tokens, browser profiles ...).
|
|
106
|
-
`(deny file-read* (subpath ${q(home)}))`,
|
|
107
|
-
`(allow file-read-metadata (subpath ${q(home)}))`,
|
|
108
|
-
`(allow file-read* ${subpaths(readable)})`,
|
|
109
|
-
];
|
|
83
|
+
const start = [
|
|
84
|
+
"(deny default)",
|
|
85
|
+
"(allow process-fork)",
|
|
86
|
+
// Runs from the system, toolchain and project directories only; a script runs through an allowed interpreter.
|
|
87
|
+
// The user's temp dir and /private/tmp are shared with every other process: each command gets its own (#63).
|
|
88
|
+
`(allow process-exec ${subpaths([...SYSTEM_READABLE, ...readable])})`,
|
|
89
|
+
"(allow signal (target same-sandbox))",
|
|
90
|
+
"(allow process-info* (target same-sandbox))",
|
|
91
|
+
"(allow sysctl-read)",
|
|
92
|
+
`(allow mach-lookup ${globals([...MACH_SERVICES, ...(proxy ? PROXY_MACH_SERVICES : network ? NETWORK_MACH_SERVICES : [])])})`,
|
|
93
|
+
'(allow ipc-posix-shm-read-data ipc-posix-shm-read-metadata (ipc-posix-name "apple.shm.notification_center"))',
|
|
94
|
+
"(allow file-read-metadata)",
|
|
95
|
+
`(allow file-read* (literal "/") ${subpaths([...SYSTEM_READABLE, "/dev"])} ${subpaths(readable)})`,
|
|
96
|
+
'(allow file-ioctl (regex #"^/dev/"))',
|
|
97
|
+
...(proxy ? [proxy] : network ? ["(allow network*)"] : []),
|
|
98
|
+
];
|
|
110
99
|
const places = `${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")} ${denyRegexes(root, deny, false).join(" ")}`;
|
|
111
100
|
return [
|
|
112
101
|
"(version 1)",
|
|
113
102
|
...start,
|
|
114
103
|
"(deny file-write*)",
|
|
115
|
-
`(allow file-write* (subpath ${q(root)})
|
|
104
|
+
`(allow file-write* (subpath ${q(root)}) (regex #"^/dev/") ${gitDirs.map((d) => `(subpath ${q(d)})`).join(" ")})`,
|
|
116
105
|
// Inside cwd: nothing that runs later outside the sandbox, nothing that reconfigures the hub.
|
|
117
106
|
`(deny file-write* (subpath ${q(join(root, ".agenthub"))}) ${[join(root, ".git"), ...gitDirs].map((d) => `(subpath ${q(join(d, "hooks"))}) (literal ${q(join(d, "config"))})`).join(" ")})`,
|
|
118
107
|
`(deny file-read* file-write* ${creds.map((c) => `(subpath ${q(join(home, c))})`).join(" ")})`,
|
package/templates/config.json
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"cf_client_id_file": "",
|
|
24
24
|
"cf_client_secret_file": ""
|
|
25
25
|
},
|
|
26
|
-
"local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": []
|
|
26
|
+
"local": { "deny": [], "bash_network": false, "max_steps": 30, "read_allow": [] },
|
|
27
27
|
"approvals": { "timeout_s": 120 },
|
|
28
28
|
"tasks": { "release_after_min": 30 },
|
|
29
29
|
"checks": { "timeout_s": 600 },
|