@mindbridgeio/muse 0.1.0
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/README.md +35 -0
- package/bin/mindbridge-muse.mjs +212 -0
- package/dist/agent-hooks.mjs +233 -0
- package/dist/agent-install.mjs +62 -0
- package/dist/agent-oauth.mjs +984 -0
- package/dist/oauth-hook.mjs +9 -0
- package/dist/project-metadata.mjs +159 -0
- package/hooks/hooks.json +52 -0
- package/package.json +28 -0
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { cp, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_SERVER_URL = "https://memory.ifelse.io";
|
|
10
|
+
|
|
11
|
+
const KEYRING_SERVICE = "mindbridge-agent-oauth";
|
|
12
|
+
const ACCESS_TOKEN_SKEW_MS = 60_000;
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
14
|
+
const AUTH_TIMEOUT_MS = 5 * 60_000;
|
|
15
|
+
const LOCK_TIMEOUT_MS = 35_000;
|
|
16
|
+
const LOCK_STALE_MARGIN_MS = 15_000;
|
|
17
|
+
// Keep a valid browser flow leased until it can finish, plus a small margin.
|
|
18
|
+
const LOCK_STALE_MS = AUTH_TIMEOUT_MS + LOCK_STALE_MARGIN_MS;
|
|
19
|
+
const LOCK_RETRY_MS = 50;
|
|
20
|
+
const OUTBOX_MAX_BYTES = 10 * 1024 * 1024;
|
|
21
|
+
const OUTBOX_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
22
|
+
const nativeRequire = createRequire(import.meta.url);
|
|
23
|
+
|
|
24
|
+
function text(value) {
|
|
25
|
+
return typeof value === "string" ? value.trim() : "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function oauthError(reason, status) {
|
|
29
|
+
const error = new Error(`MindBridge OAuth ${reason.replaceAll("_", " ")}`);
|
|
30
|
+
error.reason = reason;
|
|
31
|
+
error.status = status;
|
|
32
|
+
return error;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function oauthReason(error) {
|
|
36
|
+
return typeof error?.reason === "string" ? error.reason : "oauth_failed";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loopback(hostname) {
|
|
40
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Normalizes a trusted server URL; plain HTTP is limited to local development. */
|
|
44
|
+
export function normalizeServerURL(value = DEFAULT_SERVER_URL) {
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = new URL(text(value) || DEFAULT_SERVER_URL);
|
|
48
|
+
} catch {
|
|
49
|
+
throw oauthError("invalid_server_url");
|
|
50
|
+
}
|
|
51
|
+
if (parsed.username || parsed.password || parsed.hash || parsed.search || !parsed.hostname) throw oauthError("invalid_server_url");
|
|
52
|
+
if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback(parsed.hostname))) {
|
|
53
|
+
throw oauthError("insecure_server_url");
|
|
54
|
+
}
|
|
55
|
+
parsed.pathname = parsed.pathname.replace(/\/mcp\/?$/i, "");
|
|
56
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function platformConfigRoot({ env = process.env, home = homedir() } = {}) {
|
|
60
|
+
if (text(env.XDG_CONFIG_HOME)) return text(env.XDG_CONFIG_HOME);
|
|
61
|
+
if (process.platform === "win32") return text(env.APPDATA) || join(home, "AppData", "Roaming");
|
|
62
|
+
if (process.platform === "darwin") return join(home, "Library", "Application Support");
|
|
63
|
+
return join(home, ".config");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function platformStateRoot({ env = process.env, home = homedir() } = {}) {
|
|
67
|
+
if (text(env.XDG_STATE_HOME)) return text(env.XDG_STATE_HOME);
|
|
68
|
+
if (process.platform === "win32") return text(env.LOCALAPPDATA) || text(env.APPDATA) || join(home, "AppData", "Local");
|
|
69
|
+
if (process.platform === "darwin") return join(home, "Library", "Application Support");
|
|
70
|
+
return join(home, ".local", "state");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function serverDigest(serverURL) {
|
|
74
|
+
return createHash("sha256").update(serverURL).digest("hex").slice(0, 20);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function authPaths(host, options = {}) {
|
|
78
|
+
const configRoot = options.configRoot || platformConfigRoot(options);
|
|
79
|
+
const stateRoot = options.stateRoot || platformStateRoot(options);
|
|
80
|
+
return {
|
|
81
|
+
config: join(configRoot, "mindbridge", `${host}-oauth.json`),
|
|
82
|
+
lock: join(stateRoot, "mindbridge", "oauth", `${host}-${serverDigest(options.serverURL || "default")}.lock`),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function readJSON(path, fallback) {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error?.code === "ENOENT") return fallback;
|
|
91
|
+
throw oauthError("invalid_config");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function atomicWrite(path, content) {
|
|
96
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
97
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
98
|
+
try {
|
|
99
|
+
await writeFile(temp, content, { mode: 0o600 });
|
|
100
|
+
await rename(temp, path);
|
|
101
|
+
} finally {
|
|
102
|
+
await unlink(temp).catch(() => {});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Reads only the nonsecret persisted server selection. */
|
|
107
|
+
export async function readServerConfig(host, options = {}) {
|
|
108
|
+
const value = await readJSON(authPaths(host, options).config, undefined);
|
|
109
|
+
if (!value || typeof value !== "object" || !text(value.serverURL)) return undefined;
|
|
110
|
+
const mcpOAuthScope = text(value.mcpOAuthScope);
|
|
111
|
+
return {
|
|
112
|
+
serverURL: normalizeServerURL(value.serverURL),
|
|
113
|
+
...(mcpOAuthScope ? { mcpOAuthScope } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function resolveServerURL({ host, serverURL, env = process.env, ...options }) {
|
|
118
|
+
const explicit = text(serverURL) || text(env.MINDBRIDGE_MCP_SERVER_URL);
|
|
119
|
+
if (explicit) return normalizeServerURL(explicit);
|
|
120
|
+
const stored = await readServerConfig(host, { ...options, env });
|
|
121
|
+
return stored?.serverURL || DEFAULT_SERVER_URL;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function saveServerConfig(host, serverURL, options = {}) {
|
|
125
|
+
const normalized = normalizeServerURL(serverURL);
|
|
126
|
+
const value = { version: 1, serverURL: normalized };
|
|
127
|
+
const mcpOAuthScope = text(options.mcpOAuthScope);
|
|
128
|
+
if (mcpOAuthScope) value.mcpOAuthScope = mcpOAuthScope;
|
|
129
|
+
await atomicWrite(authPaths(host, options).config, `${JSON.stringify(value)}\n`);
|
|
130
|
+
return normalized;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Validates both OAuth resources before setup writes host configuration. */
|
|
134
|
+
export async function preflightServer(serverURL, { fetchImpl = globalThis.fetch, timeoutMs = 6_000 } = {}) {
|
|
135
|
+
const base = normalizeServerURL(serverURL);
|
|
136
|
+
const resources = [
|
|
137
|
+
["mcp", `${base}/mcp`],
|
|
138
|
+
["v1/sessions", `${base}/v1/sessions`],
|
|
139
|
+
];
|
|
140
|
+
for (const [path, expectedResource] of resources) {
|
|
141
|
+
let response;
|
|
142
|
+
try {
|
|
143
|
+
response = await fetchImpl(`${base}/.well-known/oauth-protected-resource/${path}`, {
|
|
144
|
+
headers: { Accept: "application/json" },
|
|
145
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
146
|
+
});
|
|
147
|
+
} catch {
|
|
148
|
+
throw oauthError("unreachable");
|
|
149
|
+
}
|
|
150
|
+
const body = await responseJSON(response);
|
|
151
|
+
if (!response.ok || text(body.resource) !== expectedResource || !Array.isArray(body.authorization_servers) || !body.authorization_servers.length) {
|
|
152
|
+
throw oauthError("oauth_metadata_invalid", response.status);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { serverURL: base };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function keyringMissing(error) {
|
|
159
|
+
return /not found|no (?:entry|password)|credentials? not found/i.test(String(error?.message || ""));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function commandSucceeds(command, args) {
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
let child;
|
|
165
|
+
try {
|
|
166
|
+
child = spawn(command, args, { stdio: "ignore", shell: false, windowsHide: true });
|
|
167
|
+
} catch {
|
|
168
|
+
resolve(false);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
let settled = false;
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
child.kill();
|
|
174
|
+
finish(false);
|
|
175
|
+
}, 2_000);
|
|
176
|
+
function finish(value) {
|
|
177
|
+
if (settled) return;
|
|
178
|
+
settled = true;
|
|
179
|
+
clearTimeout(timer);
|
|
180
|
+
resolve(value);
|
|
181
|
+
}
|
|
182
|
+
child.once("error", () => finish(false));
|
|
183
|
+
child.once("close", (code) => finish(code === 0));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function ensureLinuxSecretService() {
|
|
188
|
+
if (process.platform !== "linux") return;
|
|
189
|
+
if (!text(process.env.DBUS_SESSION_BUS_ADDRESS)) throw oauthError("keychain_unavailable");
|
|
190
|
+
const gdbus = await commandSucceeds("gdbus", [
|
|
191
|
+
"introspect", "--session", "--dest", "org.freedesktop.secrets",
|
|
192
|
+
"--object-path", "/org/freedesktop/secrets",
|
|
193
|
+
]);
|
|
194
|
+
const dbusSend = gdbus || await commandSucceeds("dbus-send", [
|
|
195
|
+
"--session", "--print-reply", "--dest=org.freedesktop.secrets",
|
|
196
|
+
"/org/freedesktop/secrets", "org.freedesktop.DBus.Introspectable.Introspect",
|
|
197
|
+
]);
|
|
198
|
+
if (!dbusSend) throw oauthError("keychain_unavailable");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function validCredential(value) {
|
|
202
|
+
if (!value || typeof value !== "object") return undefined;
|
|
203
|
+
const accessToken = text(value.accessToken);
|
|
204
|
+
const refreshToken = text(value.refreshToken);
|
|
205
|
+
const clientID = text(value.clientID);
|
|
206
|
+
const expiresAt = Number(value.expiresAt);
|
|
207
|
+
if (!accessToken || !refreshToken || !clientID || !Number.isFinite(expiresAt)) return undefined;
|
|
208
|
+
return { accessToken, refreshToken, clientID, expiresAt };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A keychain-only credential store. It intentionally has no file fallback. */
|
|
212
|
+
export function createCredentialStore({ host, serverURL, keyring }) {
|
|
213
|
+
if (!keyring?.Entry) throw oauthError("keychain_unavailable");
|
|
214
|
+
let entry;
|
|
215
|
+
try {
|
|
216
|
+
entry = new keyring.Entry(KEYRING_SERVICE, `${host}:${serverDigest(serverURL)}`);
|
|
217
|
+
} catch {
|
|
218
|
+
throw oauthError("keychain_unavailable");
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
async load() {
|
|
222
|
+
let raw;
|
|
223
|
+
try {
|
|
224
|
+
raw = await entry.getPassword();
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (keyringMissing(error)) return undefined;
|
|
227
|
+
throw oauthError("keychain_unavailable");
|
|
228
|
+
}
|
|
229
|
+
if (!raw) return undefined;
|
|
230
|
+
try {
|
|
231
|
+
const credential = validCredential(JSON.parse(raw));
|
|
232
|
+
if (!credential) throw new Error("invalid");
|
|
233
|
+
return credential;
|
|
234
|
+
} catch {
|
|
235
|
+
throw oauthError("keychain_invalid");
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
async save(credential) {
|
|
239
|
+
const valid = validCredential(credential);
|
|
240
|
+
if (!valid) throw oauthError("invalid_token_response");
|
|
241
|
+
try {
|
|
242
|
+
await entry.setPassword(JSON.stringify(valid));
|
|
243
|
+
} catch {
|
|
244
|
+
throw oauthError("keychain_unavailable");
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
async remove() {
|
|
248
|
+
try {
|
|
249
|
+
await entry.deletePassword();
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (!keyringMissing(error)) throw oauthError("keychain_unavailable");
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function loadKeyring(keyring) {
|
|
258
|
+
try {
|
|
259
|
+
// The native Linux binding falls back to kernel keyutils when Secret
|
|
260
|
+
// Service is unavailable. Probe the durable backend first so setup never
|
|
261
|
+
// silently accepts a credential store that will disappear at logout.
|
|
262
|
+
if (!keyring) {
|
|
263
|
+
await ensureLinuxSecretService();
|
|
264
|
+
keyring = await import("@napi-rs/keyring");
|
|
265
|
+
}
|
|
266
|
+
if (!keyring.Entry) throw new Error("missing Entry");
|
|
267
|
+
const probeAccount = `probe-${process.pid}-${Date.now()}-${randomBytes(8).toString("hex")}`;
|
|
268
|
+
const probeValue = randomBytes(16).toString("hex");
|
|
269
|
+
const probe = new keyring.Entry(KEYRING_SERVICE, probeAccount);
|
|
270
|
+
try {
|
|
271
|
+
await probe.setPassword(probeValue);
|
|
272
|
+
if (await probe.getPassword() !== probeValue) throw new Error("keychain probe mismatch");
|
|
273
|
+
} finally {
|
|
274
|
+
try {
|
|
275
|
+
await probe.deletePassword();
|
|
276
|
+
} catch {}
|
|
277
|
+
}
|
|
278
|
+
return keyring;
|
|
279
|
+
} catch {
|
|
280
|
+
throw oauthError("keychain_unavailable");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Copies the package plus its selected native binding next to persistent hooks. */
|
|
285
|
+
export async function copyKeyringRuntime({ targetRoot }) {
|
|
286
|
+
try {
|
|
287
|
+
const packageEntry = nativeRequire.resolve("@napi-rs/keyring");
|
|
288
|
+
const sourceScope = dirname(dirname(packageEntry));
|
|
289
|
+
const targetScope = join(targetRoot, "node_modules", "@napi-rs");
|
|
290
|
+
await mkdir(dirname(targetScope), { recursive: true, mode: 0o700 });
|
|
291
|
+
await cp(sourceScope, targetScope, { recursive: true, force: true, dereference: true });
|
|
292
|
+
} catch {
|
|
293
|
+
throw oauthError("keychain_runtime_unavailable");
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function sleep(ms) {
|
|
298
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** A small cross-process lock for OAuth browser flows and nonsecret state updates. */
|
|
302
|
+
export async function withFileLock(path, fn, { timeoutMs = LOCK_TIMEOUT_MS, staleMs = LOCK_STALE_MS } = {}) {
|
|
303
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
304
|
+
const deadline = Date.now() + timeoutMs;
|
|
305
|
+
while (true) {
|
|
306
|
+
let handle;
|
|
307
|
+
try {
|
|
308
|
+
handle = await open(path, "wx", 0o600);
|
|
309
|
+
} catch (error) {
|
|
310
|
+
if (error?.code !== "EEXIST") throw oauthError("lock_unavailable");
|
|
311
|
+
try {
|
|
312
|
+
const info = await stat(path);
|
|
313
|
+
if (Date.now() - info.mtimeMs > staleMs) await unlink(path);
|
|
314
|
+
} catch {
|
|
315
|
+
// A competing hook released its lock. The next iteration acquires it.
|
|
316
|
+
}
|
|
317
|
+
if (Date.now() >= deadline) throw oauthError("authentication_busy");
|
|
318
|
+
await sleep(LOCK_RETRY_MS);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
await handle.close();
|
|
322
|
+
try {
|
|
323
|
+
return await fn();
|
|
324
|
+
} finally {
|
|
325
|
+
await unlink(path).catch(() => {});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function responseJSON(response) {
|
|
331
|
+
const raw = await response.text();
|
|
332
|
+
if (!raw) return {};
|
|
333
|
+
try {
|
|
334
|
+
return JSON.parse(raw);
|
|
335
|
+
} catch {
|
|
336
|
+
return {};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function postForm(fetchImpl, url, values, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
341
|
+
let response;
|
|
342
|
+
try {
|
|
343
|
+
response = await fetchImpl(url, {
|
|
344
|
+
method: "POST",
|
|
345
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
346
|
+
body: new URLSearchParams(values).toString(),
|
|
347
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
348
|
+
});
|
|
349
|
+
} catch {
|
|
350
|
+
throw oauthError("unreachable");
|
|
351
|
+
}
|
|
352
|
+
return { response, body: await responseJSON(response) };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function postJSON(fetchImpl, url, value, timeoutMs = REQUEST_TIMEOUT_MS) {
|
|
356
|
+
let response;
|
|
357
|
+
try {
|
|
358
|
+
response = await fetchImpl(url, {
|
|
359
|
+
method: "POST",
|
|
360
|
+
headers: { "Content-Type": "application/json" },
|
|
361
|
+
body: JSON.stringify(value),
|
|
362
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
363
|
+
});
|
|
364
|
+
} catch {
|
|
365
|
+
throw oauthError("unreachable");
|
|
366
|
+
}
|
|
367
|
+
return { response, body: await responseJSON(response) };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function equalState(left, right) {
|
|
371
|
+
if (!left || !right || left.length !== right.length) return false;
|
|
372
|
+
return timingSafeEqual(Buffer.from(left), Buffer.from(right));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function loopbackCallback(state, timeoutMs) {
|
|
376
|
+
let finish;
|
|
377
|
+
const result = new Promise((resolve, reject) => {
|
|
378
|
+
finish = { resolve, reject };
|
|
379
|
+
});
|
|
380
|
+
let settled = false;
|
|
381
|
+
let timer;
|
|
382
|
+
const server = createServer((request, response) => {
|
|
383
|
+
const callbackURL = new URL(request.url || "/", "http://127.0.0.1");
|
|
384
|
+
if (request.method !== "GET" || callbackURL.pathname !== "/callback") {
|
|
385
|
+
response.writeHead(404).end();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (!equalState(callbackURL.searchParams.get("state"), state)) {
|
|
389
|
+
response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }).end("Invalid OAuth response.");
|
|
390
|
+
settle(oauthError("authorization_state_invalid"));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const code = text(callbackURL.searchParams.get("code"));
|
|
394
|
+
if (!code || callbackURL.searchParams.has("error")) {
|
|
395
|
+
response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }).end("Authorization was not completed.");
|
|
396
|
+
settle(oauthError("authorization_denied"));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }).end("MindBridge authorization complete. You can close this tab.");
|
|
400
|
+
settle(undefined, code);
|
|
401
|
+
});
|
|
402
|
+
function settle(error, code) {
|
|
403
|
+
if (settled) return;
|
|
404
|
+
settled = true;
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
server.close();
|
|
407
|
+
if (error) finish.reject(error);
|
|
408
|
+
else finish.resolve(code);
|
|
409
|
+
}
|
|
410
|
+
await new Promise((resolve, reject) => {
|
|
411
|
+
server.once("error", reject);
|
|
412
|
+
server.listen(0, "127.0.0.1", () => {
|
|
413
|
+
server.off("error", reject);
|
|
414
|
+
resolve();
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
const address = server.address();
|
|
418
|
+
if (!address || typeof address === "string") {
|
|
419
|
+
server.close();
|
|
420
|
+
throw oauthError("loopback_unavailable");
|
|
421
|
+
}
|
|
422
|
+
timer = setTimeout(() => settle(oauthError("authorization_timed_out")), timeoutMs);
|
|
423
|
+
timer.unref?.();
|
|
424
|
+
return {
|
|
425
|
+
redirectURI: `http://127.0.0.1:${address.port}/callback`,
|
|
426
|
+
wait: () => result,
|
|
427
|
+
close: () => {
|
|
428
|
+
if (settled) return;
|
|
429
|
+
settled = true;
|
|
430
|
+
clearTimeout(timer);
|
|
431
|
+
server.close();
|
|
432
|
+
},
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Opens a browser without putting a token in a shell command or log. */
|
|
437
|
+
export async function openBrowser(url, { platform = process.platform, spawnImpl = spawn } = {}) {
|
|
438
|
+
const command = platform === "darwin"
|
|
439
|
+
? ["open", [url]]
|
|
440
|
+
: platform === "win32"
|
|
441
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
442
|
+
: ["xdg-open", [url]];
|
|
443
|
+
await new Promise((resolve, reject) => {
|
|
444
|
+
const child = spawnImpl(command[0], command[1], { stdio: "ignore", windowsHide: true, shell: false });
|
|
445
|
+
child.once("error", reject);
|
|
446
|
+
child.once("spawn", () => {
|
|
447
|
+
child.unref?.();
|
|
448
|
+
resolve();
|
|
449
|
+
});
|
|
450
|
+
}).catch(() => {
|
|
451
|
+
throw oauthError("browser_unavailable");
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function fresh(credential, now) {
|
|
456
|
+
return credential.expiresAt > now() + ACCESS_TOKEN_SKEW_MS;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function credentialFromToken(body, clientID, now) {
|
|
460
|
+
const accessToken = text(body.access_token || body.accessToken);
|
|
461
|
+
const refreshToken = text(body.refresh_token || body.refreshToken);
|
|
462
|
+
const expiresIn = Number(body.expires_in || body.expiresIn);
|
|
463
|
+
if (!accessToken || !refreshToken || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
464
|
+
throw oauthError("invalid_token_response");
|
|
465
|
+
}
|
|
466
|
+
return { accessToken, refreshToken, clientID, expiresAt: now() + expiresIn * 1_000 };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* OAuth client for lifecycle hooks. Credential material lives exclusively in
|
|
471
|
+
* the platform keychain; the companion config record contains only serverURL.
|
|
472
|
+
*/
|
|
473
|
+
export function createOAuthClient({
|
|
474
|
+
host,
|
|
475
|
+
serverURL,
|
|
476
|
+
env = process.env,
|
|
477
|
+
configRoot,
|
|
478
|
+
stateRoot,
|
|
479
|
+
keyring,
|
|
480
|
+
keyringLoader = loadKeyring,
|
|
481
|
+
fetchImpl = globalThis.fetch,
|
|
482
|
+
now = Date.now,
|
|
483
|
+
browser = openBrowser,
|
|
484
|
+
authorizationTimeoutMs = AUTH_TIMEOUT_MS,
|
|
485
|
+
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
486
|
+
} = {}) {
|
|
487
|
+
const normalizedServerURL = normalizeServerURL(serverURL);
|
|
488
|
+
const resource = `${normalizedServerURL}/v1/sessions`;
|
|
489
|
+
const paths = authPaths(host, { env, configRoot, stateRoot, serverURL: normalizedServerURL });
|
|
490
|
+
let storePromise;
|
|
491
|
+
|
|
492
|
+
async function store() {
|
|
493
|
+
storePromise ??= Promise.resolve(keyring || keyringLoader()).then((loaded) => createCredentialStore({
|
|
494
|
+
host,
|
|
495
|
+
serverURL: normalizedServerURL,
|
|
496
|
+
keyring: loaded,
|
|
497
|
+
}));
|
|
498
|
+
return storePromise;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function refresh(credential) {
|
|
502
|
+
const { response, body } = await postForm(fetchImpl, `${normalizedServerURL}/oauth/token`, {
|
|
503
|
+
grant_type: "refresh_token",
|
|
504
|
+
refresh_token: credential.refreshToken,
|
|
505
|
+
client_id: credential.clientID,
|
|
506
|
+
resource,
|
|
507
|
+
}, requestTimeoutMs);
|
|
508
|
+
if (!response.ok) throw oauthError("refresh_rejected");
|
|
509
|
+
return credentialFromToken(body, credential.clientID, now);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function register(redirectURI) {
|
|
513
|
+
const { response, body } = await postJSON(fetchImpl, `${normalizedServerURL}/oauth/register`, {
|
|
514
|
+
client_name: `MindBridge ${host} lifecycle hooks`,
|
|
515
|
+
redirect_uris: [redirectURI],
|
|
516
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
517
|
+
response_types: ["code"],
|
|
518
|
+
token_endpoint_auth_method: "none",
|
|
519
|
+
}, requestTimeoutMs);
|
|
520
|
+
const clientID = text(body.client_id || body.clientId);
|
|
521
|
+
if (!response.ok || !clientID) throw oauthError("client_registration_failed");
|
|
522
|
+
return clientID;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function authorize(credentialStore) {
|
|
526
|
+
const state = randomBytes(32).toString("base64url");
|
|
527
|
+
const verifier = randomBytes(48).toString("base64url");
|
|
528
|
+
const callback = await loopbackCallback(state, authorizationTimeoutMs);
|
|
529
|
+
try {
|
|
530
|
+
const clientID = await register(callback.redirectURI);
|
|
531
|
+
const authorizeURL = new URL(`${normalizedServerURL}/oauth/authorize`);
|
|
532
|
+
authorizeURL.search = new URLSearchParams({
|
|
533
|
+
response_type: "code",
|
|
534
|
+
client_id: clientID,
|
|
535
|
+
redirect_uri: callback.redirectURI,
|
|
536
|
+
scope: "sessions",
|
|
537
|
+
resource,
|
|
538
|
+
state,
|
|
539
|
+
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
540
|
+
code_challenge_method: "S256",
|
|
541
|
+
}).toString();
|
|
542
|
+
await browser(authorizeURL.toString());
|
|
543
|
+
const code = await callback.wait();
|
|
544
|
+
const { response, body } = await postForm(fetchImpl, `${normalizedServerURL}/oauth/token`, {
|
|
545
|
+
grant_type: "authorization_code",
|
|
546
|
+
code,
|
|
547
|
+
redirect_uri: callback.redirectURI,
|
|
548
|
+
client_id: clientID,
|
|
549
|
+
code_verifier: verifier,
|
|
550
|
+
resource,
|
|
551
|
+
}, requestTimeoutMs);
|
|
552
|
+
if (!response.ok) throw oauthError("token_rejected");
|
|
553
|
+
const credential = credentialFromToken(body, clientID, now);
|
|
554
|
+
await credentialStore.save(credential);
|
|
555
|
+
return credential;
|
|
556
|
+
} finally {
|
|
557
|
+
callback.close();
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function obtain({ force = false, interactive = false } = {}) {
|
|
562
|
+
const credentialStore = await store();
|
|
563
|
+
const initial = await credentialStore.load();
|
|
564
|
+
if (!force && initial && fresh(initial, now)) return initial;
|
|
565
|
+
if (!interactive && !initial?.refreshToken) throw oauthError("authentication_required");
|
|
566
|
+
return withFileLock(paths.lock, async () => {
|
|
567
|
+
const afterWait = await credentialStore.load();
|
|
568
|
+
if (afterWait && fresh(afterWait, now) && (!force || afterWait.accessToken !== initial?.accessToken)) return afterWait;
|
|
569
|
+
if (afterWait?.refreshToken) {
|
|
570
|
+
try {
|
|
571
|
+
const refreshed = await refresh(afterWait);
|
|
572
|
+
await credentialStore.save(refreshed);
|
|
573
|
+
return refreshed;
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (oauthReason(error) !== "refresh_rejected") throw error;
|
|
576
|
+
await credentialStore.remove();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (!interactive) throw oauthError("authentication_required");
|
|
580
|
+
return authorize(credentialStore);
|
|
581
|
+
}, { staleMs: authorizationTimeoutMs + LOCK_STALE_MARGIN_MS });
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function send(path, body, accessToken, method = "POST") {
|
|
585
|
+
let response;
|
|
586
|
+
try {
|
|
587
|
+
response = await fetchImpl(`${normalizedServerURL}${path}`, {
|
|
588
|
+
method,
|
|
589
|
+
headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
|
|
590
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
591
|
+
signal: AbortSignal.timeout(requestTimeoutMs),
|
|
592
|
+
});
|
|
593
|
+
} catch {
|
|
594
|
+
throw oauthError("unreachable");
|
|
595
|
+
}
|
|
596
|
+
return { response, body: await responseJSON(response) };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function authorizedRequest(path, body, { interactive = false, method = "POST" } = {}) {
|
|
600
|
+
let credential = await obtain({ interactive });
|
|
601
|
+
let result;
|
|
602
|
+
let transientRetried = false;
|
|
603
|
+
try {
|
|
604
|
+
result = await send(path, body, credential.accessToken, method);
|
|
605
|
+
} catch (error) {
|
|
606
|
+
if (oauthReason(error) !== "unreachable") throw error;
|
|
607
|
+
transientRetried = true;
|
|
608
|
+
result = await send(path, body, credential.accessToken, method);
|
|
609
|
+
}
|
|
610
|
+
if (result.response.status === 401 || result.response.status === 403) {
|
|
611
|
+
credential = await obtain({ force: true, interactive });
|
|
612
|
+
result = await send(path, body, credential.accessToken, method);
|
|
613
|
+
} else if (!transientRetried && (result.response.status === 408 || result.response.status === 429 || result.response.status >= 500)) {
|
|
614
|
+
result = await send(path, body, credential.accessToken, method);
|
|
615
|
+
}
|
|
616
|
+
if (!result.response.ok) {
|
|
617
|
+
const status = result.response.status;
|
|
618
|
+
const reason = status === 401 || status === 403
|
|
619
|
+
? "unauthorized"
|
|
620
|
+
: status === 429
|
|
621
|
+
? "rate_limited"
|
|
622
|
+
: status === 408 || status >= 500
|
|
623
|
+
? "server_unavailable"
|
|
624
|
+
: "request_failed";
|
|
625
|
+
throw oauthError(reason, status);
|
|
626
|
+
}
|
|
627
|
+
return result.body;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return {
|
|
631
|
+
serverURL: normalizedServerURL,
|
|
632
|
+
async authenticate() {
|
|
633
|
+
return obtain({ interactive: true });
|
|
634
|
+
},
|
|
635
|
+
async status() {
|
|
636
|
+
const credential = await (await store()).load();
|
|
637
|
+
return { authenticated: Boolean(credential), expiresAt: credential?.expiresAt };
|
|
638
|
+
},
|
|
639
|
+
async logout() {
|
|
640
|
+
const credentialStore = await store();
|
|
641
|
+
const credential = await credentialStore.load();
|
|
642
|
+
if (!credential) return false;
|
|
643
|
+
try {
|
|
644
|
+
await postForm(fetchImpl, `${normalizedServerURL}/oauth/revoke`, {
|
|
645
|
+
token: credential.refreshToken,
|
|
646
|
+
token_type_hint: "refresh_token",
|
|
647
|
+
client_id: credential.clientID,
|
|
648
|
+
}, requestTimeoutMs);
|
|
649
|
+
} catch {
|
|
650
|
+
// Revocation is best effort; removing the local keychain entry is not.
|
|
651
|
+
}
|
|
652
|
+
await credentialStore.remove();
|
|
653
|
+
return true;
|
|
654
|
+
},
|
|
655
|
+
async request(path, body, { interactive = false, method = "POST" } = {}) {
|
|
656
|
+
return authorizedRequest(path, body, { interactive, method });
|
|
657
|
+
},
|
|
658
|
+
async check() {
|
|
659
|
+
return authorizedRequest("/v1/sessions/auth/check", undefined, { method: "GET" });
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function sessionStatePaths(stateRoot, serverURL, externalRef) {
|
|
665
|
+
const digest = createHash("sha256").update(`${serverURL}\n${externalRef}`).digest("hex").slice(0, 12);
|
|
666
|
+
return {
|
|
667
|
+
state: join(stateRoot, `${digest}.json`),
|
|
668
|
+
lock: join(stateRoot, `${digest}.lock`),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function loadState(path) {
|
|
673
|
+
return readJSON(path, undefined);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function saveState(path, value) {
|
|
677
|
+
await atomicWrite(path, `${JSON.stringify(value)}\n`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function positiveInteger(value, fallback = 1) {
|
|
681
|
+
return Number.isInteger(value) && value >= 1 ? value : fallback;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function automaticMessageRef(state, role, content) {
|
|
685
|
+
const assistant = role === "assistant";
|
|
686
|
+
const key = assistant ? "next_assistant_ref" : "next_user_ref";
|
|
687
|
+
const pendingKey = assistant ? "pending_assistant_ref" : "pending_user_ref";
|
|
688
|
+
const prefix = assistant ? "out" : "in";
|
|
689
|
+
const digest = createHash("sha256").update(`${role}\n${content}`).digest("hex");
|
|
690
|
+
const pending = state[pendingKey];
|
|
691
|
+
if (pending?.digest === digest && text(pending.message_ref)) {
|
|
692
|
+
return { key, pendingKey, value: pending.message_ref, next: positiveInteger(state[key]), digest, reused: true };
|
|
693
|
+
}
|
|
694
|
+
const ordinal = positiveInteger(state[key]);
|
|
695
|
+
return { key, pendingKey, value: `${prefix}:${ordinal}`, next: ordinal + 1, digest, reused: false };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function attribution(host, { userName, agentName, adapterName, adapterRef }) {
|
|
699
|
+
return {
|
|
700
|
+
user: { name: userName, external_ref: `${host}:user` },
|
|
701
|
+
agent: { name: agentName, external_ref: `${host}:agent` },
|
|
702
|
+
adapter: { kind: host, name: adapterName, external_ref: adapterRef },
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function sessionMetadata(value) {
|
|
707
|
+
return text(value?.project?.key) ? value : undefined;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** OAuth-backed lifecycle session client. Session state has no credentials. */
|
|
711
|
+
export function createOAuthSessionClient({ serverURL, oauth, stateRoot }) {
|
|
712
|
+
function paths(externalRef) {
|
|
713
|
+
return sessionStatePaths(stateRoot, serverURL, externalRef);
|
|
714
|
+
}
|
|
715
|
+
function pendingBytes(state) {
|
|
716
|
+
const pending = Array.isArray(state?.pending) ? state.pending : [];
|
|
717
|
+
return pending.length ? Buffer.byteLength(JSON.stringify(pending)) : 0;
|
|
718
|
+
}
|
|
719
|
+
async function bytesOutside(excluded) {
|
|
720
|
+
let names = [];
|
|
721
|
+
try {
|
|
722
|
+
names = await readdir(stateRoot);
|
|
723
|
+
} catch (error) {
|
|
724
|
+
if (error?.code === "ENOENT") return 0;
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
727
|
+
let total = 0;
|
|
728
|
+
for (const name of names.filter((value) => value.endsWith(".json"))) {
|
|
729
|
+
const path = join(stateRoot, name);
|
|
730
|
+
if (path === excluded) continue;
|
|
731
|
+
try {
|
|
732
|
+
total += pendingBytes(await loadState(path));
|
|
733
|
+
} catch (error) {
|
|
734
|
+
if (error?.code === "ENOENT") continue;
|
|
735
|
+
// Corrupt state is surfaced by doctor without preventing new writes.
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return total;
|
|
739
|
+
}
|
|
740
|
+
async function saveOutbox(path, state) {
|
|
741
|
+
const content = `${JSON.stringify(state)}\n`;
|
|
742
|
+
if (pendingBytes(state) + await bytesOutside(path) > OUTBOX_MAX_BYTES) throw oauthError("outbox_full");
|
|
743
|
+
await atomicWrite(path, content);
|
|
744
|
+
}
|
|
745
|
+
async function usableState(path) {
|
|
746
|
+
const state = await loadState(path);
|
|
747
|
+
if (!state) return undefined;
|
|
748
|
+
state.pending = Array.isArray(state.pending) ? state.pending : [];
|
|
749
|
+
if (!state.quarantined && state.pending.some((entry) => Date.now() - Number(entry.created_at || 0) > OUTBOX_MAX_AGE_MS)) {
|
|
750
|
+
state.pending = [];
|
|
751
|
+
state.quarantined = { reason: "expired", at: Date.now() };
|
|
752
|
+
await saveState(path, state);
|
|
753
|
+
}
|
|
754
|
+
return state;
|
|
755
|
+
}
|
|
756
|
+
async function requireState(externalRef) {
|
|
757
|
+
const state = await usableState(paths(externalRef).state);
|
|
758
|
+
if (!state || (!state.session_id && !state.pending.some((entry) => entry.kind === "start"))) {
|
|
759
|
+
throw oauthError("missing_session_state");
|
|
760
|
+
}
|
|
761
|
+
if (state.quarantined) throw oauthError(`outbox_${state.quarantined.reason}`);
|
|
762
|
+
return state;
|
|
763
|
+
}
|
|
764
|
+
async function flush(path, state) {
|
|
765
|
+
let lastResult;
|
|
766
|
+
while (state.pending.length) {
|
|
767
|
+
const entry = state.pending[0];
|
|
768
|
+
const dynamicPath = entry.kind === "append"
|
|
769
|
+
? `/v1/sessions/${encodeURIComponent(state.session_id)}/messages`
|
|
770
|
+
: entry.kind === "end"
|
|
771
|
+
? `/v1/sessions/${encodeURIComponent(state.session_id)}/end`
|
|
772
|
+
: entry.kind === "reopen"
|
|
773
|
+
? `/v1/sessions/${encodeURIComponent(state.session_id)}/reopen`
|
|
774
|
+
: entry.path;
|
|
775
|
+
lastResult = await oauth.request(dynamicPath, entry.body);
|
|
776
|
+
if (entry.kind === "start") {
|
|
777
|
+
const sessionID = text(lastResult.session_id);
|
|
778
|
+
if (!sessionID) throw oauthError("invalid_start_response");
|
|
779
|
+
state.session_id = sessionID;
|
|
780
|
+
state.metadata = sessionMetadata(lastResult.metadata) || state.metadata || sessionMetadata(entry.body.metadata);
|
|
781
|
+
}
|
|
782
|
+
if (entry.kind === "append" && entry.automatic) {
|
|
783
|
+
const pendingKey = entry.body.role === "assistant" ? "pending_assistant_ref" : "pending_user_ref";
|
|
784
|
+
if (state[pendingKey]?.message_ref === entry.body.external_ref) delete state[pendingKey];
|
|
785
|
+
}
|
|
786
|
+
state.pending.shift();
|
|
787
|
+
if (entry.kind === "end") state.ended = true;
|
|
788
|
+
if (entry.kind === "reopen") {
|
|
789
|
+
state.ended = false;
|
|
790
|
+
state.end_reason = "";
|
|
791
|
+
}
|
|
792
|
+
await saveOutbox(path, state);
|
|
793
|
+
}
|
|
794
|
+
return lastResult;
|
|
795
|
+
}
|
|
796
|
+
async function enqueue(path, state, entry, { front = false } = {}) {
|
|
797
|
+
const pending = { ...entry, created_at: Date.now() };
|
|
798
|
+
if (front) state.pending.unshift(pending);
|
|
799
|
+
else state.pending.push(pending);
|
|
800
|
+
try {
|
|
801
|
+
await saveOutbox(path, state);
|
|
802
|
+
} catch (error) {
|
|
803
|
+
if (front) state.pending.shift();
|
|
804
|
+
else state.pending.pop();
|
|
805
|
+
if (oauthReason(error) === "outbox_full") {
|
|
806
|
+
state.loss = { reason: "full", at: Date.now() };
|
|
807
|
+
await saveState(path, state);
|
|
808
|
+
}
|
|
809
|
+
throw error;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
async start({ externalRef, host, userName, agentName, adapterName, adapterRef, metadata }) {
|
|
814
|
+
const { state, lock } = paths(externalRef);
|
|
815
|
+
return withFileLock(lock, async () => {
|
|
816
|
+
const prior = await usableState(state);
|
|
817
|
+
const pendingStart = prior?.pending?.find((entry) => entry.kind === "start");
|
|
818
|
+
const startMetadata = sessionMetadata(prior?.metadata)
|
|
819
|
+
|| sessionMetadata(pendingStart?.body?.metadata)
|
|
820
|
+
|| sessionMetadata(metadata);
|
|
821
|
+
const body = {
|
|
822
|
+
external_ref: externalRef,
|
|
823
|
+
attribution: attribution(host, { userName, agentName, adapterName, adapterRef }),
|
|
824
|
+
};
|
|
825
|
+
if (startMetadata) body.metadata = startMetadata;
|
|
826
|
+
let current = prior;
|
|
827
|
+
let result;
|
|
828
|
+
if (!current) {
|
|
829
|
+
current = {
|
|
830
|
+
version: 1,
|
|
831
|
+
server_url: serverURL,
|
|
832
|
+
session_id: "",
|
|
833
|
+
external_ref: externalRef,
|
|
834
|
+
metadata: startMetadata,
|
|
835
|
+
next_user_ref: 1,
|
|
836
|
+
next_assistant_ref: 1,
|
|
837
|
+
ended: false,
|
|
838
|
+
end_reason: "",
|
|
839
|
+
pending: [],
|
|
840
|
+
};
|
|
841
|
+
await enqueue(state, current, { kind: "start", path: "/v1/sessions", body });
|
|
842
|
+
result = await flush(state, current);
|
|
843
|
+
} else if (!current.session_id) {
|
|
844
|
+
const queuedStart = current.pending.find((entry) => entry.kind === "start");
|
|
845
|
+
if (queuedStart) {
|
|
846
|
+
if (!sessionMetadata(queuedStart.body.metadata) && startMetadata) queuedStart.body.metadata = startMetadata;
|
|
847
|
+
current.metadata = sessionMetadata(current.metadata) || sessionMetadata(queuedStart.body.metadata);
|
|
848
|
+
await saveOutbox(state, current);
|
|
849
|
+
} else {
|
|
850
|
+
await enqueue(state, current, { kind: "start", path: "/v1/sessions", body }, { front: true });
|
|
851
|
+
}
|
|
852
|
+
result = await flush(state, current);
|
|
853
|
+
} else {
|
|
854
|
+
result = await oauth.request("/v1/sessions", body);
|
|
855
|
+
const sessionID = text(result.session_id);
|
|
856
|
+
if (!sessionID) throw oauthError("invalid_start_response");
|
|
857
|
+
current.session_id = sessionID;
|
|
858
|
+
current.metadata = sessionMetadata(result.metadata) || sessionMetadata(current.metadata) || startMetadata;
|
|
859
|
+
current.next_user_ref = positiveInteger(current.next_user_ref);
|
|
860
|
+
current.next_assistant_ref = positiveInteger(current.next_assistant_ref);
|
|
861
|
+
current.ended = Boolean(current.ended);
|
|
862
|
+
current.end_reason = text(current.end_reason);
|
|
863
|
+
current.pending = Array.isArray(current.pending) ? current.pending : [];
|
|
864
|
+
await saveOutbox(state, current);
|
|
865
|
+
await flush(state, current);
|
|
866
|
+
}
|
|
867
|
+
current.metadata = sessionMetadata(result?.metadata) || sessionMetadata(current.metadata) || startMetadata;
|
|
868
|
+
await saveOutbox(state, current);
|
|
869
|
+
return { session_id: current.session_id, metadata: current.metadata, prompt_block: text(result?.memory_block?.prompt_block) };
|
|
870
|
+
});
|
|
871
|
+
},
|
|
872
|
+
async append({ externalRef, role, eventType = "message", content, messageRef }) {
|
|
873
|
+
const { state, lock } = paths(externalRef);
|
|
874
|
+
return withFileLock(lock, async () => {
|
|
875
|
+
const current = await requireState(externalRef);
|
|
876
|
+
if (current.ended) throw oauthError("session_ended");
|
|
877
|
+
const automaticRef = messageRef ? undefined : automaticMessageRef(current, role, content);
|
|
878
|
+
const externalMessageRef = text(messageRef) || automaticRef.value;
|
|
879
|
+
if (automaticRef && !automaticRef.reused) {
|
|
880
|
+
current[automaticRef.key] = automaticRef.next;
|
|
881
|
+
current[automaticRef.pendingKey] = { message_ref: automaticRef.value, digest: automaticRef.digest };
|
|
882
|
+
}
|
|
883
|
+
const body = { role, event_type: eventType, content, external_ref: externalMessageRef };
|
|
884
|
+
await enqueue(state, current, {
|
|
885
|
+
kind: "append",
|
|
886
|
+
body,
|
|
887
|
+
automatic: Boolean(automaticRef),
|
|
888
|
+
});
|
|
889
|
+
await flush(state, current);
|
|
890
|
+
return { session_id: current.session_id, message_ref: externalMessageRef };
|
|
891
|
+
});
|
|
892
|
+
},
|
|
893
|
+
async reopen({ externalRef }) {
|
|
894
|
+
const { state, lock } = paths(externalRef);
|
|
895
|
+
return withFileLock(lock, async () => {
|
|
896
|
+
const current = await requireState(externalRef);
|
|
897
|
+
await enqueue(state, current, {
|
|
898
|
+
kind: "reopen",
|
|
899
|
+
body: {},
|
|
900
|
+
}, { front: true });
|
|
901
|
+
await flush(state, current);
|
|
902
|
+
return { session_id: current.session_id };
|
|
903
|
+
});
|
|
904
|
+
},
|
|
905
|
+
async end({ externalRef, reason }) {
|
|
906
|
+
const { state, lock } = paths(externalRef);
|
|
907
|
+
return withFileLock(lock, async () => {
|
|
908
|
+
const current = await requireState(externalRef);
|
|
909
|
+
const endReason = text(current.end_reason) || text(reason) || "unknown";
|
|
910
|
+
if (!current.end_reason) {
|
|
911
|
+
current.end_reason = endReason;
|
|
912
|
+
}
|
|
913
|
+
if (!current.pending.some((entry) => entry.kind === "end")) {
|
|
914
|
+
await enqueue(state, current, {
|
|
915
|
+
kind: "end",
|
|
916
|
+
body: { end_reason: endReason },
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
await flush(state, current);
|
|
920
|
+
return { session_id: current.session_id, end_reason: endReason };
|
|
921
|
+
});
|
|
922
|
+
},
|
|
923
|
+
async flushAll({ excludeExternalRef = "", limit = 1 } = {}) {
|
|
924
|
+
let names = [];
|
|
925
|
+
try {
|
|
926
|
+
names = await readdir(stateRoot);
|
|
927
|
+
} catch (error) {
|
|
928
|
+
if (error?.code === "ENOENT") return 0;
|
|
929
|
+
throw error;
|
|
930
|
+
}
|
|
931
|
+
const excluded = excludeExternalRef ? paths(excludeExternalRef).state : "";
|
|
932
|
+
let flushed = 0;
|
|
933
|
+
for (const name of names.filter((value) => value.endsWith(".json")).sort()) {
|
|
934
|
+
if (flushed >= limit) break;
|
|
935
|
+
const statePath = join(stateRoot, name);
|
|
936
|
+
if (statePath === excluded) continue;
|
|
937
|
+
try {
|
|
938
|
+
await withFileLock(statePath.replace(/\.json$/, ".lock"), async () => {
|
|
939
|
+
const current = await usableState(statePath);
|
|
940
|
+
if (!current?.pending?.length || current.quarantined) return;
|
|
941
|
+
await flush(statePath, current);
|
|
942
|
+
flushed += 1;
|
|
943
|
+
}, { timeoutMs: 500, staleMs: LOCK_STALE_MS });
|
|
944
|
+
} catch {
|
|
945
|
+
// A previous session never delays the current host event.
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
return flushed;
|
|
949
|
+
},
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
export async function outboxStatus(stateRoot) {
|
|
954
|
+
let names = [];
|
|
955
|
+
try {
|
|
956
|
+
names = await readdir(stateRoot);
|
|
957
|
+
} catch (error) {
|
|
958
|
+
if (error?.code === "ENOENT") return { pending: 0, sessions: 0, bytes: 0, oldestAt: undefined, losses: 0 };
|
|
959
|
+
throw error;
|
|
960
|
+
}
|
|
961
|
+
const result = { pending: 0, sessions: 0, bytes: 0, oldestAt: undefined, losses: 0 };
|
|
962
|
+
for (const name of names.filter((value) => value.endsWith(".json"))) {
|
|
963
|
+
const path = join(stateRoot, name);
|
|
964
|
+
try {
|
|
965
|
+
const state = await loadState(path);
|
|
966
|
+
const pending = Array.isArray(state?.pending) ? state.pending : [];
|
|
967
|
+
if (pending.length) result.bytes += Buffer.byteLength(JSON.stringify(pending));
|
|
968
|
+
if (pending.length) result.sessions += 1;
|
|
969
|
+
result.pending += pending.length;
|
|
970
|
+
if (state?.quarantined || state?.loss) result.losses += 1;
|
|
971
|
+
for (const entry of pending) {
|
|
972
|
+
const created = Number(entry.created_at || 0);
|
|
973
|
+
if (created && (!result.oldestAt || created < result.oldestAt)) result.oldestAt = created;
|
|
974
|
+
}
|
|
975
|
+
} catch (error) {
|
|
976
|
+
if (error?.code !== "ENOENT") result.losses += 1;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return result;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
export function agentHookStateRoot({ env = process.env, home = homedir(), stateRoot } = {}) {
|
|
983
|
+
return stateRoot || join(platformStateRoot({ env, home }), "mindbridge", "agent-hooks");
|
|
984
|
+
}
|