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
+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) {