open-managed-agents 0.1.2 → 0.2.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.
Files changed (28) hide show
  1. package/dist/scripts/alpha-smoke.mjs +40 -2
  2. package/dist/scripts/oma-doctor.js +1 -1
  3. package/dist/scripts/oma-models.js +20 -16
  4. package/dist/src/control-plane/app.js +75 -4
  5. package/dist/src/control-plane/events/service.js +82 -8
  6. package/dist/src/control-plane/models/catalog.js +29 -13
  7. package/dist/src/control-plane/models/credential-store.js +203 -0
  8. package/dist/src/control-plane/sessions/pi/mcp/client.js +5 -1
  9. package/dist/src/control-plane/sessions/pi/runner.js +59 -32
  10. package/dist/src/control-plane/sessions/pi/sandbox/container-engine.js +32 -0
  11. package/dist/src/control-plane/sessions/pi/sandbox/docker-egress.js +8 -0
  12. package/dist/src/control-plane/sessions/pi/sandbox/docker.js +20 -5
  13. package/dist/src/control-plane/sessions/pi/sandbox/microsandbox.js +18 -3
  14. package/dist/src/control-plane/sessions/pi/sandbox/provider.js +16 -9
  15. package/dist/src/control-plane/sessions/service.js +5 -0
  16. package/dist/src/control-plane/vaults/console-mcp-oauth.js +545 -0
  17. package/dist/src/control-plane/vaults/service.js +4 -1
  18. package/dist/src/control-plane/vaults/store.js +8 -2
  19. package/dist/src/main.js +1 -1
  20. package/dist/ui/managed-agents-console/README.md +22 -15
  21. package/dist/ui/managed-agents-console/docs/vaults.md +16 -2
  22. package/dist/ui/managed-agents-console/index.html +4 -0
  23. package/dist/ui/managed-agents-console/src/api.js +40 -0
  24. package/dist/ui/managed-agents-console/src/console.css +4 -1
  25. package/dist/ui/managed-agents-console/src/forms.jsx +79 -16
  26. package/dist/ui/managed-agents-console/src/session-vaults-data.js +40 -0
  27. package/dist/ui/managed-agents-console/src/vaults.jsx +104 -9
  28. package/package.json +7 -7
