From 4eb7734ab94554ef6ad0b181799bc170b9f6e920 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 22 Oct 2026 10:29:55 +0700 Subject: [PATCH] =?UTF-8?q?chore:=20v2.8.16=20=E2=80=94=20Oh=20My=20Pi=20l?= =?UTF-8?q?ogin=20sharing:=20a=20pane's=20key=20is=20listed=20before=20it?= =?UTF-8?q?=20starts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit omp's gateway re-reads its clients file only every 10 s, and an omp that starts while its key is refused fails model discovery once and hangs for good. omp-share.js now keeps four keys queued in the clients file (hashes only; the keys in /omp-share/tokens.json, 0600): a new pane takes one already listed (AGENTHUB_OMP_KEY), a fresh one joins the queue, and a gone pane's key leaves at the next sync. --- CLAUDE.md | 4 +- .../screens.snapshot.test.tsx.snap | 2 +- docs/SERVER-MODE.md | 16 +- package-lock.json | 4 +- package.json | 2 +- server/__tests__/omp-share.test.js | 83 +++++---- server/lib/omp-share.js | 159 ++++++++++++------ server/lib/pty-run.js | 5 +- 8 files changed, 175 insertions(+), 100 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e86f54f1..02ee6f4e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Claude Code Working Guide -_Last synced: v2.8.15 (2026-10-22) — Oh My Pi login sharing (S3, omp's broker + gateway keyed by pane token); omp `--allow-home`; Markdown opens as Preview by default._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. +_Last synced: v2.8.16 (2026-10-22) — Oh My Pi login sharing hands each pane a key already listed with the gateway (omp hung on a key minted at start)._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session. ## Project mission - Maintain a reliable local-first dashboard for Claude Code session monitoring. @@ -83,7 +83,7 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - **Server mode is a MODE of this codebase, never a fork, and local mode must not notice it.** Everything server-only checks `isServerMode()` (`AGENTHUB_MODE=server`, `server/lib/auth.js`) and is a no-op otherwise; `auth.js` opens the database only on first use, so a module that merely loads it never touches the DB in local mode. Accounts are made from the host's shell (`agenthub server setup`, `agenthub users …`) or by an admin on the Users page (`server/routes/users.js`), where EVERY change needs the admin's own password again (`confirmPassword` in `auth.js`, throttled — EREAUTH/ETOOMANY), never lets an admin demote or disable themselves (ESELF), is written to `audit_log`, and refuses in local mode (ELOCALMODE: no login to check) — do not add an account route without that step-up, and never one that returns a password hash. Rows are OWNED (phase S2, `server/lib/scope.js`): `lanes.owner_id` is the creator (never from the request — `ownerFor(req)`), a session belongs to its workspace (`sessions.lane_id` or a `dashboard_runs` row), and everything else hangs off the session. A `developer` sees only their own rows, and an `analyst` reads every row but writes only their own (`memberId` scopes an analyst's non-GET requests like a developer's — `member` was renamed `developer`, `db.js` migrates it): every list/aggregate passes `req` into `sessionWhere`/`bySessionWhere`/`laneWhere`; every route naming ONE row is covered by `scopeGuard`'s `PATH_RULES` + the query/body id names in `namedIds` (404 `ENOTFOUND`) — a new id route under a new prefix needs a rule there; every WebSocket broadcast type needs a `broadcastAudience` case (an unlisted type reaches no developer); a staff-wide view joins `STAFF_ONLY_PREFIXES`. Admin/analyst reads of a developer's session are written to `audit_log` (`server/lib/audit.js`). A pane carries `AGENTHUB_PANE_TOKEN` (hash in `dashboard_runs.pane_token_hash`, never listed) so the CLI/MCP/statusline inside it act as the workspace owner — accepted only from loopback and only while the pane lives (`userFromPaneToken`). Every write in server mode passes the Origin check in `authGuard`; prefix checks go through `apiPath()` (decoded, case-insensitive), because Express routes `/api/Webhooks` and `%77ebhooks` too. Named locks are namespaced per user, and `named-lock.js` takes only a plain name (a lock name was a path). It is NOT OS isolation unless S3 is installed (next rule); without it every pane runs as the host account, so do not call that multi-tenant. A new route that writes the host's own configuration joins `ADMIN_WRITE_PREFIXES`, and its controls hide behind `usePermissions().isAdmin` (`client/src/components/AuthGate.tsx`) — a hidden button is a courtesy, the server's refusal is the rule; hooks stay loopback-only in server mode. - **With S3 installed (server mode + `/etc/agenthub/server.conf`, `s3Enabled`), a pane runs as its workspace owner's Linux user, and the dashboard reaches a member only through sudo.** `ah-`, home `/`; the FOLDER decides (`accountForPath` in `server/lib/run-as.js`), and a folder under the users root with no enabled, provisioned user is `EMEMBERGONE` — never fall back to the host there. Every tmux call on a member's socket (attach included), every git/`gh` call in a member's folder and every write is `sudo -n -u ah- ` by the absolute paths in `server.conf` (`sudoArgv`; tmux through `/opt/agenthub/libexec/tmux-member` once installed, which runs the member's RUNNING server's own binary so a tmux upgrade never mixes versions); sudoers allows exactly those, plus `/opt/agenthub/tools/bin/claude` for the opt-in plugin baseline (`applyPluginBaseline`), as `%agenthub-members` and NEVER root as a run-as target. **The only root commands are `/opt/agenthub/sbin/agenthub-provision` (create/disable/enable/delete — `delete` is an admin's step-up-confirmed Delete user (`DELETE /api/users/:id`, `auth.deleteUser`: Linux account + home FIRST, rows only after it succeeded) and removes only an `agenthub-members` account and exactly `/` as a real directory, `rm -rf --one-file-system`, never through a link; it never adopts a non-member account, and otherwise root never touches a path inside an existing home) and `systemctl start|stop|restart|enable|disable agenthub.service`;** do not add a third — a host compromise is already every member's. **sudo logs argv, so nothing secret or private crosses it:** a member pane's env (pane token, pool token) and command (prompt) go in a launch file written by `write-as` from stdin and run by `pane-launch`; file content always goes on stdin. **The host never writes, chmods or deletes inside a member's home itself — only through `write-as` as the member** (`writeFileAs`, `handoffWrite`, `mkdirAs`, `removeAs`, `scratchFile`): the member can swap any name there for a link, and the host's write would follow it. A member's file is READ only through `openSafe`/`readFileSafe*`/`readStreamSafe` (open, then `fstat`: a regular file that member owns, `O_NONBLOCK`), never a plain `fs` read. **Hooks are authenticated by the peer uid** of the loopback connection (`/proc/net/tcp*`, `server/lib/hook-auth.js`), never by anything in the body: host or root = trusted, an enabled `ah-*` = only work, sessions, runs and transcript paths inside its own home (`EFOREIGN`, foreign paths dropped BEFORE any tool mapping reads them), anyone else `EPEER`. **`hidepid=invisible` (gid `agenthub-proc` exempt) is required** — panes' argv/env would otherwise be readable by every account — and `install-system` stops without it. The unit's ambient `CAP_DAC_READ_SEARCH` is dropped for every child (`server/lib/drop-caps.js`, installed first in `server/index.js`, before anything reads `child_process`) — keep it there, and a spawn outside `child_process` (node-pty) takes `ptyArgv`. **The account comes from a path's TEXT, so a path a member can redirect is checked before it is used:** a workspace's real path (`laneRoot` in `git-diff.js`, `EFOREIGN`) and a git dir their git prints (`git-exclude.js`) must stay the same account's (`sameOwner`), a member's home is no workspace, and a READ uses `ownerForRead`, never the throwing `accountForPath`. **A workspace inside a member's home is owned by that member only, and one outside the users root (it runs as the HOST) by an admin only, by the LIVE role** (`assertOwnerFits`: create, assign, every pane start — `EOWNERFOLDER`; demoting or disabling a user ends their host panes): its panes run as the FOLDER's account but carry the OWNER's pane token, which that account can read; a pane's cwd must be that same account's (`ECWDACCOUNT`), and a pane token is accepted only from that account's uid (`peerIsPaneAccount`). A path a non-admin names in ANY request (an analyst's GET too) stays in their own folder or workspaces (`ownFolder`, `insideOwnWorkspace`, `scope.ownCwdQuery`), and the config `file` reads never serve a credential file or anything in the data dir (`isSecretConfigPath`). **A member can stall or flood what the dashboard reads, and it all runs on one thread:** every synchronous sudo into a member is time-bounded (`MEMBER_EXEC`, tmux `memberCall` with a back-off) and a member's tmux failure makes only THEIR panes unknown, never everyone's; a member's file is read with a size cap (`readFileSafe*` `max`), line by line only through `readLines` (never `readline`, which a one-line 512 MB file crashes), and a sparse one is never copied out as real blocks. **S3 fails closed, never to the host:** `serverConf` keeps the last good `server.conf` in server mode if the file stops reading, `tmux.accountOf` throws (never "the host's server") when it cannot tell a session's account under S3, and every non-GET request naming a workspace re-checks `assertOwnerFits` by the LIVE role (`scopeGuard`, `409 EOWNERFOLDER`) — a demoted admin keeps READING their host-folder workspaces but `/up`, `/hook`, a commit or a file write there no longer runs as the host. The AI Proxy gateway marks what it relays (`x-agenthub-via-gateway`) and the dashboard refuses any request so marked (`403 EGATEWAY`), since a personal profile may point back at it from our own trusted uid; a member's launch file refuses a NUL in argv/env (`EBADARG`). Operator steps: `docs/SERVER-MODE.md` § Operator runbook. - **A pool token reaches a pane only through tmux `-e` (a member's pane under S3: its launch file), and only for the account `pickAccount` chose.** Never type it into a pane, never put it in the CLI's argv, never return it from a route (`poolStatus` has no token field), and never add a route that reads one back — the pool is filled from the host's shell (`agenthub accounts add [--tool codex]`) or by an admin on the Users page (`POST`/`DELETE /api/accounts`, `/accounts` in `ADMIN_WRITE_PREFIXES`, their own password again, audited by account NAME), where the token only goes in. An empty pool must leave every spawn exactly as before. The two exceptions are a Claude login (`server/lib/claude-pool.js` kind `login`, moved in from a saved switch-user profile — the host CLI's own moves with its live login, and the host's home becomes one more placement — or a pasted `.credentials.json`) and a Codex ChatGPT login (`server/lib/codex-pool.js`), which neither CLI reads from an env var: it is written as `auth.json` into a MEMBER's own CODEX_HOME, as the member (`write-as`, 0600), never into a host pane's CODEX_HOME, never over a login AgentHub did not place, and never a second account into a home where a live pane of that member runs on one — except an admin's switch of that user (`account-pins.js` → `claude-pool.switchHome`, Claude only), which first copies every home's newer pair back (`refreshLogins`), never writes over the member's own login, and restarts no pane; `syncBack` copies the newest back. A Claude login's refresh token is single use, so the server is its one refresher (`refreshLogins`, ahead of expiry, pushing the new pair into every home it placed it in), and a pair a member's Claude refreshed first is copied back only once Anthropic confirms the same account (`fetchOwner`). A Codex API key is `-e AGENTHUB_POOL_KEY` plus a `-c` provider whose `env_key` only names it. -- **A shared Oh My Pi login is reached only through omp's gateway, by a pane token, never handed out.** `server/lib/omp-share.js` (S3 only, an admin's switch, off by default) runs `omp auth-broker serve` + `omp auth-gateway serve --clients=` as the host on loopback; both tokens stay in the host's home and the broker's reaches only the gateway's env — never a pane, a route, a log or argv. A member's omp gets the `agenthub` provider in its `models.yml` (written as them) whose `apiKey` is the env var NAME `AGENTHUB_PANE_TOKEN`, and the clients file holds only the SHA-256 of LIVE omp panes' tokens. Do not give a pane the broker or gateway token, `OMP_AUTH_BROKER_*`, or a copy of the host's `agent.db`. +- **A shared Oh My Pi login is reached only through omp's gateway, by a per-pane key, never handed out.** `server/lib/omp-share.js` (S3 only, an admin's switch, off by default) runs `omp auth-broker serve` + `omp auth-gateway serve --clients=` as the host on loopback; both tokens stay in the host's home and the broker's reaches only the gateway's env — never a pane, a route, a log or argv. A member's omp gets the `agenthub` provider in its `models.yml` (written as them) whose `apiKey` is the env var NAME `AGENTHUB_OMP_KEY`; that key is one already QUEUED in the clients file (the gateway re-reads it only every 10 s, and an omp started on a refused key hangs for good), and the clients file holds only SHA-256s — of the queue and of LIVE omp panes' keys. Do not give a pane the broker or gateway token, `OMP_AUTH_BROKER_*`, or a copy of the host's `agent.db`. - Preserve existing behavior unless explicitly asked to change it. - Prefer minimal, reversible diffs. - Never silently weaken safety controls around destructive actions. diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index 6f541a10..f2ca458b 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = ` - UI build v2.8.15 (rebuild client to match) + UI build v2.8.16 (rebuild client to match)

