@staix/agent-hub 0.7.9 → 0.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@ 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.11
8
+
9
+ - A project whose path contains a backslash works with the local worker and Pi again: Bun 1.3.14's `realpathSync` throws for such paths, so the sandbox profile, the file tools' path guard and the Pi resume check now resolve paths through `realPath`, which then asks the system `realpath`; it returns each name as stored on disk, so another spelling of `.git/config` or `.env` is still refused (#26).
10
+
11
+ ## 0.7.10
12
+
13
+ - A `local.deny` entry, or a project path, containing `"` no longer breaks the local worker's sandbox profile (its bash and git tools stopped with a parse error); the deny rules are written as plain SBPL strings, which can hold a quote (#23).
14
+
7
15
  ## 0.7.9
8
16
 
9
17
  - 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).
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.9, control protocol 10. Durable delivery records distinguish queued
7
+ Status: 0.7.11, 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.9 && ahub setup
31
+ bun add -g github:STAIxBWLB/agent-hub#v0.7.11 && ahub setup
32
32
  ```
33
33
 
34
34
  The installed commands remain `ahub` and `agent-hub`.
@@ -1,6 +1,6 @@
1
1
  # Operations guide
2
2
 
3
- This guide describes ahub 0.7.9 and control protocol 10. Live verification
3
+ This guide describes ahub 0.7.11 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
@@ -226,8 +226,8 @@ source and carries every recovery fix released up to it. Protocol 8 and older
226
226
  project directory, without replacing the global CLI first:
227
227
 
228
228
  ```bash
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
229
+ bunx --package @staix/agent-hub@0.7.11 ahub upgrade --to 0.7.11 --dry-run
230
+ bunx --package @staix/agent-hub@0.7.11 ahub upgrade --to 0.7.11 --yes
231
231
  ```
232
232
 
233
233
  | Running now | Coordinator to use |
@@ -249,19 +249,19 @@ The coordinator verifies and retains the exact target package, preserves its
249
249
  own source, and promotes the global CLI only after restored projects pass
250
250
  readback.
251
251
 
252
- Once the installed CLI is 0.7.9, review the current project or all registered
252
+ Once the installed CLI is 0.7.11, review the current project or all registered
253
253
  projects first:
254
254
 
255
255
  ```bash
256
256
  ahub restart --dry-run
257
- ahub upgrade --to 0.7.9 --dry-run
257
+ ahub upgrade --to 0.7.11 --dry-run
258
258
  ```
259
259
 
260
260
  Apply only after reviewing the plan:
261
261
 
262
262
  ```bash
263
263
  ahub restart --yes
264
- ahub upgrade --to 0.7.9 --yes
264
+ ahub upgrade --to 0.7.11 --yes
265
265
  ahub recovery status <operation-id>
266
266
  ahub recovery resume <operation-id>
267
267
  ahub recovery abort <operation-id>
@@ -17,7 +17,7 @@ ahub setup # installs the Claude Code channel plu
17
17
  Or use the matching GitHub release:
18
18
 
19
19
  ```bash
20
- bun add -g github:STAIxBWLB/agent-hub#v0.7.9
20
+ bun add -g github:STAIxBWLB/agent-hub#v0.7.11
21
21
  ahub setup
22
22
  ```
23
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.7.9",
3
+ "version": "0.7.11",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.7.9",
3
+ "version": "0.7.11",
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.9",
15730
+ version: "0.7.11",
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",
@@ -1,10 +1,11 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import { join, resolve, relative } from "node:path";
4
4
  import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
5
5
  import { renderDigest, replyAudience, replyParent, type Envelope, type PeerId } from "../hub/envelope.ts";
6
6
  import { BasePeer } from "../hub/peers.ts";
7
7
  import { stopOwnedProcess } from "../hub/child-process.ts";
8
+ import { realPath } from "../hub/project.ts";
8
9
 
9
10
  export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
10
11
  export interface PiToolSchema { name: string; description?: string; parameters: Record<string, unknown>; }