@@ -226,9 +226,17 @@ async function main() {
226
226
  if (state.fixture !== undefined) ok("Observed exact custom provider/model routing across both model requests");
227
227
 
228
228
  step("Best-effort cleanup");
229
- await bestEffort(() => requestJson(baseUrl, apiKey, `/v1/sessions/${session.id}`, { method: "DELETE" }));
229
+ let deleteError;
230
+ try {
231
+ await requestJson(baseUrl, apiKey, `/v1/sessions/${session.id}`, { method: "DELETE" });
232
+ } catch (error) {
233
+ deleteError = error;
234
+ }
230
235
  await bestEffort(() => requestJson(baseUrl, apiKey, `/v1/agents/${agent.id}/archive`, { method: "POST" }));
231
236
  if (egressSmoke) {
237
+ // The egress smoke asserts teardown, so a failed delete is the first thing
238
+ // to know when resources remain; elsewhere cleanup stays best-effort.
239
+ if (deleteError !== undefined) fail(`Session delete failed: ${deleteError.message}`);
232
240
  await assertNoSessionDockerResources(session.id);
233
241
  ok("No session sandbox, sidecar, or internal network remained after delete");
234
242
  }
@@ -360,6 +368,34 @@ async function startTemporaryOma(sandboxProvider, model, localFixtureBaseUrl, eg
360
368
  throw new Error(`Timed out waiting for OMA startup logs.\n${logs.trim()}`);
361
369
  }
362
370
 
371
+ // Which leftover is it: the sandbox or the egress sidecar, and was it still
372
+ // running or being created? Distinguishes "teardown never ran" from
373
+ // "something was created after delete".
374
+ function describeSessionDockerResources(sessionId) {
375
+ const filter = `label=open-managed-agents.session-id=${sessionId}`;
376
+ const ids = (resource) => {
377
+ const args = resource === "container" ? ["ps", "-aq", "--filter", filter] : ["network", "ls", "-q", "--filter", filter];
378
+ return spawnSync("docker", args, { encoding: "utf8" }).stdout.split("\n").filter(Boolean);
379
+ };
380
+ const inspect = (args) => {
381
+ const result = spawnSync("docker", args, { encoding: "utf8" });
382
+ return result.status === 0 ? result.stdout.trim() : `inspect failed: ${(result.stderr || "").trim()}`;
383
+ };
384
+ const containerFormat = [
385
+ '{{if index .Config.Labels "open-managed-agents.egress-sidecar"}}egress-sidecar{{else}}sandbox{{end}}',
386
+ "name={{.Name}}",
387
+ "status={{.State.Status}}",
388
+ "created={{.Created}}",
389
+ "started={{.State.StartedAt}}",
390
+ "finished={{.State.FinishedAt}}",
391
+ ].join(" ");
392
+ const lines = [
393
+ ...ids("container").map((id) => ` container ${id}: ${inspect(["inspect", "--format", containerFormat, id])}`),
394
+ ...ids("network").map((id) => ` network ${id}: ${inspect(["network", "inspect", "--format", "name={{.Name}} created={{.Created}}", id])}`),
395
+ ];
396
+ return `Leftover resources (checked at ${new Date().toISOString()}):\n${lines.join("\n")}`;
397
+ }
398
+
363
399
  async function assertNoSessionDockerResources(sessionId) {
364
400
  const filters = ["container", "network"];
365
401
  const deadline = Date.now() + 10_000;
@@ -375,7 +411,9 @@ async function assertNoSessionDockerResources(sessionId) {
375
411
  }
376
412
  if (remaining.length === 0) return;
377
413
  if (Date.now() >= deadline) {
378
- throw new Error(`Docker resources remain for ${sessionId}: ${remaining.join("; ")}`);
414
+ throw new Error(
415
+ `Docker resources remain for ${sessionId}: ${remaining.join("; ")}\n${describeSessionDockerResources(sessionId)}`,
416
+ );
379
417
  }
380
418
  await delay(100);
381
419
  }
@@ -33,7 +33,7 @@ export async function inspectOma(options, runtime = { env: process.env }) {
33
33
  throw new Error("Pi configuration paths are not safe to read");
34
34
  }
35
35
  const authData = readAuthSnapshot(deployment.authPath);
36
- const catalog = createReadOnlyPiModelCatalog({
36
+ const catalog = await createReadOnlyPiModelCatalog({
37
37
  allowedProviders: deployment.allowedProviders,
38
38
  defaultModel: deployment.defaultModel,
39
39
  authPath: deployment.authPath,
@@ -25,20 +25,20 @@ export async function runModelsCli(argv, runtime) {
25
25
  try {
26
26
  switch (command) {
27
27
  case "providers-status":
28
- return ok(providersStatus(loadCatalog(runtime.env)));
28
+ return ok(providersStatus(await loadCatalog(runtime.env)));
29
29
  case "models-list": {
30
30
  const options = parseModelsListArgs(args);
31
- return ok(modelsList(loadCatalog(runtime.env), options));
31
+ return ok(modelsList(await loadCatalog(runtime.env), options));
32
32
  }
33
33
  case "models-validate":
34
- return ok(modelsValidate(runtime.env, parseModelsValidateArgs(args)));
34
+ return ok(await modelsValidate(runtime.env, parseModelsValidateArgs(args)));
35
35
  case "auth-set":
36
36
  return ok(await authSet(runtime, args));
37
37
  case "auth-remove":
38
- return ok(authRemove(runtime.env, args));
38
+ return ok(await authRemove(runtime.env, args));
39
39
  case "auth-status":
40
40
  assertAuthStatusArgs(args);
41
- return ok(authStatus(loadCatalog(runtime.env), args));
41
+ return ok(await authStatus(await loadCatalog(runtime.env), args));
42
42
  default:
43
43
  return fail(`Unknown command: ${command}\n\n${USAGE}`);
44
44
  }
@@ -47,13 +47,13 @@ export async function runModelsCli(argv, runtime) {
47
47
  return fail(err instanceof Error ? err.message : String(err));
48
48
  }
49
49
  }
50
- function loadCatalog(env, overrides = {}) {
50
+ async function loadCatalog(env, overrides = {}) {
51
51
  const deployment = parseModelDeploymentConfigFromEnv(env);
52
52
  const config = {
53
53
  ...deployment,
54
54
  ...(overrides.modelsPath === undefined ? {} : { modelsPath: overrides.modelsPath }),
55
55
  };
56
- const catalog = createPiModelCatalog({
56
+ const catalog = await createPiModelCatalog({
57
57
  allowedProviders: config.allowedProviders,
58
58
  defaultModel: config.defaultModel,
59
59
  authPath: config.authPath,
@@ -108,8 +108,8 @@ function modelsList(catalog, options) {
108
108
  }
109
109
  return `${lines.join("\n")}\n`;
110
110
  }
111
- function modelsValidate(env, options) {
112
- const catalog = loadCatalog(env, options.file === undefined ? {} : { modelsPath: options.file });
111
+ async function modelsValidate(env, options) {
112
+ const catalog = await loadCatalog(env, options.file === undefined ? {} : { modelsPath: options.file });
113
113
  const warnings = catalog.securityReport.warnings.map((warning) => `Warning: ${warning}`);
114
114
  return [
115
115
  ...warnings,
@@ -121,7 +121,7 @@ function modelsValidate(env, options) {
121
121
  }
122
122
  async function authSet(runtime, args) {
123
123
  const parsed = parseAuthSetArgs(args);
124
- const catalog = loadCatalog(runtime.env);
124
+ const catalog = await loadCatalog(runtime.env);
125
125
  assertEnabledProvider(catalog, parsed.provider);
126
126
  const key = parsed.stdin
127
127
  ? await (runtime.readStdin ?? readAllStdin)()
@@ -129,21 +129,25 @@ async function authSet(runtime, args) {
129
129
  const trimmed = key.trim();
130
130
  if (trimmed === "")
131
131
  throw new Error("API key must not be empty");
132
- catalog.authStorage.set(parsed.provider, { type: "api_key", key: trimmed });
132
+ if (trimmed.startsWith("!") && !parseModelDeploymentConfigFromEnv(runtime.env).allowModelAuthCommands) {
133
+ throw new Error("Command-backed keys (starting with \"!\") require OMA_ALLOW_MODEL_AUTH_COMMANDS=true");
134
+ }
135
+ await catalog.credentials.modify(parsed.provider, async () => ({ type: "api_key", key: trimmed }));
133
136
  return (`Stored API key for ${parsed.provider}.\n` +
134
137
  `The key was not printed. Restart \`oma up\` for a running appliance to observe this change.\n`);
135
138
  }
136
- function authRemove(env, args) {
139
+ async function authRemove(env, args) {
137
140
  if (args.length !== 1)
138
141
  throw new Error("Usage: oma auth remove <provider>");
139
142
  const [provider] = args;
140
143
  assertProviderName(provider);
141
- const catalog = loadCatalog(env);
142
- catalog.authStorage.remove(provider);
144
+ const catalog = await loadCatalog(env);
145
+ await catalog.credentials.delete(provider);
143
146
  return `Removed stored API key for ${provider} if one existed. Restart \`oma up\` for a running appliance to observe this change.\n`;
144
147
  }
145
- function authStatus(catalog, args) {
148
+ async function authStatus(catalog, args) {
146
149
  const providers = args.length === 1 ? [args[0]] : [...catalog.allowedProviders].sort();
150
+ const stored = new Set((await catalog.credentials.list()).map((entry) => entry.providerId));
147
151
  const lines = ["provider\tstored\tready_models\ttotal_models"];
148
152
  for (const provider of providers) {
149
153
  assertProviderName(provider);
@@ -152,7 +156,7 @@ function authStatus(catalog, args) {
152
156
  const ready = models.filter((model) => catalog.hasConfiguredAuth(model));
153
157
  lines.push([
154
158
  provider,
155
- catalog.authStorage.has(provider) ? "true" : "false",
159
+ stored.has(provider) ? "true" : "false",
156
160
  String(ready.length),
157
161
  String(models.length),
158
162
  ].join("\t"));
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import { bodyLimit } from "hono/body-limit";
3
3
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
4
+ import { randomBytes } from "node:crypto";
4
5
  import { existsSync } from "node:fs";
5
6
  import { join } from "node:path";
6
7
  import { fileURLToPath } from "node:url";
@@ -43,6 +44,7 @@ import { sessionsRoutes } from "./sessions/routes.js";
43
44
  import { DefaultSessionService } from "./sessions/service.js";
44
45
  import { SqliteSessionStore } from "./sessions/store.js";
45
46
  import { vaultsRoutes } from "./vaults/routes.js";
47
+ import { ConsoleMcpOauthService } from "./vaults/console-mcp-oauth.js";
46
48
  import { DefaultVaultService } from "./vaults/service.js";
47
49
  import { SqliteVaultStore } from "./vaults/store.js";
48
50
  import { createFileMountResolver, createSkillSnapshotsProvider, createSessionEgressBundleResolver, createStoreBackedAgentRevisionProvider, createStoreBackedCustomToolsProvider, } from "./wiring.js";
@@ -338,6 +340,65 @@ function registerConsoleSessionRoutes(app, services) {
338
340
  c.header("cache-control", "no-store");
339
341
  return c.body(null, 204);
340
342
  });
343
+ const mcpOauth = services.consoleMcpOauth;
344
+ if (mcpOauth !== undefined) {
345
+ app.post("/console/mcp-oauth/flows", async (c) => {
346
+ if (!sameOriginForCookieWrite(c))
347
+ return c.text("Forbidden\n", 403);
348
+ const workspaceId = consoleWorkspaceId(c, services);
349
+ c.header("cache-control", "no-store");
350
+ return c.json(await mcpOauth.startConnect(workspaceId, await parseJsonBody(c.req), new URL("/console/mcp-oauth/callback", c.req.url).toString()), 200);
351
+ });
352
+ app.post("/console/mcp-oauth/reauthorize", async (c) => {
353
+ if (!sameOriginForCookieWrite(c))
354
+ return c.text("Forbidden\n", 403);
355
+ const workspaceId = consoleWorkspaceId(c, services);
356
+ c.header("cache-control", "no-store");
357
+ return c.json(await mcpOauth.startReauthorize(workspaceId, await parseJsonBody(c.req), new URL("/console/mcp-oauth/callback", c.req.url).toString()), 200);
358
+ });
359
+ app.get("/console/mcp-oauth/flows/:flowId", (c) => {
360
+ const workspaceId = consoleWorkspaceId(c, services);
361
+ c.header("cache-control", "no-store");
362
+ return c.json(mcpOauth.status(workspaceId, c.req.param("flowId")), 200);
363
+ });
364
+ app.get("/console/mcp-oauth/callback", async (c) => {
365
+ c.header("cache-control", "no-store");
366
+ const scriptNonce = randomBytes(18).toString("base64url");
367
+ c.header("content-security-policy", `default-src 'none'; script-src 'nonce-${scriptNonce}'; style-src 'unsafe-inline'`);
368
+ let flowId;
369
+ let ok = false;
370
+ try {
371
+ const result = c.req.query("error") !== undefined
372
+ ? mcpOauth.deny(c.req.query("state"))
373
+ : await mcpOauth.complete(c.req.query("state"), c.req.query("code"));
374
+ flowId = result.flowId;
375
+ ok = result.ok;
376
+ }
377
+ catch {
378
+ ok = false;
379
+ }
380
+ return c.html(consoleMcpOauthCallbackHtml(ok, flowId, scriptNonce), ok ? 200 : 400);
381
+ });
382
+ }
383
+ }
384
+ function consoleWorkspaceId(c, services) {
385
+ if (services.auth === undefined)
386
+ return DEFAULT_WORKSPACE_ID;
387
+ const token = getCookie(c, CONSOLE_WORKSPACE_COOKIE);
388
+ const workspaceId = token === undefined
389
+ ? undefined
390
+ : services.consoleSessionAuth?.service.authenticateWorkspace(token);
391
+ if (workspaceId === undefined)
392
+ throw authenticationFailed();
393
+ return workspaceId;
394
+ }
395
+ function consoleMcpOauthCallbackHtml(ok, flowId, scriptNonce) {
396
+ const payload = JSON.stringify({ type: "oma:mcp-oauth", ok, flowId: flowId ?? null });
397
+ const title = ok ? "MCP connected" : "MCP connection failed";
398
+ const detail = ok
399
+ ? "Authorization completed. You can close this window."
400
+ : "Return to OMA and start Connect again.";
401
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${title}</title><style>body{background:#181a1f;color:#eee;font:16px system-ui;margin:0;display:grid;min-height:100vh;place-items:center}.card{max-width:34rem;padding:2rem;border:1px solid #343840;border-radius:12px}p{color:#aaa}</style></head><body><main class="card"><h1>${title}</h1><p>${detail}</p></main><script nonce="${scriptNonce}">if(window.opener){window.opener.postMessage(${payload},window.location.origin)}setTimeout(()=>window.close(),600)</script></body></html>`;
341
402
  }
342
403
  function consoleWorkspace(workspaceId, auth) {
343
404
  return { id: workspaceId, name: auth.workspaceName(workspaceId) ?? workspaceId };
@@ -377,14 +438,14 @@ function sameOriginForCookieWrite(c) {
377
438
  return false;
378
439
  }
379
440
  }
380
- export function createDeploymentControlPlaneApp(env = process.env, opts = {}) {
381
- return createDeploymentControlPlane(env, opts, { backgroundWorkers: false }).app;
441
+ export async function createDeploymentControlPlaneApp(env = process.env, opts = {}) {
442
+ return (await createDeploymentControlPlane(env, opts, { backgroundWorkers: false })).app;
382
443
  }
383
444
  // Same wiring as createDeploymentControlPlaneApp, but hands back the stores
384
445
  // so a caller that owns the process lifecycle (the appliance entrypoint,
385
446
  // tests that boot twice against one OMA_HOME) can close them and release the
386
447
  // .oma.lock instead of leaking them until process exit.
387
- export function createDeploymentControlPlane(env = process.env, opts = {}, internal = {}) {
448
+ export async function createDeploymentControlPlane(env = process.env, opts = {}, internal = {}) {
388
449
  if (opts.consoleBootstrap !== undefined && !isLoopbackHost(env.OMA_HOST)) {
389
450
  throw new Error("Console bootstrap is available only on a loopback appliance bind");
390
451
  }
@@ -507,7 +568,7 @@ export function createDeploymentControlPlane(env = process.env, opts = {}, inter
507
568
  : undefined;
508
569
  let modelCatalog;
509
570
  try {
510
- modelCatalog = opts.runner?.modelCatalog ?? createPiModelCatalog({
571
+ modelCatalog = opts.runner?.modelCatalog ?? await createPiModelCatalog({
511
572
  allowedProviders: modelConfig.allowedProviders,
512
573
  defaultModel: modelConfig.defaultModel,
513
574
  authBackend: createOmaAuthStorageBackend(modelConfig.authPath),
@@ -676,6 +737,14 @@ export function createDeploymentControlPlane(env = process.env, opts = {}, inter
676
737
  secureCookies: tlsTerminated,
677
738
  ...(opts.consoleBootstrap === undefined ? {} : { bootstrap: opts.consoleBootstrap }),
678
739
  },
740
+ ...(runtimeConfig.mcp === undefined
741
+ ? {}
742
+ : {
743
+ consoleMcpOauth: new ConsoleMcpOauthService(vaultService, mcpRuntime.fetch, {
744
+ operationTimeoutMs: runtimeConfig.mcp.operationTimeoutMs ??
745
+ DEFAULT_MCP_OPERATION_TIMEOUT_MS,
746
+ }),
747
+ }),
679
748
  ...(openapiRoot === undefined ? {} : { openapi: { root: openapiRoot } }),
680
749
  ...(authMode === "api-key"
681
750
  ? {
@@ -703,6 +772,7 @@ export function createDeploymentControlPlane(env = process.env, opts = {}, inter
703
772
  }),
704
773
  sessions: new DefaultSessionService(stores.sessions, stores.agents, stores.environments, stores.files, {
705
774
  assertDeletable: sessionEvents.assertSessionDeletable.bind(sessionEvents),
775
+ awaitDeletable: sessionEvents.waitForPostIdleRuntimeSettle.bind(sessionEvents),
706
776
  runtime: runner,
707
777
  modelAvailability,
708
778
  skills: stores.skills,
@@ -841,6 +911,7 @@ export function createInMemoryControlPlaneApp(opts = {}) {
841
911
  vaults: vaultService,
842
912
  sessions: new DefaultSessionService(sessionStore, agentStore, environmentStore, fileStorage, {
843
913
  assertDeletable: sessionEvents.assertSessionDeletable.bind(sessionEvents),
914
+ awaitDeletable: sessionEvents.waitForPostIdleRuntimeSettle.bind(sessionEvents),
844
915
  ...(opts.runtime?.runner ? { runtime: opts.runtime.runner } : {}),
845
916
  skills: skillsStore,
846
917
  vaults: vaultService,
@@ -9,6 +9,9 @@ import { archiveGuardKey, requireActiveSession, requireExistingSession, sessionN
9
9
  import { eventPayload, hasToolResultForToolUseId, lostMcpToolConfirmationPayload, lostToolConfirmationPayload, parseSendRequest, sameCustomToolResult, sameCustomToolResultPayload, sameToolConfirmation, toSendResponseEvent, } from "./request.js";
10
10
  import { actionClosedWithoutResult, hasTerminalIdleDraft, isAcknowledgedInFlightAction, isRuntimeCustomToolUseEvent, isRuntimeLeaseExpired, isRuntimeMcpConnectionFailedEvent, isRuntimeMcpToolResultEvent, isRuntimeMcpToolUseEvent, isRuntimeMcpToolWithModelEndEvent, isRuntimeToolPermissionUseEvent, isRuntimeToolPermissionWithModelEndEvent, isRuntimeTurnClosed, leaseExpiresAt, runtimeErrorDraft, runtimeLeaseRetryDelayMs, runtimeTurnStillOwned, textFromContent, toError, unique, } from "./runtime-helpers.js";
11
11
  import { materializeMcpConnectionFailedRows, materializeMcpToolResultRows, materializeMcpToolUseRows, materializeToolPermissionUseRows, toolPermissionRuntimeChanges, } from "./tool-persistence.js";
12
+ // Upper bound on how long DELETE waits for post-idle runtime work (output
13
+ // collection, turn close) before falling back to the running-session 400.
14
+ const POST_IDLE_SETTLE_TIMEOUT_MS = 10_000;
12
15
  export class DefaultSessionEventsService {
13
16
  events;
14
17
  sessions;
@@ -26,6 +29,14 @@ export class DefaultSessionEventsService {
26
29
  closedSessions = new Set();
27
30
  deletedSessions = new Set();
28
31
  activeRuntimeTasks = new Map();
32
+ // Sessions whose live runtime task has already published its terminal
33
+ // session.status_idle and is only doing post-idle work (output collection,
34
+ // turn close). A delete waits for these instead of rejecting them.
35
+ // Per session: how many live runtime tasks are post-idle. A delete waits
36
+ // only while every live task is post-idle; any genuinely running task means
37
+ // the session is running and gets the 400 without waiting.
38
+ postIdleRuntimeTasks = new Map();
39
+ runtimeSettledWaiters = new Map();
29
40
  interruptedCustomToolActions = new Map();
30
41
  pendingToolConfirmations = new Map();
31
42
  interruptedToolConfirmations = new Map();
@@ -397,6 +408,34 @@ export class DefaultSessionEventsService {
397
408
  throw sessionNotArchivable(sessionId, session.status);
398
409
  }
399
410
  }
411
+ // A client that saw session.status_idle may delete while the runtime task is
412
+ // still collecting outputs and closing the turn. Wait (bounded) for that
413
+ // post-idle work to finish so assertSessionDeletable sees a settled session;
414
+ // a genuinely running turn is not waited on and still gets the hosted 400.
415
+ async waitForPostIdleRuntimeSettle(workspaceId, sessionId, timeoutMs = POST_IDLE_SETTLE_TIMEOUT_MS) {
416
+ const key = sessionScopeKey(workspaceId, sessionId);
417
+ if (!this.allRuntimeTasksPostIdle(key))
418
+ return;
419
+ await new Promise((resolve) => {
420
+ // One cleanup for both outcomes: a timed-out waiter must not stay
421
+ // registered while post-idle work is stalled, or retried deletes leak.
422
+ const settle = () => {
423
+ clearTimeout(timer);
424
+ const waiters = this.runtimeSettledWaiters.get(key);
425
+ const index = waiters?.indexOf(settle) ?? -1;
426
+ if (index >= 0)
427
+ waiters.splice(index, 1);
428
+ if (waiters?.length === 0)
429
+ this.runtimeSettledWaiters.delete(key);
430
+ resolve();
431
+ };
432
+ const timer = setTimeout(settle, timeoutMs);
433
+ timer.unref?.();
434
+ const waiters = this.runtimeSettledWaiters.get(key) ?? [];
435
+ waiters.push(settle);
436
+ this.runtimeSettledWaiters.set(key, waiters);
437
+ });
438
+ }
400
439
  // Preflight for DELETE /v1/sessions/:id. Hosted CMA rejects a delete while the
401
440
  // session is running with a 400 (probe 38); we reuse the same running-detection
402
441
  // as archive so a session whose runtime task is live but whose row status lags
@@ -546,9 +585,9 @@ export class DefaultSessionEventsService {
546
585
  this.terminalizeAbandonedRuntimeTurn(claimed, "Accepted runtime turn has no recoverable trigger event.");
547
586
  continue;
548
587
  }
549
- this.beginRuntimeTask(workspaceId, claimed.session_id);
550
- void this.runRuntimePrompts(workspaceId, claimed.session_id, prompts, undefined).finally(() => {
551
- this.finishRuntimeTask(workspaceId, claimed.session_id);
588
+ const task = this.beginRuntimeTask(workspaceId, claimed.session_id);
589
+ void this.runRuntimePrompts(workspaceId, claimed.session_id, prompts, undefined, task).finally(() => {
590
+ this.finishRuntimeTask(workspaceId, claimed.session_id, task);
552
591
  });
553
592
  continue;
554
593
  }
@@ -741,24 +780,31 @@ export class DefaultSessionEventsService {
741
780
  return;
742
781
  if (prompts.length === 0)
743
782
  return;
744
- this.beginRuntimeTask(workspaceId, sessionId);
745
- void this.runRuntimePrompts(workspaceId, sessionId, prompts, signal)
783
+ const task = this.beginRuntimeTask(workspaceId, sessionId);
784
+ void this.runRuntimePrompts(workspaceId, sessionId, prompts, signal, task)
746
785
  .finally(() => {
747
- this.finishRuntimeTask(workspaceId, sessionId);
786
+ this.finishRuntimeTask(workspaceId, sessionId, task);
748
787
  });
749
788
  }
750
789
  beginRuntimeTask(workspaceId, sessionId) {
751
790
  const key = sessionScopeKey(workspaceId, sessionId);
752
791
  this.activeRuntimeTasks.set(key, (this.activeRuntimeTasks.get(key) ?? 0) + 1);
792
+ return { postIdle: false };
753
793
  }
754
- finishRuntimeTask(workspaceId, sessionId) {
794
+ finishRuntimeTask(workspaceId, sessionId, task) {
755
795
  const key = sessionScopeKey(workspaceId, sessionId);
796
+ // Both counts drop in the same synchronous step, so a woken delete never
797
+ // observes this task as still active.
798
+ if (task.postIdle)
799
+ this.adjustPostIdleRuntimeTasks(key, -1);
756
800
  const remaining = (this.activeRuntimeTasks.get(key) ?? 1) - 1;
757
801
  if (remaining > 0) {
758
802
  this.activeRuntimeTasks.set(key, remaining);
803
+ this.wakeSettleWaitersUnlessAllPostIdle(key);
759
804
  return;
760
805
  }
761
806
  this.activeRuntimeTasks.delete(key);
807
+ this.wakeSettleWaitersUnlessAllPostIdle(key);
762
808
  this.interruptedCustomToolActions.delete(key);
763
809
  this.interruptedToolConfirmations.delete(key);
764
810
  this.retireLifecycleGuardsIfIdle(workspaceId, sessionId);
@@ -770,10 +816,36 @@ export class DefaultSessionEventsService {
770
816
  this.closedSessions.delete(key);
771
817
  this.deletedSessions.delete(key);
772
818
  }
819
+ setRuntimeTaskPostIdle(workspaceId, sessionId, task, postIdle) {
820
+ if (task.postIdle === postIdle)
821
+ return;
822
+ task.postIdle = postIdle;
823
+ const key = sessionScopeKey(workspaceId, sessionId);
824
+ this.adjustPostIdleRuntimeTasks(key, postIdle ? 1 : -1);
825
+ if (!postIdle)
826
+ this.wakeSettleWaitersUnlessAllPostIdle(key);
827
+ }
828
+ adjustPostIdleRuntimeTasks(key, delta) {
829
+ const next = (this.postIdleRuntimeTasks.get(key) ?? 0) + delta;
830
+ if (next > 0)
831
+ this.postIdleRuntimeTasks.set(key, next);
832
+ else
833
+ this.postIdleRuntimeTasks.delete(key);
834
+ }
835
+ allRuntimeTasksPostIdle(key) {
836
+ const active = this.activeRuntimeTasks.get(key) ?? 0;
837
+ return active > 0 && (this.postIdleRuntimeTasks.get(key) ?? 0) === active;
838
+ }
839
+ wakeSettleWaitersUnlessAllPostIdle(key) {
840
+ if (this.allRuntimeTasksPostIdle(key))
841
+ return;
842
+ for (const settle of [...(this.runtimeSettledWaiters.get(key) ?? [])])
843
+ settle();
844
+ }
773
845
  activeRuntimeTaskCount(workspaceId, sessionId) {
774
846
  return this.activeRuntimeTasks.get(sessionScopeKey(workspaceId, sessionId)) ?? 0;
775
847
  }
776
- async runRuntimePrompts(workspaceId, sessionId, prompts, signal) {
848
+ async runRuntimePrompts(workspaceId, sessionId, prompts, signal, task) {
777
849
  if (!this.runtimeRunner || !this.runtimeTranslator)
778
850
  return;
779
851
  let activePrompt;
@@ -782,6 +854,7 @@ export class DefaultSessionEventsService {
782
854
  for (const prompt of prompts) {
783
855
  activePrompt = prompt;
784
856
  activeOpenModelRequestStartIds = [];
857
+ this.setRuntimeTaskPostIdle(workspaceId, sessionId, task, false);
785
858
  const stopRenewing = this.startRuntimeLeaseRenewal(workspaceId, sessionId, prompt);
786
859
  try {
787
860
  this.markRuntimeTurnState(workspaceId, sessionId, prompt.turnId, prompt.ownerId, prompt.ownerGeneration, "dispatching");
@@ -910,6 +983,7 @@ export class DefaultSessionEventsService {
910
983
  activeOpenModelRequestStartIds.pop();
911
984
  }
912
985
  if (hasTerminalIdleDraft(drafts)) {
986
+ this.setRuntimeTaskPostIdle(workspaceId, sessionId, task, true);
913
987
  await this.indexSessionOutputsFromLiveRuntime(workspaceId, sessionId, prompt);
914
988
  }
915
989
  }
@@ -1,8 +1,11 @@
1
- import { AuthStorage, ModelRegistry, } from "@earendil-works/pi-coding-agent";
1
+ import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
2
2
  import { scanModelConfigSecurity } from "./config-security.js";
3
- export const PINNED_PI_MODEL_RUNTIME_VERSION = "0.80.6";
3
+ import { InMemoryAuthStorageBackend, InMemoryModelsStore, OmaCredentialStore, } from "./credential-store.js";
4
+ export const PINNED_PI_MODEL_RUNTIME_VERSION = "0.85.1";
4
5
  export function createPiModelCatalog(config) {
5
- return createCatalog(config, AuthStorage.fromStorage(config.authBackend));
6
+ return createCatalog(config, new OmaCredentialStore(config.authBackend, {
7
+ commands: config.allowModelAuthCommands === true ? "execute" : "deny",
8
+ }));
6
9
  }
7
10
  /**
8
11
  * Read-only catalog construction for diagnostics. The caller supplies an
@@ -10,9 +13,10 @@ export function createPiModelCatalog(config) {
10
13
  * auth file. Runtime code must continue to use createPiModelCatalog().
11
14
  */
12
15
  export function createReadOnlyPiModelCatalog(config, authData = {}) {
13
- return createCatalog(config, AuthStorage.inMemory(authData));
16
+ // Diagnostics must stay side-effect free: never run command-backed keys.
17
+ return createCatalog(config, new OmaCredentialStore(new InMemoryAuthStorageBackend(authData), { commands: "unresolved" }));
14
18
  }
15
- function createCatalog(config, authStorage) {
19
+ async function createCatalog(config, credentials) {
16
20
  const allowedProviders = new Set(config.allowedProviders);
17
21
  if (allowedProviders.size !== config.allowedProviders.length) {
18
22
  throw new Error("OMA_MODEL_PROVIDERS must not contain duplicate providers");
@@ -20,17 +24,27 @@ function createCatalog(config, authStorage) {
20
24
  if (!allowedProviders.has(config.defaultModel.provider)) {
21
25
  throw new Error(`Default model provider ${config.defaultModel.provider} is not enabled on this deployment`);
22
26
  }
23
- const securityReport = scanModelConfigSecurity({
27
+ const scan = () => scanModelConfigSecurity({
24
28
  modelsPath: config.modelsPath,
25
29
  authPath: config.authPath,
26
30
  allowedProviders,
27
31
  allowCommands: config.allowModelAuthCommands,
28
32
  });
29
- const authErrors = authStorage.drainErrors();
30
- if (authErrors.length > 0) {
31
- throw new Error(`Failed to load model auth storage: ${authErrors.map((error) => error.message).join("; ")}`);
33
+ const securityReport = scan();
34
+ // Fail at startup on an unreadable or corrupt auth.json (Pi's own store
35
+ // would keep serving its last in-memory snapshot).
36
+ try {
37
+ await credentials.list();
32
38
  }
33
- const modelRegistry = ModelRegistry.create(authStorage, config.modelsPath);
39
+ catch (error) {
40
+ throw new Error(`Failed to load model auth storage: ${error instanceof Error ? error.message : String(error)}`);
41
+ }
42
+ const modelRuntime = await ModelRuntime.create({
43
+ credentials,
44
+ modelsPath: config.modelsPath,
45
+ modelsStore: new InMemoryModelsStore(),
46
+ });
47
+ const modelRegistry = new ModelRegistry(modelRuntime);
34
48
  const registryError = modelRegistry.getError();
35
49
  if (registryError !== undefined) {
36
50
  throw new Error(registryError);
@@ -46,9 +60,13 @@ function createCatalog(config, authStorage) {
46
60
  return {
47
61
  defaultModel: { ...config.defaultModel },
48
62
  allowedProviders,
49
- authStorage,
63
+ credentials,
64
+ modelRuntime,
50
65
  modelRegistry,
51
66
  securityReport,
67
+ assertConfigSecurity() {
68
+ scan();
69
+ },
52
70
  resolve(ref) {
53
71
  if (!allowedProviders.has(ref.provider))
54
72
  return undefined;
@@ -63,8 +81,6 @@ function createCatalog(config, authStorage) {
63
81
  });
64
82
  },
65
83
  hasConfiguredAuth(model) {
66
- // Deliberately model-scoped: provider status can report env credentials as
67
- // configured:false in Pi 0.80.6 while the resolved model is usable.
68
84
  return modelRegistry.hasConfiguredAuth(model);
69
85
  },
70
86
  providerAuthMetadata(provider) {