chore: v2.8.16 — Oh My Pi login sharing: a pane's key is listed before it starts

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 <data dir>/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.
This commit is contained in:
2026-10-22 10:29:55 +07:00
parent 750d74a2af
commit 4eb7734ab9
8 changed files with 175 additions and 100 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
@@ -7239,7 +7239,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.15 (rebuild client to match)
UI build v2.8.16 (rebuild client to match)
</span>
</p>
</div>
+10 -6
View File
@@ -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/<model>`:
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/<model>`:
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
`<data dir>/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.
+2 -2
View File
@@ -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": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.15",
"version": "2.8.16",
"bin": {
"agenthub": "bin/agenthub.js"
},
+47 -36
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
@@ -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);
});
});
+110 -49
View File
@@ -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ĩ <vinnt@smartgift.vn>
*/
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;
/** `{ <run id>: { 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,
};
+2 -3
View File
@@ -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) {