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.
- package/dist/scripts/alpha-smoke.mjs +40 -2
- package/dist/scripts/oma-doctor.js +1 -1
- package/dist/scripts/oma-models.js +20 -16
- package/dist/src/control-plane/app.js +75 -4
- package/dist/src/control-plane/events/service.js +82 -8
- package/dist/src/control-plane/models/catalog.js +29 -13
- package/dist/src/control-plane/models/credential-store.js +203 -0
- package/dist/src/control-plane/sessions/pi/mcp/client.js +5 -1
- package/dist/src/control-plane/sessions/pi/runner.js +59 -32
- package/dist/src/control-plane/sessions/pi/sandbox/container-engine.js +32 -0
- package/dist/src/control-plane/sessions/pi/sandbox/docker-egress.js +8 -0
- package/dist/src/control-plane/sessions/pi/sandbox/docker.js +20 -5
- package/dist/src/control-plane/sessions/pi/sandbox/microsandbox.js +18 -3
- package/dist/src/control-plane/sessions/pi/sandbox/provider.js +16 -9
- package/dist/src/control-plane/sessions/service.js +5 -0
- package/dist/src/control-plane/vaults/console-mcp-oauth.js +545 -0
- package/dist/src/control-plane/vaults/service.js +4 -1
- package/dist/src/control-plane/vaults/store.js +8 -2
- package/dist/src/main.js +1 -1
- package/dist/ui/managed-agents-console/README.md +22 -15
- package/dist/ui/managed-agents-console/docs/vaults.md +16 -2
- package/dist/ui/managed-agents-console/index.html +4 -0
- package/dist/ui/managed-agents-console/src/api.js +40 -0
- package/dist/ui/managed-agents-console/src/console.css +4 -1
- package/dist/ui/managed-agents-console/src/forms.jsx +79 -16
- package/dist/ui/managed-agents-console/src/session-vaults-data.js +40 -0
- package/dist/ui/managed-agents-console/src/vaults.jsx +104 -9
- 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
|
-
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
1
|
+
import { ModelRegistry, ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { scanModelConfigSecurity } from "./config-security.js";
|
|
3
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|