open-managed-agents 0.1.1 → 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 (29) hide show
  1. package/README.md +32 -7
  2. package/dist/scripts/alpha-smoke.mjs +40 -2
  3. package/dist/scripts/oma-doctor.js +1 -1
  4. package/dist/scripts/oma-models.js +20 -16
  5. package/dist/src/control-plane/app.js +75 -4
  6. package/dist/src/control-plane/events/service.js +82 -8
  7. package/dist/src/control-plane/models/catalog.js +29 -13
  8. package/dist/src/control-plane/models/credential-store.js +203 -0
  9. package/dist/src/control-plane/sessions/pi/mcp/client.js +5 -1
  10. package/dist/src/control-plane/sessions/pi/runner.js +59 -32
  11. package/dist/src/control-plane/sessions/pi/sandbox/container-engine.js +32 -0
  12. package/dist/src/control-plane/sessions/pi/sandbox/docker-egress.js +8 -0
  13. package/dist/src/control-plane/sessions/pi/sandbox/docker.js +20 -5
  14. package/dist/src/control-plane/sessions/pi/sandbox/microsandbox.js +18 -3
  15. package/dist/src/control-plane/sessions/pi/sandbox/provider.js +16 -9
  16. package/dist/src/control-plane/sessions/service.js +5 -0
  17. package/dist/src/control-plane/vaults/console-mcp-oauth.js +545 -0
  18. package/dist/src/control-plane/vaults/service.js +4 -1
  19. package/dist/src/control-plane/vaults/store.js +8 -2
  20. package/dist/src/main.js +1 -1
  21. package/dist/ui/managed-agents-console/README.md +22 -15
  22. package/dist/ui/managed-agents-console/docs/vaults.md +16 -2
  23. package/dist/ui/managed-agents-console/index.html +4 -0
  24. package/dist/ui/managed-agents-console/src/api.js +40 -0
  25. package/dist/ui/managed-agents-console/src/console.css +4 -1
  26. package/dist/ui/managed-agents-console/src/forms.jsx +79 -16
  27. package/dist/ui/managed-agents-console/src/session-vaults-data.js +40 -0
  28. package/dist/ui/managed-agents-console/src/vaults.jsx +104 -9
  29. package/package.json +15 -7
package/README.md CHANGED
@@ -12,17 +12,35 @@ files, and sandboxes live on your machines, under your policies.
12
12
  > self-hosted baseline. The orchestration/persistence layer (multi-agent
13
13
  > runtime, memory stores,
14
14
  > scheduled deployments, webhooks, outcomes) is deliberately deferred. See
15
- > **[PARITY.md](PARITY.md)** for the full domain-by-domain scorecard and the
16
- > active parity worklist, and **[ALPHA.md](ALPHA.md)** for the first-user
15
+ > **[PARITY.md](https://github.com/oneryalcin/open-managed-agents/blob/main/PARITY.md)** for the full domain-by-domain scorecard and the
16
+ > active parity worklist, and **[ALPHA.md](https://github.com/oneryalcin/open-managed-agents/blob/main/ALPHA.md)** for the first-user
17
17
  > alpha readiness plan.
18
18
 
19
19
  ## Quickstart
20
20
 
21
- Start with the canonical source-checkout guide:
21
+ > **Public onboarding preview:** the one-command path is shipped, but its
22
+ > three-minute warm-path target has not yet completed the real-user timing
23
+ > study. Docker image downloads are explicitly outside that warm-path target.
22
24
 
23
- **[Getting Started](docs/getting-started.md)**
25
+ Prerequisites: Node.js 22.19 or newer and a running Docker-compatible daemon.
26
+ Then run:
24
27
 
25
- The alpha path is:
28
+ ```bash
29
+ npx --yes open-managed-agents@latest
30
+ ```
31
+
32
+ The guided terminal checks local prerequisites, asks which model provider to
33
+ use, reads the API key through a masked prompt, starts an authenticated
34
+ loopback appliance, creates or reuses a starter agent/environment/session, and
35
+ opens that session in the console. The appliance stays attached to the command;
36
+ press Ctrl-C to stop it. State and provider credentials persist under `~/.oma`.
37
+
38
+ If the pinned sandbox image is not cached, OMA names the image and asks before
39
+ pulling it. Rerunning the same command while its onboarding-owned appliance is
40
+ alive reopens the existing starter session rather than creating duplicates.
41
+
42
+ For the full source-checkout diagnostics and deterministic smoke tests, use
43
+ the **[Getting Started guide](https://github.com/oneryalcin/open-managed-agents/blob/main/docs/getting-started.md)**:
26
44
 
27
45
  ```bash
28
46
  git clone https://github.com/oneryalcin/open-managed-agents.git
@@ -45,7 +63,8 @@ events, and cleanup.
45
63
  Docker-local egress boundary: npm, uv/PyPI, and GitHub must work under the
46
64
  reviewed preset while an unrelated HTTPS destination remains denied.
47
65
 
48
- `oma up` starts the durable local appliance in the foreground, stores data
66
+ In the advanced source-checkout flow, `oma up` starts the durable local appliance
67
+ in the foreground, stores data
49
68
  under `~/.oma`, and prints the console URL plus the first workspace API key
50
69
  once. Docker deployments have the pinned approved-HTTPS sidecar capability,
51
70
  but every environment remains offline unless its immutable allowlist selects a
@@ -57,6 +76,12 @@ mint another while the server is running:
57
76
  oma keys mint
58
77
  ```
59
78
 
79
+ When using the public package without a global install, the equivalent is:
80
+
81
+ ```bash
82
+ npx --yes open-managed-agents@latest keys mint
83
+ ```
84
+
60
85
  Interactive, air-gap-safe OpenAPI documentation is served at `/docs/`, with
61
86
  the same schema available to tools at `/openapi.json`. The documentation lists
62
87
  only routes OMA currently ships; workspace and admin credentials entered in the
@@ -69,7 +94,7 @@ import anthropic
69
94
 
70
95
  client = anthropic.Anthropic(
71
96
  base_url="http://127.0.0.1:4180",
72
- api_key="oma_...", # printed on first boot
97
+ api_key="oma_...", # returned by `oma keys mint`
73
98
  default_headers={"anthropic-beta": "managed-agents-2026-04-01"},
74
99
  )
75
100
 
@@ -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) {