diff --git a/docs/SERVER-MODE.md b/docs/SERVER-MODE.md index 4743a1ed..337160bf 100644 --- a/docs/SERVER-MODE.md +++ b/docs/SERVER-MODE.md @@ -553,14 +553,18 @@ omp login # e.g. anthropic - A member's omp pane gets one provider `agenthub` in its `~/.omp/agent/models.yml` (written as the member; the rest of the file is kept, a file that is not a YAML mapping is left alone), pointing at the - gateway with `apiKey: AGENTHUB_PANE_TOKEN` — the NAME of the env var holding - its pane token — and `OMP_SKIP_SETUP=1`. It runs on `--model agenthub/`: + gateway with `apiKey: AGENTHUB_OMP_KEY` — the NAME of the env var holding + the pane's key — and `OMP_SKIP_SETUP=1`. It runs on `--model agenthub/`: the model picked in AgentHub, else the host's own `modelRoles.default` (`~/.omp/agent/config.yml`) — left to itself omp would take the provider's - first listed model, a retired one the API answers 404 for. The clients file lists the SHA-256 - of each LIVE omp pane's token (rewritten every 15 s; the gateway re-reads it - every 10 s), so a token stops working shortly after its pane ends, and - turning sharing off empties it at once. + first listed model, a retired one the API answers 404 for. +- The gateway re-reads its clients file only every 10 s, and an omp that + starts while its key is still refused hangs for good. So AgentHub keeps four + keys QUEUED in the clients file (hashes only; the keys themselves in + `/omp-share/tokens.json`, 0600): a new pane takes one that is + already listed, and a fresh key joins the queue. A pane's key leaves the + file at the first sync (every 15 s) after its pane is gone, and turning + sharing off empties both files at once. - A scoped client token calls models only: it cannot read a credential, the gateway's usage or credential health, and refresh tokens never leave the broker. Host panes use the host's login directly and get nothing new. diff --git a/package-lock.json b/package-lock.json index ee691bea..572cb314 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agenthub", - "version": "2.8.15", + "version": "2.8.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agenthub", - "version": "2.8.15", + "version": "2.8.16", "hasInstallScript": true, "license": "UNLICENSED", "dependencies": { diff --git a/package.json b/package.json index 362c4479..ca0b44b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agenthub", - "version": "2.8.15", + "version": "2.8.16", "bin": { "agenthub": "bin/agenthub.js" }, diff --git a/server/__tests__/omp-share.test.js b/server/__tests__/omp-share.test.js index 94ff5704..183d3354 100644 --- a/server/__tests__/omp-share.test.js +++ b/server/__tests__/omp-share.test.js @@ -2,9 +2,10 @@ * @file omp-share.test.js * @description Oh My Pi login sharing (server/lib/omp-share.js): a member's * `models.yml` gets one `agenthub` provider pointing at the loopback gateway - * with the pane token's env var NAME as its key (the rest of the file kept, a - * non-mapping refused); the gateway's clients file holds only LIVE omp panes' - * token hashes; a spawn is untouched while sharing is off, for a host pane, or + * with the key's env var NAME as its key (the rest of the file kept, a + * non-mapping refused); a pane takes a key the clients file listed BEFORE it + * started (the gateway re-reads it only every 10 s), and a gone pane's key + * leaves it; a spawn is untouched while sharing is off, for a host pane, or * without S3; and the route refuses outside S3. No omp daemon is started. * @author Nguyễn Ngọc Trí Vĩ */ @@ -23,13 +24,15 @@ process.env.CLAUDE_HOME = path.join(TMP, ".claude"); process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data"); process.env.AGENTHUB_OMP_GATEWAY_PORT = "4999"; +const crypto = require("node:crypto"); const yaml = require("js-yaml"); + +const sha = (t) => crypto.createHash("sha256").update(t).digest("hex"); const { createApp, startServer } = require("../index"); const share = require("../lib/omp-share"); const linux = require("../lib/linux-accounts"); const runAs = require("../lib/run-as"); const tmux = require("../lib/tmux"); -const { db } = require("../db"); const realS3 = linux.s3Enabled; const realLive = tmux.liveSessions; @@ -57,7 +60,7 @@ describe("mergedModels", () => { providers: { agenthub: { baseUrl: "http://127.0.0.1:4999", - apiKey: "AGENTHUB_PANE_TOKEN", + apiKey: "AGENTHUB_OMP_KEY", api: "anthropic-messages", authHeader: true, discovery: { type: "proxy" }, @@ -72,7 +75,7 @@ describe("mergedModels", () => { const doc = yaml.load(share.mergedModels(text)); assert.equal(doc.providers.mine.baseUrl, "http://h/v1"); assert.deepEqual(doc.equivalence, { x: "y" }); - assert.equal(doc.providers.agenthub.apiKey, "AGENTHUB_PANE_TOKEN"); + assert.equal(doc.providers.agenthub.apiKey, "AGENTHUB_OMP_KEY"); }); it("writes nothing when the provider is already there as is", () => { @@ -113,17 +116,18 @@ describe("forSpawn", () => { const member = { login: "ah-mia", home: path.join(TMP, "home-mia") }; it("is null while sharing is off, for a host pane, or without S3", () => { - assert.equal(share.forSpawn(member, "agenthub-lane-1", "h"), null); // off + assert.equal(share.forSpawn(member, "agenthub-lane-1"), null); // off fs.mkdirSync(path.dirname(flag), { recursive: true }); fs.writeFileSync(flag, ""); - assert.equal(share.forSpawn(member, "agenthub-lane-1", "h"), null); // no S3 + assert.equal(share.forSpawn(member, "agenthub-lane-1"), null); // no S3 linux.s3Enabled = () => true; - assert.equal(share.forSpawn(null, "agenthub-lane-1", "h"), null); // the host's - assert.equal(share.forSpawn(member, "agenthub-lane-1", null), null); // no token + assert.equal(share.forSpawn(null, "agenthub-lane-1"), null); // the host's }); - it("writes the member's models.yml AS them and lists the pane's token", () => { + it("hands out a key the gateway already listed, and writes models.yml AS the member", () => { turnOn(); + share.syncClients(); // the queue, listed before any pane asks + const listedBefore = JSON.parse(fs.readFileSync(clientsFile, "utf8")); const writes = []; const real = { read: runAs.readFileSafe, mkdir: runAs.mkdirAs, write: runAs.writeAs }; runAs.readFileSafe = () => { @@ -135,17 +139,27 @@ describe("forSpawn", () => { runAs.writeAs = (acct, file, mode, content) => writes.push([mode, acct.login, file, content]); tmux.liveSessions = () => new Set(); try { - const s = share.forSpawn(member, "agenthub-lane-7", "a".repeat(64)); - assert.deepEqual(s.env, { OMP_SKIP_SETUP: "1" }); + const s = share.forSpawn(member, "agenthub-lane-7"); + const key = s.env.AGENTHUB_OMP_KEY; + assert.match(key, /^ahomp_/); + assert.equal(s.env.OMP_SKIP_SETUP, "1"); + const hash = sha(key); + assert.ok( + Object.values(listedBefore).some((c) => c.tokenSha256 === hash), + "the key was listed before the pane started" + ); + const after = JSON.parse(fs.readFileSync(clientsFile, "utf8")); + assert.deepEqual(after["agenthub-lane-7"], { tokenSha256: hash }); + // its place in the queue is filled again, and no plaintext reaches the clients file + assert.equal(Object.keys(after).filter((k) => k.startsWith("standby-")).length, 4); + assert.ok(!fs.readFileSync(clientsFile, "utf8").includes("ahomp_")); + assert.equal((fs.statSync(clientsFile).mode & 0o777).toString(8), "600"); s.place(); const agentDir = path.join(member.home, ".omp", "agent"); assert.deepEqual(writes[0], ["mkdir", "ah-mia", agentDir]); assert.equal(writes[1][0], "replace"); assert.equal(writes[1][2], path.join(agentDir, "models.yml")); - assert.equal(yaml.load(writes[1][3]).providers.agenthub.apiKey, "AGENTHUB_PANE_TOKEN"); - const clients = JSON.parse(fs.readFileSync(clientsFile, "utf8")); - assert.deepEqual(clients["agenthub-lane-7"], { tokenSha256: "a".repeat(64) }); - assert.equal((fs.statSync(clientsFile).mode & 0o777).toString(8), "600"); + assert.equal(yaml.load(writes[1][3]).providers.agenthub.apiKey, "AGENTHUB_OMP_KEY"); } finally { runAs.readFileSafe = real.read; runAs.mkdirAs = real.mkdir; @@ -154,29 +168,26 @@ describe("forSpawn", () => { }); }); -describe("liveClients", () => { - before(() => { - const ins = db.prepare( - `INSERT INTO dashboard_runs (id, cwd, status, tool, pane_token_hash) VALUES (?, '/x', ?, ?, ?)` - ); - ins.run("agenthub-lane-21", "running", "omp", "b".repeat(64)); - ins.run("agenthub-lane-22", "running", "omp", "c".repeat(64)); // pane gone - ins.run("agenthub-lane-23", "ended", "omp", "d".repeat(64)); - ins.run("agenthub-lane-24", "running", "claude", "e".repeat(64)); +describe("pruneTokens", () => { + const now = 1_000_000; + const tokens = () => ({ + standby: [], + used: { + "agenthub-lane-21": { hash: "b", at: now - 120_000 }, // live + "agenthub-lane-22": { hash: "c", at: now - 120_000 }, // pane gone + "agenthub-lane-23": { hash: "d", at: now - 5_000 }, // just started + }, }); - it("lists only live omp panes", () => { - tmux.liveSessions = () => new Set(["agenthub-lane-21", "agenthub-lane-23", "agenthub-lane-24"]); - const c = share.liveClients(); - assert.deepEqual(c["agenthub-lane-21"], { tokenSha256: "b".repeat(64) }); - for (const id of ["agenthub-lane-22", "agenthub-lane-23", "agenthub-lane-24"]) { - assert.equal(c[id], undefined, id); - } + it("drops only a gone pane's key past the grace, and keeps the queue full", () => { + const t = share.pruneTokens(tokens(), new Set(["agenthub-lane-21"]), now); + assert.deepEqual(Object.keys(t.used).sort(), ["agenthub-lane-21", "agenthub-lane-23"]); + assert.equal(t.standby.length, 4); }); - it("says nothing when tmux could not be asked", () => { - tmux.liveSessions = () => null; - assert.equal(share.liveClients(), null); + it("drops nothing when tmux could not be asked", () => { + const t = share.pruneTokens(tokens(), null, now); + assert.equal(Object.keys(t.used).length, 3); }); }); diff --git a/server/lib/omp-share.js b/server/lib/omp-share.js index 4cad957a..21994546 100644 --- a/server/lib/omp-share.js +++ b/server/lib/omp-share.js @@ -14,13 +14,14 @@ * `/v1/messages`, `/v1/chat/completions`, `/v1/responses`, `/v1/models` * with the broker's credentials put in upstream. Its own unscoped token * stays in the host's home too. - * A member's omp pane reaches the gateway as a SCOPED client: the clients file - * lists the SHA-256 of each live omp pane's own pane token - * (`dashboard_runs.pane_token_hash`, the same hash `auth.js` keeps), and its - * `~/.omp/agent/models.yml` gets one `agenthub` provider whose `apiKey` is the - * env var NAME `AGENTHUB_PANE_TOKEN`. A scoped client can call models but - * never read a credential or the gateway's usage, and its token stops working - * within the gateway's 10 s re-read once its pane is gone (`syncClients`) — so + * A member's omp pane reaches the gateway as a SCOPED client: it is handed one + * key (`AGENTHUB_OMP_KEY`, through tmux -e / its launch file) that the + * clients file had already listed — by SHA-256 only — before the pane started + * (the gateway re-reads that file only every 10 s; see `takeToken`), and its + * `~/.omp/agent/models.yml` gets one `agenthub` provider whose `apiKey` is + * that env var's NAME. A scoped client can call models but never read a + * credential or the gateway's usage, and its key stops working within the + * next sync + the gateway's re-read once its pane is gone (`syncClients`) — so * an agent can spend through the login while its pane lives, never carry it * away. omp's gateway serves only its built-in providers' models (a custom * `models.yml` provider of the host, such as a 9Router, is not shared). @@ -30,6 +31,7 @@ * @author Nguyễn Ngọc Trí Vĩ */ +const crypto = require("node:crypto"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); @@ -37,10 +39,8 @@ const net = require("node:net"); const { spawn } = require("node:child_process"); const PROVIDER = "agenthub"; -const TOKEN_ENV = "AGENTHUB_PANE_TOKEN"; +const TOKEN_ENV = "AGENTHUB_OMP_KEY"; const SYNC_MS = 15_000; -/** A spawn's token counts before its run row exists (recordRun runs after). */ -const PENDING_MS = 60_000; const port = (name, fallback) => { const n = parseInt(process.env[name] || "", 10); @@ -53,6 +53,7 @@ const dir = () => path.join(require("./claude-home").getDataDir(), "omp-share"); const onFlag = () => path.join(dir(), "enabled"); const clientsFile = () => path.join(dir(), "clients.json"); const pidsFile = () => path.join(dir(), "pids.json"); +const tokensFile = () => path.join(dir(), "tokens.json"); const logFile = (name) => path.join(dir(), `${name}.log`); /** The host's own omp config dir, where the broker keeps its token. */ const ompDir = () => process.env.AGENTHUB_OMP_DIR || path.join(os.homedir(), ".omp"); @@ -67,52 +68,110 @@ function ensureDir() { fs.mkdirSync(dir(), { recursive: true, mode: 0o700 }); } -// ── the clients file ────────────────────────────────────────────────────── +// ── tokens and the clients file ─────────────────────────────────────────── +// +// The gateway re-reads its clients file only every 10 s, and an omp that +// starts while its key is still refused hangs for good (its model discovery +// fails once and is never retried). So a pane never gets a key minted for +// it: it takes one of STANDBY keys that have been listed for a while, and a +// fresh one takes that key's place in the queue. `tokens.json` (0600, the +// host's data dir) keeps the queue and which run holds which key, so both +// survive a restart; a key whose pane is gone leaves the file at the next sync. -const pending = new Map(); // run id → { hash, at } -let lastClients = null; +const STANDBY = 4; +/** A run's key stays listed this long before its pane must show up in tmux. */ +const GRACE_MS = 60_000; -/** `{ : { tokenSha256 } }` for every live omp pane, or null when tmux could not say. */ -function liveClients() { - const live = require("./tmux").liveSessions(); - if (!live) return null; - const rows = require("../db") - .db.prepare( - `SELECT id, pane_token_hash FROM dashboard_runs - WHERE tool = 'omp' AND status IN ('running', 'spawning') AND pane_token_hash IS NOT NULL` - ) - .all(); +const sha = (t) => crypto.createHash("sha256").update(t).digest("hex"); +const newToken = () => `ahomp_${crypto.randomBytes(24).toString("base64url")}`; + +function readTokens() { + try { + const j = JSON.parse(fs.readFileSync(tokensFile(), "utf8")); + return { + standby: Array.isArray(j.standby) ? j.standby.filter((t) => typeof t === "string") : [], + used: j.used && typeof j.used === "object" ? j.used : {}, + }; + } catch { + return { standby: [], used: {} }; + } +} + +function writeAtomic(file, text) { + ensureDir(); + const tmp = `${file}.${process.pid}.tmp`; + fs.writeFileSync(tmp, text, { mode: 0o600 }); + fs.renameSync(tmp, file); +} + +function refill(t) { + while (t.standby.length < STANDBY) t.standby.push(newToken()); +} + +/** The gateway's view: every queued key and every key a run holds — hashes only. */ +function clientsFor(t) { const out = {}; - for (const r of rows) if (live.has(r.id)) out[r.id] = { tokenSha256: r.pane_token_hash }; - const now = Date.now(); - for (const [id, p] of pending) { - if (now - p.at > PENDING_MS) pending.delete(id); - else out[id] = { tokenSha256: p.hash }; + for (const s of t.standby) out[`standby-${sha(s).slice(0, 16)}`] = { tokenSha256: sha(s) }; + for (const [id, u] of Object.entries(t.used)) { + if (u && typeof u.hash === "string") out[id] = { tokenSha256: u.hash }; } return out; } -function writeClients(clients) { - const text = `${JSON.stringify(clients, null, 2)}\n`; - if (text === lastClients) return; - ensureDir(); - const tmp = `${clientsFile()}.${process.pid}.tmp`; - fs.writeFileSync(tmp, text, { mode: 0o600 }); - fs.renameSync(tmp, clientsFile()); +let lastClients = null; + +function save(t) { + writeAtomic(tokensFile(), `${JSON.stringify(t)}\n`); + const text = `${JSON.stringify(clientsFor(t), null, 2)}\n`; + if (text === lastClients && fs.existsSync(clientsFile())) return; + writeAtomic(clientsFile(), text); lastClients = text; } -/** Rewrite the clients file from the live omp panes (a gone pane's token drops out). */ +/** A key for run `runId`, already listed with the gateway; its place in the queue is refilled. */ +function takeToken(runId) { + const t = readTokens(); + refill(t); + const token = t.standby.shift(); + t.used[runId] = { hash: sha(token), at: Date.now() }; + refill(t); + save(t); + return token; +} + +/** + * Drop the keys of runs whose pane is gone (after GRACE_MS), keep the queue + * full, and rewrite both files. `live`: the tmux session names, or null when + * tmux could not say — then nothing is dropped. + */ +function pruneTokens(t, live, now = Date.now()) { + if (live) { + for (const [id, u] of Object.entries(t.used)) { + if (!live.has(id) && now - (Number(u?.at) || 0) > GRACE_MS) delete t.used[id]; + } + } + refill(t); + return t; +} + +/** Rewrite the clients file from the live omp panes (a gone pane's key drops out). */ function syncClients() { if (!enabled() || !available()) return; try { - const clients = liveClients(); - if (clients) writeClients(clients); + save(pruneTokens(readTokens(), require("./tmux").liveSessions())); } catch (err) { console.warn("omp share: clients sync failed:", err.message); } } +/** Sharing turned off: no key stays listed, queued or held. */ +function revokeAll() { + lastClients = null; + if (fs.existsSync(tokensFile()) || fs.existsSync(clientsFile())) { + save({ standby: [], used: {} }); + } +} + // ── a member's models.yml ───────────────────────────────────────────────── function providerEntry() { @@ -200,10 +259,12 @@ function shareModel(requested, hostDefault = hostDefaultModel()) { * null when sharing is off (the pane then runs on the member's own login). * Only a MEMBER's pane — the host's omp already holds the login itself. */ -function forSpawn(member, runId, tokenHash) { - if (!member || !tokenHash || !enabled() || !available()) return null; +function forSpawn(member, runId) { + if (!member || !enabled() || !available()) return null; + const token = takeToken(runId); return { - env: { OMP_SKIP_SETUP: "1" }, + // The key itself, never in argv: tmux -e, or a member's launch file. + env: { OMP_SKIP_SETUP: "1", [TOKEN_ENV]: token }, model: (requested) => shareModel(requested), place() { try { @@ -211,8 +272,6 @@ function forSpawn(member, runId, tokenHash) { } catch (err) { console.warn(`omp share: ${member.login}'s models.yml left as is:`, err.message); } - pending.set(runId, { hash: tokenHash, at: Date.now() }); - syncClients(); }, }; } @@ -328,8 +387,9 @@ async function startDaemons() { return; } killStale(); - // The gateway refuses to start without the clients file it names. - if (!fs.existsSync(clientsFile())) writeClients({}); + // The gateway refuses to start without the clients file it names, and + // must list the queued keys from its first read. + save(pruneTokens(readTokens(), null)); const bport = brokerPort(); const gport = gatewayPort(); for (const p of [bport, gport]) { @@ -422,9 +482,7 @@ async function setEnabled(on) { fs.rmSync(onFlag(), { force: true }); stop(); // No pane keeps a way in once sharing is off. - lastClients = null; - pending.clear(); - if (fs.existsSync(clientsFile())) writeClients({}); + revokeAll(); } return state(); } @@ -458,7 +516,10 @@ module.exports = { mergedModels, providerEntry, shareModel, - liveClients, + takeToken, + pruneTokens, + clientsFor, + readTokens, PROVIDER, TOKEN_ENV, }; diff --git a/server/lib/pty-run.js b/server/lib/pty-run.js index 50d4d85d..61d54efa 100644 --- a/server/lib/pty-run.js +++ b/server/lib/pty-run.js @@ -490,13 +490,12 @@ function spawnRun(args) { : null; const paneCred = require("./auth").newPaneToken(); // A member's omp on the host's shared omp login (omp-share.js): its - // models.yml points at the loopback gateway, keyed by this pane's token. + // models.yml points at the loopback gateway, with a key already listed there. const ompShare = tool === "omp" ? require("./omp-share").forSpawn( require("./run-as").accountForLane(laneId), - runIdForLane(laneId, slot), - paneCred?.hash + runIdForLane(laneId, slot) ) : null; if (forkSession === true) {