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:
+110
-49
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user