@@ -139,11 +140,11 @@ export class PiPeer extends BasePeer {
139
140
  const sessions = join(this.opts.stateDir, "pi-sessions");
140
141
  mkdirSync(sessions, { recursive: true, mode: 0o700 });
141
142
  if (this.opts.sessionFile) {
142
- const file = realpathSync(this.opts.sessionFile);
143
- const rel = relative(realpathSync(sessions), file);
144
- if (!rel || rel.startsWith("..") || resolve(realpathSync(sessions), rel) !== file) throw new Error("Pi session file is outside the managed project session directory");
143
+ const file = realPath(this.opts.sessionFile);
144
+ const rel = relative(realPath(sessions), file);
145
+ if (!rel || rel.startsWith("..") || resolve(realPath(sessions), rel) !== file) throw new Error("Pi session file is outside the managed project session directory");
145
146
  const header = JSON.parse(readFileSync(file, "utf8").split("\n", 1)[0]!);
146
- if (header.type !== "session" || typeof header.cwd !== "string" || realpathSync(header.cwd) !== realpathSync(this.opts.cwd) || (this.opts.sessionId && header.id !== this.opts.sessionId)) throw new Error("Pi session header does not match the requested project/session");
147
+ if (header.type !== "session" || typeof header.cwd !== "string" || realPath(header.cwd) !== realPath(this.opts.cwd) || (this.opts.sessionId && header.id !== this.opts.sessionId)) throw new Error("Pi session header does not match the requested project/session");
147
148
  }
148
149
  const token = randomUUID();
149
150
  this.tuiExit = new Promise((resolvePromise) => { this.resolveTuiExit = resolvePromise; });
package/src/cli/setup.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { readFileSync, realpathSync } from "node:fs";
1
+ import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { realPath } from "../hub/project.ts";
3
4
  import { VERSION } from "../version.ts";
4
5
 
5
6
  export const PLUGIN = "agent-hub@agent-hub";
@@ -27,7 +28,7 @@ export function parseList<T>(json: string): T[] {
27
28
 
28
29
  const real = (p: string) => {
29
30
  try {
30
- return realpathSync(p);
31
+ return realPath(p);
31
32
  } catch {
32
33
  return p;
33
34
  }
@@ -17,6 +17,26 @@ export function canonicalPath(path: string): string {
17
17
  return canonical(path);
18
18
  }
19
19
 
20
+ /**
21
+ * realpathSync, except that Bun 1.3.14 throws ENOENT for an existing path containing a backslash (issue #26). Such a
22
+ * path goes to the system realpath, which handles it and returns every component as stored on disk: guardPath checks
23
+ * names, and on a case-insensitive disk `.GIT/config` or `id_rſa` would otherwise pass for another file. A missing
24
+ * path or a dangling symlink still throws, which guardPath relies on. Other paths take realpathSync's answer unchanged.
25
+ */
26
+ export function realPath(path: string): string {
27
+ try {
28
+ return realpathSync(path);
29
+ } catch (error) {
30
+ if (!resolve(path).includes("\\")) throw error; // a relative path can sit under a backslash directory too
31
+ // The path as given: resolve() would fold a `..` after a symlink by text. GNU realpath accepts a dangling last
32
+ // component unless told -e; the BSD one on macOS refuses it by default.
33
+ const argv = process.platform === "darwin" ? ["/bin/realpath", "--", path] : ["realpath", "-e", "--", path];
34
+ const r = spawnSync(argv[0]!, argv.slice(1), { encoding: "utf8" });
35
+ if (r.status !== 0 || !r.stdout.endsWith("\n")) throw error;
36
+ return r.stdout.slice(0, -1);
37
+ }
38
+ }
39
+
20
40
  function gitRoot(dir: string): string | undefined {
21
41
  const result = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
22
42
  if (result.status !== 0) return undefined;
package/src/local/deny.ts CHANGED
@@ -17,7 +17,14 @@ export function isDenied(path: string, extra: string[] = []): boolean {
17
17
  return DENY_SEGMENTS.some((s) => hasSegment(norm, s)) || DENY_NAMES.some((re) => new RegExp(re).test(basename(norm))) || extra.some((e) => e && norm.includes(e));
18
18
  }
19
19
 
20
- const sbplEscape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/"/g, '\\"');
20
+ /** Regex metacharacters only; `sbplString` quotes the finished regex. */
21
+ const sbplEscape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22
+
23
+ /**
24
+ * An SBPL string literal. Only the plain `"..."` form can hold a quote: in the raw `#"..."` form a backslash escapes
25
+ * nothing, so a `"` in a path ended the literal and broke the whole profile (issue #23).
26
+ */
27
+ export const sbplString = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
21
28
 
22
29
  /**
23
30
  * Seatbelt regex filters equivalent to isDenied. Seatbelt sees absolute paths, so the `local.deny` substrings are
@@ -32,5 +39,5 @@ export function denyRegexes(root: string, extra: string[] = []): string[] {
32
39
  ...DENY_SEGMENTS.map((s) => `/${sbplEscape(s)}(/|$)`),
33
40
  ...names,
34
41
  ...extra.filter(Boolean).map((e) => `^${sbplEscape(root)}/.*${sbplEscape(e)}`),
35
- ].map((re) => `(regex #"${re.replace(/"/g, '\\"')}")`);
42
+ ].map((re) => `(regex ${sbplString(re)})`);
36
43
  }
@@ -1,15 +1,16 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { existsSync, realpathSync } from "node:fs";
2
+ import { existsSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
- import { denyRegexes } from "./deny.ts";
5
+ import { realPath } from "../hub/project.ts";
6
+ import { denyRegexes, sbplString } from "./deny.ts";
6
7
 
7
8
  const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
8
9
  export const OUTPUT_CAP = 20_000;
9
10
 
10
11
  export const sandboxAvailable = () => process.platform === "darwin" && existsSync(SANDBOX_EXEC);
11
12
 
12
- const q = (s: string) => `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
13
+ const q = sbplString;
13
14
 
14
15
  /**
15
16
  * Seatbelt profile for everything the local worker executes (bash, git). Path checks cannot scope a shell,
@@ -30,8 +31,8 @@ function externalGitDirs(root: string): string[] {
30
31
  export function profile(cwd: string, network: boolean, readAllow: string[] = [], deny: string[] = []): string {
31
32
  const home = homedir();
32
33
  const inHome = (p: string) => (p.startsWith("~/") ? join(home, p.slice(2)) : p);
33
- const root = realpathSync(cwd);
34
- const tmp = realpathSync(tmpdir());
34
+ const root = realPath(cwd);
35
+ const tmp = realPath(tmpdir());
35
36
  const gitDirs = externalGitDirs(root);
36
37
  const creds = [".ssh", ".aws", ".gnupg", ".config/gh", ".config/gcloud", ".kube", ".docker", ".netrc", ".npmrc", ".omniroute", ".claude", ".codex", ".kimi-code", "Library/Keychains"];
37
38
  return [
@@ -1,5 +1,6 @@
1
- import { lstatSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
1
+ import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
+ import { realPath } from "../hub/project.ts";
3
4
  import { hasSegment, isDenied } from "./deny.ts";
4
5
  import { OUTPUT_CAP, sandboxedExec } from "./sandbox.ts";
5
6
 
@@ -31,7 +32,7 @@ const lexists = (p: string) => {
31
32
 
32
33
  /** Absolute path inside cwd after resolving symlinks, or an Error. `write` also refuses .git and .agenthub. */
33
34
  export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string, mode: "read" | "write"): string {
34
- const root = realpathSync(ctx.cwd);
35
+ const root = realPath(ctx.cwd);
35
36
  const abs = resolve(root, path);
36
37
  // Resolve symlinks on the deepest part that exists: a link pointing outside must not pass as an inside path.
37
38
  // lstat, not exists: a dangling symlink "does not exist" to existsSync, yet a write through it lands at its target.
@@ -39,7 +40,7 @@ export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string,
39
40
  while (!lexists(existing)) existing = dirname(existing);
40
41
  let realExisting: string;
41
42
  try {
42
- realExisting = realpathSync(existing);
43
+ realExisting = realPath(existing);
43
44
  } catch {
44
45
  throw new Error(`${path} goes through a dangling symlink`);
45
46
  }