@orboto/mcp 0.211.3 → 0.212.1

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.
@@ -12,6 +12,7 @@ import { resolveToolset } from './toolset.js';
12
12
  import { OrbotoClient, preflightMcpSession } from './orboto-client.js';
13
13
  import { EventBridge } from './event-bridge.js';
14
14
  import { mcpInstanceToken } from './tools/shared.js';
15
+ import { releaseCapacityAtSessionEnd } from './tools/capacity-session.js';
15
16
  /** Default store backed by the api's `/system/mcp/sessions` endpoints. Each
16
17
  * call builds a short-lived OrbotoClient bound to the request's token so the
17
18
  * api scopes every read/write to that identity. */
@@ -278,6 +279,8 @@ export function createHttpServer({ baseUrl, sessionStore }) {
278
279
  }
279
280
  }
280
281
  void store.remove(token, sessionId).catch(() => { });
282
+ if (sessionId)
283
+ await releaseCapacityAtSessionEnd(existing?.client ?? new OrbotoClient({ baseUrl, apiKey: token }), mcpInstanceToken(undefined, { sessionId }));
281
284
  if (existing) {
282
285
  await existing.transport.handleRequest(req, res);
283
286
  return;
@@ -130,6 +130,7 @@ function fakeApi(opts = {}) {
130
130
  mcpUseGranted: opts.mcpUseGranted ?? true,
131
131
  userMcpEnabled: opts.userMcpEnabled ?? true,
132
132
  seenAuth: [],
133
+ capacityReleased: [],
133
134
  };
134
135
  const api = createNodeServer((req, res) => {
135
136
  const url = req.url ?? '';
@@ -154,6 +155,23 @@ function fakeApi(opts = {}) {
154
155
  res.end(JSON.stringify({ instructions: '' }));
155
156
  return;
156
157
  }
158
+ if (url.startsWith('/agents/session/unfinished')) {
159
+ res.writeHead(200, { 'content-type': 'application/json' });
160
+ res.end(JSON.stringify({ sessionId: 'sess-uuid', verdict: 'continue', reason: 'runnable', text: 'continue' }));
161
+ return;
162
+ }
163
+ if (url.startsWith('/capacity/claims?')) {
164
+ res.writeHead(200, { 'content-type': 'application/json' });
165
+ res.end(JSON.stringify({ items: [{ id: 'c-granted', resourceName: 'build-host:runner-1', state: 'granted' }, { id: 'c-queued', resourceName: 'unity:mac-1', state: 'queued' }], nextCursor: null }));
166
+ return;
167
+ }
168
+ const release = /^\/capacity\/claims\/([^/]+)\/release$/.exec(url);
169
+ if (release && req.method === 'POST') {
170
+ state.capacityReleased.push({ id: release[1], session: String(req.headers['x-orboto-agent-session'] ?? '') });
171
+ res.writeHead(200, { 'content-type': 'application/json' });
172
+ res.end(JSON.stringify({ id: release[1], state: 'released' }));
173
+ return;
174
+ }
157
175
  if (url.startsWith('/sse/mcp-events')) {
158
176
  res.writeHead(200, { 'content-type': 'text/event-stream' });
159
177
  res.end();
@@ -238,6 +256,21 @@ describe('ORB-1353 - persisted-session resilience (transport, fake api)', () =>
238
256
  await res.text();
239
257
  expect(store.resolve).toHaveBeenCalledWith('orb_alice', sid);
240
258
  });
259
+ it('ORB-2272 - the client ending its session (DELETE) releases the granted and the queued capacity claim of that session', async () => {
260
+ const store = fakeStore();
261
+ const fake = fakeApi();
262
+ const { base } = await startWithFakeApi(store.store, fake);
263
+ const initRes = await post(base, INIT_BODY, { authorization: 'Bearer orb_alice' });
264
+ const sid = initRes.headers.get('mcp-session-id');
265
+ await initRes.text();
266
+ const del = await fetch(`${base}/mcp`, { method: 'DELETE', headers: { authorization: 'Bearer orb_alice', 'mcp-session-id': sid } });
267
+ await del.text();
268
+ expect(del.status).toBeLessThan(300);
269
+ expect(fake.state.capacityReleased).toEqual([
270
+ { id: 'c-granted', session: `mcp-${sid}` },
271
+ { id: 'c-queued', session: `mcp-${sid}` },
272
+ ]);
273
+ });
241
274
  it('auto-adopts an unknown session id under a FRESH id when auth is valid (layer 2)', async () => {
242
275
  const store = fakeStore();
243
276
  const { base } = await startWithFakeApi(store.store);
package/dist/index.js CHANGED
@@ -58,6 +58,8 @@ async function main() {
58
58
  const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
59
59
  const stdio = new StdioServerTransport();
60
60
  await server.connect(stdio);
61
+ const { releaseCapacityOnStdinEnd } = await import('./tools/capacity-session.js');
62
+ const releaseCapacity = releaseCapacityOnStdinEnd(process.stdin, preflightClient, (await import('./tools/shared.js')).mcpProcessInstance());
61
63
  if (channel) {
62
64
  const { InboxChannel, digestMinutesFromEnv, cliOutdatedNotice, CLI_OUTDATED_NOTICE } = await import('./inbox-channel.js');
63
65
  const { mcpProcessInstance } = await import('./tools/shared.js');
@@ -81,7 +83,7 @@ async function main() {
81
83
  const cleanup = () => { clearInterval(beats); inbox.close(); };
82
84
  stdio.onclose = cleanup;
83
85
  const { endProbeSessionOnStdinEnd } = await import('./probe-session.js');
84
- endProbeSessionOnStdinEnd({ env: process.env, stdin: process.stdin, client: preflightClient, instanceToken, cleanup, exit: (code) => process.exit(code) });
86
+ endProbeSessionOnStdinEnd({ env: process.env, stdin: process.stdin, client: preflightClient, instanceToken, cleanup, exit: (code) => process.exit(code), settle: releaseCapacity });
85
87
  }
86
88
  return;
87
89
  }
@@ -45,6 +45,8 @@ afterEach(() => { vi.restoreAllMocks(); });
45
45
  * write instead of after it (measured curated = 39,679 chars / 11,022
46
46
  * tokens, from 39,461 / 10,961 - one boolean parameter, no new tool). The
47
47
  * verdicts themselves ride the response, not the manifest.
48
+ * ORB-2338 added no named tool; the transcript rides the escape hatch and
49
+ * the resource.
48
50
  *
49
51
  * @see ORB-1805, ORB-1669, ORB-1910
50
52
  */
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { createHash, randomBytes } from 'node:crypto';
5
5
  import { createServer } from 'node:http';
6
- import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
8
8
  import { dirname, join } from 'node:path';
9
9
  import { VERSION } from './version.js';
@@ -83,7 +83,7 @@ export function buildAuthorizeUrl(authorizationEndpoint, params) {
83
83
  u.searchParams.set('state', params.state);
84
84
  return u.toString();
85
85
  }
86
- function parseTokenResponse(json) {
86
+ function parseTokenResponse(json, now = Date.now) {
87
87
  const t = json;
88
88
  if (!t.access_token)
89
89
  throw new Error('OAuth token response missing access_token');
@@ -91,7 +91,7 @@ function parseTokenResponse(json) {
91
91
  return {
92
92
  accessToken: t.access_token,
93
93
  refreshToken: t.refresh_token ?? null,
94
- expiresAt: Date.now() + expiresInMs,
94
+ expiresAt: now() + expiresInMs,
95
95
  scope: t.scope ?? BOOTSTRAP_SCOPE,
96
96
  };
97
97
  }
@@ -113,7 +113,7 @@ export async function exchangeAuthCode(tokenEndpoint, params, fetchImpl = fetch)
113
113
  }
114
114
  return parseTokenResponse(await res.json());
115
115
  }
116
- export async function refreshTokens(tokenEndpoint, params, fetchImpl = fetch) {
116
+ export async function refreshTokens(tokenEndpoint, params, fetchImpl = fetch, now = Date.now) {
117
117
  const res = await fetchImpl(tokenEndpoint, {
118
118
  method: 'POST',
119
119
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
@@ -127,7 +127,7 @@ export async function refreshTokens(tokenEndpoint, params, fetchImpl = fetch) {
127
127
  const body = await res.text().catch(() => '');
128
128
  throw new Error(`OAuth token refresh failed: ${res.status} ${body}`);
129
129
  }
130
- return parseTokenResponse(await res.json());
130
+ return parseTokenResponse(await res.json(), now);
131
131
  }
132
132
  /** Cache file path. Honours ORBOTO_MCP_TOKEN_CACHE for tests / custom homes. */
133
133
  export function tokenCachePath() {
@@ -149,37 +149,91 @@ export function loadCachedGrant(origin, path = tokenCachePath()) {
149
149
  const file = readCacheFile(path);
150
150
  return file[origin] ?? null;
151
151
  }
152
- export function saveCachedGrant(origin, grant, path = tokenCachePath()) {
153
- const file = readCacheFile(path);
154
- file[origin] = grant;
152
+ function writeCacheFile(path, file) {
155
153
  mkdirSync(dirname(path), { recursive: true });
156
- writeFileSync(path, JSON.stringify(file, null, 2), { mode: 0o600 });
154
+ const tmp = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
155
+ writeFileSync(tmp, JSON.stringify(file, null, 2), { mode: 0o600 });
157
156
  try {
158
- chmodSync(path, 0o600);
157
+ chmodSync(tmp, 0o600);
159
158
  }
160
159
  catch { /* best-effort on platforms without chmod */ }
160
+ renameSync(tmp, path);
161
+ }
162
+ export function saveCachedGrant(origin, grant, path = tokenCachePath()) {
163
+ const file = readCacheFile(path);
164
+ file[origin] = grant;
165
+ writeCacheFile(path, file);
161
166
  }
162
167
  export function clearCachedGrant(origin, path = tokenCachePath()) {
163
168
  const file = readCacheFile(path);
164
169
  if (!(origin in file))
165
170
  return;
166
171
  delete file[origin];
172
+ writeCacheFile(path, file);
173
+ }
174
+ export const CACHE_LOCK_STALE_MS = 60_000;
175
+ export const CACHE_LOCK_TIMEOUT_MS = 75_000;
176
+ /** Runs `fn` while holding `<cache>.lock`, so processes sharing one cache never present the same refresh token twice. */
177
+ export async function withCacheLock(path, fn, opts = {}) {
178
+ const lockPath = `${path}.lock`;
179
+ const staleMs = opts.staleMs ?? CACHE_LOCK_STALE_MS;
180
+ const deadline = Date.now() + (opts.timeoutMs ?? CACHE_LOCK_TIMEOUT_MS);
167
181
  mkdirSync(dirname(path), { recursive: true });
168
- writeFileSync(path, JSON.stringify(file, null, 2), { mode: 0o600 });
182
+ for (;;) {
183
+ try {
184
+ const fd = openSync(lockPath, 'wx', 0o600);
185
+ try {
186
+ writeSync(fd, String(process.pid));
187
+ }
188
+ finally {
189
+ closeSync(fd);
190
+ }
191
+ break;
192
+ }
193
+ catch (e) {
194
+ if (e.code !== 'EEXIST')
195
+ throw e;
196
+ try {
197
+ if (Date.now() - statSync(lockPath).mtimeMs > staleMs) {
198
+ unlinkSync(lockPath);
199
+ continue;
200
+ }
201
+ }
202
+ catch {
203
+ continue;
204
+ }
205
+ if (Date.now() > deadline)
206
+ throw new Error('Another orboto-mcp process holds the OAuth cache lock.');
207
+ await new Promise((r) => setTimeout(r, 25));
208
+ }
209
+ }
169
210
  try {
170
- chmodSync(path, 0o600);
211
+ return await fn();
212
+ }
213
+ finally {
214
+ try {
215
+ unlinkSync(lockPath);
216
+ }
217
+ catch { /* already reclaimed as stale */ }
171
218
  }
172
- catch { /* best-effort */ }
173
219
  }
174
220
  /**
175
221
  * Build a token provider around an initial token set + a refresh closure.
176
- * Kept pure (no I/O of its own beyond the injected refresh fn) so the
177
- * refresh-on-expiry logic unit tests deterministically with a fake clock.
222
+ * With a store, a refresh first adopts a newer token set another process
223
+ * persisted and rotates only when no live access token is on disk.
178
224
  */
179
- export function createTokenProvider(initial, refresh, onRefreshed, now = Date.now) {
225
+ export function createTokenProvider(initial, refresh, onRefreshed, now = Date.now, store) {
180
226
  let current = initial;
181
227
  let inflight = null;
182
- async function doRefresh() {
228
+ const live = (t) => now() < t.expiresAt - EXPIRY_SKEW_MS;
229
+ async function rotate(forced) {
230
+ const seen = current.accessToken;
231
+ const stored = store?.read();
232
+ if (stored && stored.refreshToken && stored.refreshToken !== current.refreshToken) {
233
+ current = stored;
234
+ if (live(stored) && (!forced || stored.accessToken !== seen))
235
+ return current.accessToken;
236
+ }
183
237
  if (!current.refreshToken) {
184
238
  throw new Error('OAuth session expired and no refresh token is available - reconnect the client.');
185
239
  }
@@ -188,17 +242,20 @@ export function createTokenProvider(initial, refresh, onRefreshed, now = Date.no
188
242
  onRefreshed?.(current);
189
243
  return current.accessToken;
190
244
  }
245
+ function doRefresh(forced) {
246
+ return store ? store.withLock(() => rotate(forced)) : rotate(forced);
247
+ }
191
248
  return {
192
249
  async getAccessToken() {
193
- if (now() < current.expiresAt - EXPIRY_SKEW_MS)
250
+ if (live(current))
194
251
  return current.accessToken;
195
252
  if (!inflight)
196
- inflight = doRefresh().finally(() => { inflight = null; });
253
+ inflight = doRefresh(false).finally(() => { inflight = null; });
197
254
  return inflight;
198
255
  },
199
256
  async forceRefresh() {
200
257
  if (!inflight)
201
- inflight = doRefresh().finally(() => { inflight = null; });
258
+ inflight = doRefresh(true).finally(() => { inflight = null; });
202
259
  return inflight;
203
260
  },
204
261
  };
@@ -324,43 +381,70 @@ export async function runLoopbackAuthorization(opts) {
324
381
  }
325
382
  /**
326
383
  * Resolve an OAuth token provider for the stdio proxy. Order:
327
- * 1. A cached grant whose refresh token still works -> silent refresh.
328
- * 2. Otherwise run the interactive browser-assisted loopback flow once.
329
- * Either way the resulting grant is persisted (0600) so the next boot is
330
- * silent. The returned provider auto-refreshes and re-persists on rotation.
384
+ * 1. A cached grant with a live access token -> used as is, nothing rotates.
385
+ * 2. A cached grant whose refresh token still works -> silent refresh under the cache lock.
386
+ * 3. Otherwise run the interactive browser-assisted loopback flow once.
387
+ * The returned provider re-reads the cache under the lock before every refresh.
331
388
  */
332
389
  export async function bootstrapOAuth(opts) {
333
390
  const fetchImpl = opts.fetchImpl ?? fetch;
334
391
  const log = opts.log ?? ((m) => process.stderr.write(`${m}\n`));
335
392
  const origin = deriveOrigin(opts.apiBaseUrl);
336
393
  const cachePath = opts.cachePath ?? tokenCachePath();
337
- const persist = (clientId, tokenEndpoint, tokens) => {
338
- if (tokens.refreshToken) {
394
+ const now = opts.now ?? Date.now;
395
+ const providerFor = (clientId, tokenEndpoint, initial) => {
396
+ const store = {
397
+ withLock: (fn) => withCacheLock(cachePath, fn),
398
+ read: () => {
399
+ const g = loadCachedGrant(origin, cachePath);
400
+ if (!g || g.clientId !== clientId)
401
+ return null;
402
+ return { accessToken: g.accessToken ?? '', refreshToken: g.refreshToken, expiresAt: g.expiresAt ?? 0, scope: g.scope };
403
+ },
404
+ };
405
+ const persist = (tokens) => {
406
+ if (!tokens.refreshToken)
407
+ return;
339
408
  saveCachedGrant(origin, {
340
409
  clientId, tokenEndpoint, refreshToken: tokens.refreshToken, scope: tokens.scope,
410
+ accessToken: tokens.accessToken, expiresAt: tokens.expiresAt,
341
411
  }, cachePath);
342
- }
412
+ };
413
+ return createTokenProvider(initial, (rt) => refreshTokens(tokenEndpoint, { clientId, refreshToken: rt }, fetchImpl, now), persist, now, store);
343
414
  };
344
415
  const cached = loadCachedGrant(origin, cachePath);
345
416
  if (cached) {
417
+ const provider = providerFor(cached.clientId, cached.tokenEndpoint, {
418
+ accessToken: cached.accessToken ?? '', refreshToken: cached.refreshToken,
419
+ expiresAt: cached.expiresAt ?? 0, scope: cached.scope,
420
+ });
346
421
  try {
347
- const tokens = await refreshTokens(cached.tokenEndpoint, {
348
- clientId: cached.clientId, refreshToken: cached.refreshToken,
349
- }, fetchImpl);
350
- persist(cached.clientId, cached.tokenEndpoint, tokens);
422
+ await provider.getAccessToken();
351
423
  log('[orboto-mcp] reconnected via cached OAuth session (no browser needed)');
352
- return createTokenProvider(tokens, (rt) => refreshTokens(cached.tokenEndpoint, { clientId: cached.clientId, refreshToken: rt }, fetchImpl), (next) => persist(cached.clientId, cached.tokenEndpoint, next));
424
+ return provider;
353
425
  }
354
426
  catch (e) {
355
427
  log(`[orboto-mcp] cached OAuth session no longer valid (${e.message}); re-authorizing`);
356
- clearCachedGrant(origin, cachePath);
428
+ await withCacheLock(cachePath, async () => {
429
+ const onDisk = loadCachedGrant(origin, cachePath);
430
+ const renewedElsewhere = onDisk?.accessToken && (onDisk.expiresAt ?? 0) - EXPIRY_SKEW_MS > now();
431
+ if (onDisk && !renewedElsewhere)
432
+ clearCachedGrant(origin, cachePath);
433
+ });
357
434
  }
358
435
  }
359
436
  const meta = await discoverAuthServer(origin, fetchImpl);
360
437
  const { tokens, clientId } = await runLoopbackAuthorization({
361
438
  meta, log, openBrowser: opts.openBrowser, fetchImpl,
362
439
  });
363
- persist(clientId, meta.token_endpoint, tokens);
440
+ if (tokens.refreshToken) {
441
+ await withCacheLock(cachePath, async () => {
442
+ saveCachedGrant(origin, {
443
+ clientId, tokenEndpoint: meta.token_endpoint, refreshToken: tokens.refreshToken, scope: tokens.scope,
444
+ accessToken: tokens.accessToken, expiresAt: tokens.expiresAt,
445
+ }, cachePath);
446
+ });
447
+ }
364
448
  log('[orboto-mcp] OAuth authorization complete');
365
- return createTokenProvider(tokens, (rt) => refreshTokens(meta.token_endpoint, { clientId, refreshToken: rt }, fetchImpl), (next) => persist(clientId, meta.token_endpoint, next));
449
+ return providerFor(clientId, meta.token_endpoint, tokens);
366
450
  }
@@ -5,11 +5,11 @@
5
5
  * its testable pieces (PKCE, authorize-url, request builders).
6
6
  */
7
7
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
- import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
11
  import { createHash } from 'node:crypto';
12
- import { generatePkce, buildAuthorizeUrl, deriveOrigin, discoverAuthServer, registerLoopbackClient, exchangeAuthCode, refreshTokens, loadCachedGrant, saveCachedGrant, clearCachedGrant, createTokenProvider, bootstrapOAuth, EXPIRY_SKEW_MS, } from './oauth-bootstrap.js';
12
+ import { generatePkce, buildAuthorizeUrl, deriveOrigin, discoverAuthServer, registerLoopbackClient, exchangeAuthCode, refreshTokens, loadCachedGrant, saveCachedGrant, clearCachedGrant, createTokenProvider, bootstrapOAuth, withCacheLock, EXPIRY_SKEW_MS, } from './oauth-bootstrap.js';
13
13
  describe('PKCE', () => {
14
14
  it('produces an S256 challenge that verifies against the verifier', () => {
15
15
  const { verifier, challenge, method } = generatePkce();
@@ -198,3 +198,119 @@ describe('bootstrapOAuth cached-grant path', () => {
198
198
  expect(loadCachedGrant('https://x.test', path)).toBeNull();
199
199
  });
200
200
  });
201
+ describe('createTokenProvider with a shared store', () => {
202
+ const expired = { accessToken: 'at0', refreshToken: 'rt0', expiresAt: 0, scope: 'mcp' };
203
+ const storeOf = (read) => ({ withLock: (fn) => fn(), read });
204
+ it('adopts a newer live token set persisted after construction instead of rotating', async () => {
205
+ const refresh = vi.fn();
206
+ const p = createTokenProvider(expired, refresh, undefined, () => 1_000, storeOf(() => ({
207
+ accessToken: 'at5', refreshToken: 'rt5', expiresAt: 10_000_000, scope: 'mcp',
208
+ })));
209
+ expect(await p.getAccessToken()).toBe('at5');
210
+ expect(refresh).not.toHaveBeenCalled();
211
+ });
212
+ it('continues from the newer refresh token when the stored access token is expired too', async () => {
213
+ const refresh = vi.fn().mockResolvedValue({ accessToken: 'at6', refreshToken: 'rt6', expiresAt: 10_000_000, scope: 'mcp' });
214
+ const p = createTokenProvider(expired, refresh, undefined, () => 1_000, storeOf(() => ({
215
+ accessToken: 'at5', refreshToken: 'rt5', expiresAt: 0, scope: 'mcp',
216
+ })));
217
+ expect(await p.getAccessToken()).toBe('at6');
218
+ expect(refresh).toHaveBeenCalledWith('rt5');
219
+ });
220
+ it('forceRefresh with the same stored token rotates it, with a different live one adopts it', async () => {
221
+ const live = { accessToken: 'at1', refreshToken: 'rt1', expiresAt: 10_000_000, scope: 'mcp' };
222
+ const refresh = vi.fn().mockResolvedValue({ accessToken: 'at2', refreshToken: 'rt2', expiresAt: 10_000_000, scope: 'mcp' });
223
+ let onDisk = live;
224
+ const p = createTokenProvider(live, refresh, (n) => { onDisk = n; }, () => 1_000, storeOf(() => onDisk));
225
+ expect(await p.forceRefresh()).toBe('at2');
226
+ expect(refresh).toHaveBeenCalledWith('rt1');
227
+ onDisk = { accessToken: 'at9', refreshToken: 'rt9', expiresAt: 10_000_000, scope: 'mcp' };
228
+ expect(await p.forceRefresh()).toBe('at9');
229
+ expect(refresh).toHaveBeenCalledOnce();
230
+ });
231
+ });
232
+ describe('bootstrapOAuth with two processes on one cache', () => {
233
+ let dir;
234
+ let path;
235
+ beforeEach(() => {
236
+ dir = mkdtempSync(join(tmpdir(), 'orboto-oauth-'));
237
+ path = join(dir, 'cache.json');
238
+ });
239
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
240
+ function rotatingServer() {
241
+ let live = 'rt0';
242
+ let n = 0;
243
+ const sent = [];
244
+ const fetchImpl = vi.fn(async (_url, init) => {
245
+ const body = JSON.parse(String(init?.body));
246
+ sent.push(body.refresh_token);
247
+ await new Promise((r) => setTimeout(r, 30));
248
+ if (body.refresh_token !== live)
249
+ return new Response('{"error":"invalid_grant"}', { status: 400 });
250
+ n += 1;
251
+ live = `rt${n}`;
252
+ return jsonResponse({ access_token: `at${n}`, refresh_token: live, expires_in: 3600, scope: 'mcp offline_access' });
253
+ });
254
+ return { fetchImpl, sent };
255
+ }
256
+ const seed = () => saveCachedGrant('https://x.test', {
257
+ clientId: 'cid', refreshToken: 'rt0', scope: 'mcp offline_access', tokenEndpoint: 'https://x.test/oauth/token',
258
+ }, path);
259
+ it('two concurrent boots on an expired cache rotate once and share the token', async () => {
260
+ seed();
261
+ const { fetchImpl, sent } = rotatingServer();
262
+ const boot = () => bootstrapOAuth({ apiBaseUrl: 'https://x.test/api', fetchImpl, openBrowser: vi.fn(), cachePath: path, log: () => { } });
263
+ const [a, b] = await Promise.all([boot(), boot()]);
264
+ expect(sent).toEqual(['rt0']);
265
+ expect(await a.getAccessToken()).toBe('at1');
266
+ expect(await b.getAccessToken()).toBe('at1');
267
+ expect(loadCachedGrant('https://x.test', path)).toMatchObject({ refreshToken: 'rt1', accessToken: 'at1' });
268
+ });
269
+ it('a boot with a live cached access token rotates nothing, and the later refresh sends the first process rotated token', async () => {
270
+ seed();
271
+ const { fetchImpl, sent } = rotatingServer();
272
+ let offset = 0;
273
+ const now = () => Date.now() + offset;
274
+ const boot = () => bootstrapOAuth({ apiBaseUrl: 'https://x.test/api', fetchImpl, openBrowser: vi.fn(), cachePath: path, log: () => { }, now });
275
+ const a = await boot();
276
+ const b = await boot();
277
+ expect(sent).toEqual(['rt0']);
278
+ offset = 2 * 3600_000;
279
+ expect(await a.getAccessToken()).toBe('at2');
280
+ expect(await b.getAccessToken()).toBe('at2');
281
+ expect(sent).toEqual(['rt0', 'rt1']);
282
+ offset = 4 * 3600_000;
283
+ expect(await b.forceRefresh()).toBe('at3');
284
+ expect(sent).toEqual(['rt0', 'rt1', 'rt2']);
285
+ expect(await a.getAccessToken()).toBe('at3');
286
+ expect(sent).toHaveLength(3);
287
+ });
288
+ });
289
+ describe('withCacheLock', () => {
290
+ let dir;
291
+ let path;
292
+ beforeEach(() => {
293
+ dir = mkdtempSync(join(tmpdir(), 'orboto-oauth-'));
294
+ path = join(dir, 'cache.json');
295
+ });
296
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
297
+ it('serialises holders and removes the lock file afterwards', async () => {
298
+ const order = [];
299
+ const hold = (tag) => withCacheLock(path, async () => {
300
+ order.push(`${tag}+`);
301
+ await new Promise((r) => setTimeout(r, 20));
302
+ order.push(`${tag}-`);
303
+ });
304
+ await Promise.all([hold('a'), hold('b')]);
305
+ expect(order.join(' ')).toMatch(/^(a\+ a- b\+ b-|b\+ b- a\+ a-)$/);
306
+ expect(existsSync(`${path}.lock`)).toBe(false);
307
+ });
308
+ it('reclaims a stale lock and times out on a held one', async () => {
309
+ writeFileSync(`${path}.lock`, '1');
310
+ const old = new Date(Date.now() - 120_000);
311
+ utimesSync(`${path}.lock`, old, old);
312
+ await expect(withCacheLock(path, async () => 'ok')).resolves.toBe('ok');
313
+ writeFileSync(`${path}.lock`, '1');
314
+ await expect(withCacheLock(path, async () => 'x', { timeoutMs: 60 })).rejects.toThrow(/holds the OAuth cache lock/);
315
+ });
316
+ });
@@ -10,7 +10,9 @@ export function endProbeSessionOnStdinEnd(opts) {
10
10
  return;
11
11
  ended = true;
12
12
  opts.cleanup();
13
- void opts.client.post('/v1/agent/end-session', {}, { instanceToken: opts.instanceToken })
13
+ void (opts.settle ?? (() => Promise.resolve()))()
14
+ .catch(() => undefined)
15
+ .then(() => opts.client.post('/v1/agent/end-session', {}, { instanceToken: opts.instanceToken }))
14
16
  .catch(() => undefined)
15
17
  .finally(() => opts.exit(0));
16
18
  };
@@ -3,13 +3,13 @@ import { EventEmitter } from 'node:events';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { describe, expect, it, vi } from 'vitest';
5
5
  import { endProbeSessionOnStdinEnd, PROBE_SESSION_ENV } from './probe-session.js';
6
- function harness(env) {
6
+ function harness(env, settle) {
7
7
  const stdin = new EventEmitter();
8
8
  const calls = [];
9
9
  const client = { post: async (path, body, opts) => { calls.push({ path, body, instanceToken: opts?.instanceToken }); return undefined; } };
10
10
  const cleanup = vi.fn();
11
11
  const exit = vi.fn();
12
- const armed = endProbeSessionOnStdinEnd({ env, stdin, client, instanceToken: 'agent-0123456789ab', cleanup, exit });
12
+ const armed = endProbeSessionOnStdinEnd({ env, stdin, client, instanceToken: 'agent-0123456789ab', cleanup, exit, ...(settle ? { settle } : {}) });
13
13
  return { stdin, calls, cleanup, exit, armed };
14
14
  }
15
15
  describe('ORB-2263 - probe session end', () => {
@@ -26,6 +26,15 @@ describe('ORB-2263 - probe session end', () => {
26
26
  expect(h.cleanup).toHaveBeenCalledTimes(1);
27
27
  expect(h.calls).toEqual([{ path: '/v1/agent/end-session', body: {}, instanceToken: 'agent-0123456789ab' }]);
28
28
  });
29
+ it('ORB-2272 - releases the session\'s capacity claims before the session row ends, even when that release fails', async () => {
30
+ const order = [];
31
+ const h = harness({ [PROBE_SESSION_ENV]: '1' }, async () => { order.push('capacity'); throw new Error('api down'); });
32
+ const post = h.calls;
33
+ h.stdin.emit('end');
34
+ await vi.waitFor(() => expect(h.exit).toHaveBeenCalledWith(0));
35
+ expect(order).toEqual(['capacity']);
36
+ expect(post.map((c) => c.path)).toEqual(['/v1/agent/end-session']);
37
+ });
29
38
  it('leaves a real session alone so a resume keeps its row', () => {
30
39
  const h = harness({});
31
40
  expect(h.armed).toBe(false);
package/dist/resources.js CHANGED
@@ -5,6 +5,7 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
5
5
  import { OrbotoApiError } from './orboto-client.js';
6
6
  import { loadRequiredRules } from './required-rules.js';
7
7
  import { resolveProjectByKey, resolveTicketByKey } from './tools/shared.js';
8
+ import { readTranscriptResource } from './tools/live-transcript.js';
8
9
  export function registerOrbotoResources(server, client) {
9
10
  server.registerResource('rules', new ResourceTemplate('orboto://rules', { list: undefined }), {
10
11
  title: 'Workspace agent rules (complete)',
@@ -74,6 +75,13 @@ export function registerOrbotoResources(server, client) {
74
75
  }],
75
76
  };
76
77
  });
78
+ server.registerResource('live-transcript', new ResourceTemplate('orboto://live/{sessionId}/transcript', { list: undefined }), {
79
+ title: 'Live session transcript',
80
+ description: 'ORB-2338 - the newest lines of a live agent session you may see: prompts, assistant markdown, tool groups, turn ends, usage and context, masked. Cut to 4,000 characters with an explicit note; the rest is paged with `orboto_api_call get /agents/live-sessions/<id>/transcript?afterSeq=N`.',
81
+ mimeType: 'text/markdown',
82
+ }, async (uri, vars) => ({
83
+ contents: [{ uri: uri.href, mimeType: 'text/markdown', text: await readTranscriptResource(client, String(vars.sessionId)) }],
84
+ }));
77
85
  server.registerResource('search', new ResourceTemplate('orboto://search/{query}', { list: undefined }), {
78
86
  title: 'orboto search',
79
87
  description: 'Full-text search across tickets, comments, docs - visibility-filtered.',
package/dist/server.js CHANGED
@@ -28,6 +28,7 @@ import { agentBroadcastToolConfig, makeAgentBroadcastHandler, } from './tools/ag
28
28
  import { agentMessageWorkToolConfig, makeAgentMessageWorkHandler } from './tools/agent-message-work.js';
29
29
  import { agentMessagesToolConfig, makeAgentMessagesHandler } from './tools/agent-messages.js';
30
30
  import { agentWakesToolConfig, makeAgentWakesHandler } from './tools/agent-wakes.js';
31
+ import { agentWhoToolConfig, makeAgentWhoHandler } from './tools/agent-who.js';
31
32
  import { sessionCheckToolConfig, makeSessionCheckHandler } from './tools/session-check.js';
32
33
  import { knowledgeAskToolConfig, makeKnowledgeAskHandler, knowledgeAddToolConfig, makeKnowledgeAddHandler } from './tools/knowledge.js';
33
34
  import { listProjectsToolConfig, makeListProjectsHandler } from './tools/list-projects.js';
@@ -210,6 +211,7 @@ export async function buildOrbotoMcpServer(opts) {
210
211
  reg('orboto_delete_agent_instruction', deleteAgentInstructionToolConfig, makeDeleteAgentInstructionHandler(client));
211
212
  reg('orboto_agent_heartbeat', agentHeartbeatToolConfig, makeAgentHeartbeatHandler(client));
212
213
  reg('orboto_agent_presence', agentPresenceToolConfig, makeAgentPresenceHandler(client));
214
+ reg('orboto_who', agentWhoToolConfig, makeAgentWhoHandler(client));
213
215
  reg('orboto_agent_notify', agentNotifyToolConfig, makeAgentNotifyHandler(client));
214
216
  reg('orboto_message_work', agentMessageWorkToolConfig, makeAgentMessageWorkHandler(client));
215
217
  reg('orboto_messages', agentMessagesToolConfig, makeAgentMessagesHandler(client));
@@ -0,0 +1,30 @@
1
+ /** ORB-2272, ORB-2274 - an orboto_api_call on a tail route is logged under the tool name it stands in for; leaf module. */
2
+ const SEG = '[^/]+';
3
+ export const TAIL_LABELS = [
4
+ { method: 'GET', path: new RegExp(`^/capacity/resources(/${SEG}(/queue)?)?$`), label: 'orboto_capacity_list' },
5
+ { method: 'GET', path: new RegExp(`^/capacity/claims(/${SEG})?$`), label: 'orboto_capacity_list' },
6
+ { method: 'POST', path: /^\/capacity\/claims$/, label: 'orboto_capacity_claim' },
7
+ { method: 'POST', path: new RegExp(`^/capacity/claims/${SEG}/renew$`), label: 'orboto_capacity_renew' },
8
+ { method: 'POST', path: new RegExp(`^/capacity/claims/${SEG}/release$`), label: 'orboto_capacity_release' },
9
+ { method: 'GET', path: /^\/capacity\/windows$/, label: 'orboto_capacity_windows' },
10
+ { method: 'GET', path: /^\/capacity\/metrics$/, label: 'orboto_capacity_stats' },
11
+ { method: 'GET', path: new RegExp(`^/capacity/resources/${SEG}/plan$`), label: 'orboto_capacity_plan' },
12
+ { method: 'POST', path: new RegExp(`^/capacity/resources/${SEG}/plan$`), label: 'orboto_capacity_plan' },
13
+ { method: 'POST', path: /^\/capacity\/seeds\/nightly-unity$/, label: 'orboto_capacity_seed' },
14
+ ];
15
+ /** The metrics label of one tool call: the stand-in tool name for a labelled api_call, else the tool name itself. */
16
+ export function metricsToolName(toolName, args) {
17
+ if (toolName !== 'orboto_api_call' || !args || typeof args !== 'object')
18
+ return toolName;
19
+ const { method, path } = args;
20
+ if (typeof method !== 'string' || typeof path !== 'string')
21
+ return toolName;
22
+ const bare = path.split('?')[0].replace(/\/+$/, '');
23
+ const hit = TAIL_LABELS.find((l) => l.method === method.toUpperCase() && l.path.test(bare));
24
+ return hit ? hit.label : toolName;
25
+ }
26
+ /** The inner HTTP status of an api_call envelope, so a labelled call that the API refused counts as a failure. */
27
+ export function envelopeStatus(structured) {
28
+ const status = structured?.status;
29
+ return typeof status === 'number' ? status : undefined;
30
+ }
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * ORB-1805 - the schema diet must never cost a PARAMETER.
3
3
  *
4
- * ORB-2157 - one reviewed baseline edit: `orboto_help.tool` is no longer
5
- * required, because the tool now also answers `topic` ("connect" returns the
6
- * setup script). The parameter itself is untouched and still the only way to
7
- * ask for a tool's guidance; a call with neither says which topics exist.
4
+ * Reviewed baseline edits: ORB-2157 made `orboto_help.tool` optional (the
5
+ * tool also answers `topic`; a call with neither lists the topics); ORB-2264
6
+ * made `orboto_agent_notify.targetEmail` optional (`to` names the recipient
7
+ * too, a role address like integrator@KEY reaches several accounts; the
8
+ * parameter stays and narrows a role address to one account).
8
9
  */
9
10
  import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
10
11
  import { readFileSync } from 'node:fs';