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.
- package/README.md +32 -7
- 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 +15 -7
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
export class InMemoryAuthStorageBackend {
|
|
3
|
+
content;
|
|
4
|
+
// Serializes async read-modify-writes, as Pi's in-memory backend does.
|
|
5
|
+
chain = Promise.resolve();
|
|
6
|
+
constructor(data) {
|
|
7
|
+
if (data !== undefined)
|
|
8
|
+
this.content = JSON.stringify(data, null, 2);
|
|
9
|
+
}
|
|
10
|
+
withLock(fn) {
|
|
11
|
+
const { result, next } = fn(this.content);
|
|
12
|
+
if (next !== undefined)
|
|
13
|
+
this.content = next;
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
withLockAsync(fn) {
|
|
17
|
+
const run = this.chain.then(async () => {
|
|
18
|
+
const { result, next } = await fn(this.content);
|
|
19
|
+
if (next !== undefined)
|
|
20
|
+
this.content = next;
|
|
21
|
+
return result;
|
|
22
|
+
});
|
|
23
|
+
this.chain = run.catch(() => { });
|
|
24
|
+
return run;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class OmaCredentialStore {
|
|
28
|
+
backend;
|
|
29
|
+
inflightRead;
|
|
30
|
+
commands;
|
|
31
|
+
constructor(backend, options = {}) {
|
|
32
|
+
this.backend = backend;
|
|
33
|
+
this.commands = options.commands ?? "deny";
|
|
34
|
+
}
|
|
35
|
+
async read(providerId, options) {
|
|
36
|
+
const credential = (await this.readAll(options))[providerId];
|
|
37
|
+
if (credential?.type !== "api_key" || credential.key === undefined)
|
|
38
|
+
return credential;
|
|
39
|
+
if (credential.key.startsWith("!")) {
|
|
40
|
+
if (this.commands === "unresolved")
|
|
41
|
+
return credential;
|
|
42
|
+
if (this.commands === "deny") {
|
|
43
|
+
throw new Error(`auth.json.${providerId}.key uses command-backed auth; set OMA_ALLOW_MODEL_AUTH_COMMANDS=true to allow it`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { ...credential, key: resolveConfigValue(credential.key, credential.env) };
|
|
47
|
+
}
|
|
48
|
+
async list(options) {
|
|
49
|
+
return Object.entries(await this.readAll(options)).map(([providerId, credential]) => ({
|
|
50
|
+
providerId,
|
|
51
|
+
type: credential.type,
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
async modify(providerId, fn, options) {
|
|
55
|
+
options?.signal?.throwIfAborted();
|
|
56
|
+
return this.backend.withLockAsync(async (content) => {
|
|
57
|
+
const data = parseAuthData(content);
|
|
58
|
+
const next = await fn(data[providerId]);
|
|
59
|
+
if (next === undefined)
|
|
60
|
+
return { result: data[providerId] };
|
|
61
|
+
return { result: next, next: JSON.stringify({ ...data, [providerId]: next }, null, 2) };
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async delete(providerId, options) {
|
|
65
|
+
options?.signal?.throwIfAborted();
|
|
66
|
+
await this.backend.withLockAsync(async (content) => {
|
|
67
|
+
const data = parseAuthData(content);
|
|
68
|
+
delete data[providerId];
|
|
69
|
+
return { result: undefined, next: JSON.stringify(data, null, 2) };
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
// Pi's availability refresh reads every provider in parallel; concurrent
|
|
73
|
+
// readers share one locked read instead of contending for the file lock.
|
|
74
|
+
readAll(options) {
|
|
75
|
+
options?.signal?.throwIfAborted();
|
|
76
|
+
this.inflightRead ??= this.backend
|
|
77
|
+
.withLockAsync(async (content) => ({ result: parseAuthData(content) }))
|
|
78
|
+
.finally(() => {
|
|
79
|
+
this.inflightRead = undefined;
|
|
80
|
+
});
|
|
81
|
+
return this.inflightRead;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* OMA never enables Pi's network model catalogs, so the cache Pi would keep
|
|
86
|
+
* in models-store.json is unnecessary; an in-memory store avoids creating
|
|
87
|
+
* that file next to models.json (Pi's file stores create on first lock).
|
|
88
|
+
*/
|
|
89
|
+
export class InMemoryModelsStore {
|
|
90
|
+
entries = new Map();
|
|
91
|
+
async read(providerId) {
|
|
92
|
+
return this.entries.get(providerId);
|
|
93
|
+
}
|
|
94
|
+
async write(providerId, entry) {
|
|
95
|
+
this.entries.set(providerId, entry);
|
|
96
|
+
}
|
|
97
|
+
async delete(providerId) {
|
|
98
|
+
this.entries.delete(providerId);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function parseAuthData(content) {
|
|
102
|
+
if (!content)
|
|
103
|
+
return {};
|
|
104
|
+
return JSON.parse(content.replace(/^/, ""));
|
|
105
|
+
}
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Config-value resolution, ported from Pi's core/resolve-config-value.ts
|
|
108
|
+
// (MIT, (c) Mario Zechner; unchanged from 0.80.6 through 0.85.1) because Pi no
|
|
109
|
+
// longer exports it. Semantics:
|
|
110
|
+
// - "!cmd" runs cmd in a shell and uses trimmed stdout (cached per process);
|
|
111
|
+
// OMA only admits these when OMA_ALLOW_MODEL_AUTH_COMMANDS=true
|
|
112
|
+
// (config-security.ts);
|
|
113
|
+
// - "$VAR" / "${VAR}" interpolate the environment ("$$" and "$!" escape);
|
|
114
|
+
// - anything else is a literal.
|
|
115
|
+
// Omitted: Pi's Windows configured-shell path; OMA does not target Windows.
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
const ENV_VAR_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
118
|
+
const ENV_VAR_NAME_PREFIX_RE = /^[A-Za-z_][A-Za-z0-9_]*/;
|
|
119
|
+
const commandResultCache = new Map();
|
|
120
|
+
export function resolveConfigValue(config, env) {
|
|
121
|
+
if (config.startsWith("!"))
|
|
122
|
+
return executeCommand(config);
|
|
123
|
+
let resolved = "";
|
|
124
|
+
for (const part of parseTemplate(config)) {
|
|
125
|
+
if (part.type === "literal") {
|
|
126
|
+
resolved += part.value;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const value = env?.[part.name] || process.env[part.name] || undefined;
|
|
130
|
+
if (value === undefined)
|
|
131
|
+
return undefined;
|
|
132
|
+
resolved += value;
|
|
133
|
+
}
|
|
134
|
+
return resolved;
|
|
135
|
+
}
|
|
136
|
+
function parseTemplate(config) {
|
|
137
|
+
const parts = [];
|
|
138
|
+
const literal = (value) => {
|
|
139
|
+
if (!value)
|
|
140
|
+
return;
|
|
141
|
+
const last = parts[parts.length - 1];
|
|
142
|
+
if (last?.type === "literal")
|
|
143
|
+
last.value += value;
|
|
144
|
+
else
|
|
145
|
+
parts.push({ type: "literal", value });
|
|
146
|
+
};
|
|
147
|
+
let index = 0;
|
|
148
|
+
while (index < config.length) {
|
|
149
|
+
const dollar = config.indexOf("$", index);
|
|
150
|
+
if (dollar < 0) {
|
|
151
|
+
literal(config.slice(index));
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
literal(config.slice(index, dollar));
|
|
155
|
+
const next = config[dollar + 1];
|
|
156
|
+
if (next === "$" || next === "!") {
|
|
157
|
+
literal(next);
|
|
158
|
+
index = dollar + 2;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (next === "{") {
|
|
162
|
+
const end = config.indexOf("}", dollar + 2);
|
|
163
|
+
if (end < 0) {
|
|
164
|
+
literal("$");
|
|
165
|
+
index = dollar + 1;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const name = config.slice(dollar + 2, end);
|
|
169
|
+
if (ENV_VAR_NAME_RE.test(name))
|
|
170
|
+
parts.push({ type: "env", name });
|
|
171
|
+
else
|
|
172
|
+
literal(config.slice(dollar, end + 1));
|
|
173
|
+
index = end + 1;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const match = config.slice(dollar + 1).match(ENV_VAR_NAME_PREFIX_RE);
|
|
177
|
+
if (match) {
|
|
178
|
+
parts.push({ type: "env", name: match[0] });
|
|
179
|
+
index = dollar + 1 + match[0].length;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
literal("$");
|
|
183
|
+
index = dollar + 1;
|
|
184
|
+
}
|
|
185
|
+
return parts;
|
|
186
|
+
}
|
|
187
|
+
function executeCommand(config) {
|
|
188
|
+
if (commandResultCache.has(config))
|
|
189
|
+
return commandResultCache.get(config);
|
|
190
|
+
let result;
|
|
191
|
+
try {
|
|
192
|
+
result = execSync(config.slice(1), {
|
|
193
|
+
encoding: "utf-8",
|
|
194
|
+
timeout: 10_000,
|
|
195
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
196
|
+
}).trim() || undefined;
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
result = undefined;
|
|
200
|
+
}
|
|
201
|
+
commandResultCache.set(config, result);
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
@@ -15,7 +15,11 @@ export const DEFAULT_MCP_OPERATION_TIMEOUT_MS = 60_000;
|
|
|
15
15
|
// structured mcp_connection_failed_error), never a silent partial register.
|
|
16
16
|
const MAX_DISCOVERED_TOOLS = 256;
|
|
17
17
|
const MAX_TOOL_NAME_LENGTH = 256;
|
|
18
|
-
|
|
18
|
+
// Notion's official server currently publishes several detailed tool
|
|
19
|
+
// descriptions above 4 KiB (the largest observed in the OAuth acceptance
|
|
20
|
+
// flow is 7,602 characters). Keep a per-tool bound, but leave enough room for
|
|
21
|
+
// standards-compliant first-party servers to describe complex operations.
|
|
22
|
+
const MAX_TOOL_DESCRIPTION_LENGTH = 16_384;
|
|
19
23
|
const MAX_TOOL_SCHEMA_BYTES = 64 * 1024;
|
|
20
24
|
const MAX_TOOL_LIST_PAGES = 16;
|
|
21
25
|
export class McpConnection {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createAgentSession, createSyntheticSourceInfo, DefaultResourceLoader, ModelRegistry, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { InMemoryAuthStorageBackend, InMemoryModelsStore, OmaCredentialStore, } from "../../models/credential-store.js";
|
|
2
3
|
import { RuntimeUnsupportedSessionFileResourcesError } from "../../events/types.js";
|
|
3
4
|
import { PiCustomToolBridge, } from "./custom-tools.js";
|
|
4
5
|
import { PiToolPermissionBridge, } from "./tool-permissions.js";
|
|
@@ -35,16 +36,27 @@ export function buildSessionSkillsResourceLoader(skills, systemPrompt) {
|
|
|
35
36
|
}
|
|
36
37
|
const DEFAULT_MCP_MAX_CONSECUTIVE_FAILURES = 5;
|
|
37
38
|
const MCP_UNAUTHENTICATED_FINGERPRINT = "none";
|
|
38
|
-
function createPrivatePiModelCatalog(provider = "anthropic", modelId = "claude-haiku-4-5") {
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
async function createPrivatePiModelCatalog(provider = "anthropic", modelId = "claude-haiku-4-5") {
|
|
40
|
+
// No stored credentials: provider auth comes from the environment. This
|
|
41
|
+
// seam never reads the operator's personal Pi auth.json.
|
|
42
|
+
const credentials = new OmaCredentialStore(new InMemoryAuthStorageBackend());
|
|
43
|
+
// modelsPath null: no models.json either (Pi would otherwise read the
|
|
44
|
+
// operator's ~/.pi/agent/models.json, which no OMA policy scan covers).
|
|
45
|
+
const modelRuntime = await ModelRuntime.create({
|
|
46
|
+
credentials,
|
|
47
|
+
modelsPath: null,
|
|
48
|
+
modelsStore: new InMemoryModelsStore(),
|
|
49
|
+
});
|
|
50
|
+
const modelRegistry = new ModelRegistry(modelRuntime);
|
|
41
51
|
const allowedProviders = new Set([provider]);
|
|
42
52
|
return {
|
|
43
53
|
defaultModel: { provider, id: modelId },
|
|
44
54
|
allowedProviders,
|
|
45
|
-
|
|
55
|
+
credentials,
|
|
56
|
+
modelRuntime,
|
|
46
57
|
modelRegistry,
|
|
47
58
|
securityReport: { warnings: [] },
|
|
59
|
+
assertConfigSecurity: () => { },
|
|
48
60
|
// Standalone runners are an internal/test seam without deployment
|
|
49
61
|
// admission policy. Production always receives the shared allowlisted
|
|
50
62
|
// catalog from the composition root.
|
|
@@ -67,9 +79,7 @@ function createPrivatePiModelCatalog(provider = "anthropic", modelId = "claude-h
|
|
|
67
79
|
}
|
|
68
80
|
export class PiSessionRunner {
|
|
69
81
|
opts;
|
|
70
|
-
|
|
71
|
-
modelRegistry;
|
|
72
|
-
modelCatalog;
|
|
82
|
+
modelCatalogPromise;
|
|
73
83
|
sessions = new Map();
|
|
74
84
|
pendingSessions = new Map();
|
|
75
85
|
pendingInterrupts = new Map();
|
|
@@ -91,10 +101,9 @@ export class PiSessionRunner {
|
|
|
91
101
|
closed = false;
|
|
92
102
|
constructor(opts = {}) {
|
|
93
103
|
this.opts = opts;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
this.modelRegistry = catalog.modelRegistry;
|
|
104
|
+
if (opts.modelCatalog !== undefined) {
|
|
105
|
+
this.modelCatalogPromise = Promise.resolve(opts.modelCatalog);
|
|
106
|
+
}
|
|
98
107
|
this.resolveSandboxProviderFactory();
|
|
99
108
|
this.idleTtlMs = opts.idleTtlMs ?? DEFAULT_IDLE_TTL_MS;
|
|
100
109
|
this.now = opts.now ?? Date.now;
|
|
@@ -254,7 +263,7 @@ export class PiSessionRunner {
|
|
|
254
263
|
this.touch(sessionId, handle);
|
|
255
264
|
if (handle.running && !handle.needsFreshPromptAfterInterrupt) {
|
|
256
265
|
try {
|
|
257
|
-
|
|
266
|
+
steerVerbatim(handle.session, text);
|
|
258
267
|
this.touch(sessionId, handle);
|
|
259
268
|
}
|
|
260
269
|
catch (error) {
|
|
@@ -269,7 +278,7 @@ export class PiSessionRunner {
|
|
|
269
278
|
const activeSandboxedToolCalls = new Map();
|
|
270
279
|
let done = false;
|
|
271
280
|
let failure;
|
|
272
|
-
let
|
|
281
|
+
let queuedOnRunningTurn = false;
|
|
273
282
|
let wake;
|
|
274
283
|
const stop = handle.session.subscribe((event) => {
|
|
275
284
|
updateRunning(handle, event);
|
|
@@ -325,16 +334,19 @@ export class PiSessionRunner {
|
|
|
325
334
|
};
|
|
326
335
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
327
336
|
const run = handle.session
|
|
328
|
-
|
|
337
|
+
// Verbatim, as hosted: Pi's default expands "/skill:<name>" by reading
|
|
338
|
+
// the skill file from the control-plane host, not the sandbox (#255).
|
|
339
|
+
.prompt(text, { expandPromptTemplates: false })
|
|
329
340
|
.catch(async (error) => {
|
|
330
341
|
if (isAlreadyProcessing(error)) {
|
|
331
|
-
|
|
332
|
-
// Best effort for the losing prompt race: once Pi confirms this
|
|
333
|
-
// is
|
|
334
|
-
// subscriber. A pre-rejection event can
|
|
335
|
-
// should be limited to early status
|
|
336
|
-
// subscriber owns the full turn
|
|
337
|
-
|
|
342
|
+
steerVerbatim(handle.session, text);
|
|
343
|
+
// Best effort for the losing prompt race: once Pi confirms this
|
|
344
|
+
// message is queued on the running turn, discard overlap events
|
|
345
|
+
// captured by this temporary subscriber. A pre-rejection event can
|
|
346
|
+
// still escape; in practice that should be limited to early status
|
|
347
|
+
// frames, while the winning prompt subscriber owns the full turn,
|
|
348
|
+
// including the steered message's output.
|
|
349
|
+
queuedOnRunningTurn = true;
|
|
338
350
|
queue.length = 0;
|
|
339
351
|
return;
|
|
340
352
|
}
|
|
@@ -346,12 +358,12 @@ export class PiSessionRunner {
|
|
|
346
358
|
});
|
|
347
359
|
try {
|
|
348
360
|
while (!done || queue.length > 0) {
|
|
349
|
-
if (
|
|
361
|
+
if (queuedOnRunningTurn)
|
|
350
362
|
break;
|
|
351
363
|
if (queue.length === 0) {
|
|
352
364
|
await new Promise((resolve) => {
|
|
353
365
|
wake = resolve;
|
|
354
|
-
if (done ||
|
|
366
|
+
if (done || queuedOnRunningTurn || queue.length > 0) {
|
|
355
367
|
wake = undefined;
|
|
356
368
|
resolve();
|
|
357
369
|
}
|
|
@@ -462,15 +474,16 @@ export class PiSessionRunner {
|
|
|
462
474
|
const created = (async () => {
|
|
463
475
|
const customToolContext = this.preparingSessionAgentContext(workspaceId, sessionId);
|
|
464
476
|
const agentRevision = this.opts.agentRevision?.(workspaceId, sessionId, customToolContext);
|
|
477
|
+
const modelCatalog = await this.modelCatalog();
|
|
465
478
|
const revisionModel = agentRevision === undefined
|
|
466
479
|
? undefined
|
|
467
|
-
:
|
|
480
|
+
: modelCatalog.resolve(agentRevision.model);
|
|
468
481
|
if (agentRevision !== undefined && revisionModel === undefined) {
|
|
469
482
|
throw new Error(`Pi model not available: ${agentRevision.model.provider}/${agentRevision.model.id}`);
|
|
470
483
|
}
|
|
471
484
|
if (agentRevision !== undefined &&
|
|
472
485
|
revisionModel !== undefined &&
|
|
473
|
-
!
|
|
486
|
+
!modelCatalog.hasConfiguredAuth(revisionModel)) {
|
|
474
487
|
throw new Error(`Credentials for model provider ${agentRevision.model.provider} are not configured on this deployment`);
|
|
475
488
|
}
|
|
476
489
|
const customToolNames = new Set((this.opts.customTools?.(workspaceId, sessionId, customToolContext) ?? []).map((tool) => tool.name));
|
|
@@ -735,12 +748,14 @@ export class PiSessionRunner {
|
|
|
735
748
|
}
|
|
736
749
|
}
|
|
737
750
|
async createPiSession(workspaceId, sessionId, sandbox, context, revision, revisionModel, mcpTools = []) {
|
|
738
|
-
const
|
|
739
|
-
const
|
|
740
|
-
const
|
|
751
|
+
const modelCatalog = await this.modelCatalog();
|
|
752
|
+
const provider = revision?.model.provider ?? modelCatalog.defaultModel.provider;
|
|
753
|
+
const modelId = revision?.model.id ?? modelCatalog.defaultModel.id;
|
|
754
|
+
const model = revisionModel ?? modelCatalog.resolve({ provider, id: modelId });
|
|
741
755
|
if (!model) {
|
|
742
756
|
throw new Error(`Pi model not available: ${provider}/${modelId}`);
|
|
743
757
|
}
|
|
758
|
+
modelCatalog.assertConfigSecurity();
|
|
744
759
|
const customToolNames = (this.opts.customTools?.(workspaceId, sessionId, context) ?? []).map((tool) => tool.name);
|
|
745
760
|
const sandboxTools = this.enabledSandboxTools(workspaceId, sessionId, sandbox);
|
|
746
761
|
const customTools = [
|
|
@@ -761,8 +776,7 @@ export class PiSessionRunner {
|
|
|
761
776
|
]
|
|
762
777
|
: [...customToolNames, ...mcpToolNames],
|
|
763
778
|
customTools,
|
|
764
|
-
|
|
765
|
-
modelRegistry: this.modelRegistry,
|
|
779
|
+
modelRuntime: modelCatalog.modelRuntime,
|
|
766
780
|
sessionManager: SessionManager.inMemory(),
|
|
767
781
|
resourceLoader: await this.createResourceLoader(workspaceId, sessionId, revision?.system ?? undefined),
|
|
768
782
|
});
|
|
@@ -778,6 +792,16 @@ export class PiSessionRunner {
|
|
|
778
792
|
await loader.reload();
|
|
779
793
|
return loader;
|
|
780
794
|
}
|
|
795
|
+
// Pi's ModelRuntime can only be built asynchronously, so the standalone
|
|
796
|
+
// default catalog is created on first use rather than in the constructor.
|
|
797
|
+
modelCatalog() {
|
|
798
|
+
// Reset on failure so one bad attempt is not cached for the runner's life.
|
|
799
|
+
this.modelCatalogPromise ??= createPrivatePiModelCatalog(this.opts.provider, this.opts.model).catch((error) => {
|
|
800
|
+
this.modelCatalogPromise = undefined;
|
|
801
|
+
throw error;
|
|
802
|
+
});
|
|
803
|
+
return this.modelCatalogPromise;
|
|
804
|
+
}
|
|
781
805
|
preparingSessionAgentContext(workspaceId, sessionId) {
|
|
782
806
|
const preparing = this.preparingSessionAgents.get(sessionId);
|
|
783
807
|
if (preparing?.workspaceId !== workspaceId)
|
|
@@ -876,7 +900,7 @@ export class PiSessionRunner {
|
|
|
876
900
|
}
|
|
877
901
|
catch {
|
|
878
902
|
// interruptSession callers observe abort errors; message delivery waits only
|
|
879
|
-
// for the abort window to close before deciding prompt vs
|
|
903
|
+
// for the abort window to close before deciding prompt vs steer.
|
|
880
904
|
}
|
|
881
905
|
}
|
|
882
906
|
evict(sessionId, handle) {
|
|
@@ -930,6 +954,9 @@ function updateRunning(handle, event) {
|
|
|
930
954
|
if (type === "agent_end")
|
|
931
955
|
handle.running = false;
|
|
932
956
|
}
|
|
957
|
+
function steerVerbatim(session, text) {
|
|
958
|
+
session.agent.steer({ role: "user", content: [{ type: "text", text }], timestamp: Date.now() });
|
|
959
|
+
}
|
|
933
960
|
function isAlreadyProcessing(error) {
|
|
934
961
|
return (error instanceof Error &&
|
|
935
962
|
error.message.includes(ALREADY_PROCESSING_MESSAGE));
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
const detected = new Map();
|
|
3
|
+
/**
|
|
4
|
+
* Which engine answers to `command`. Podman (directly, via the podman-docker
|
|
5
|
+
* shim, or via a `docker` symlink) accepts the Docker CLI except for a few
|
|
6
|
+
* flags, so docker-local speaks a small dialect instead of a separate provider.
|
|
7
|
+
* Detected once per command; anything that is not Podman is treated as Docker.
|
|
8
|
+
*/
|
|
9
|
+
export function detectContainerEngine(command) {
|
|
10
|
+
let engine = detected.get(command);
|
|
11
|
+
if (engine === undefined) {
|
|
12
|
+
// Not `--version`: Podman names itself after argv[0], so a `docker`
|
|
13
|
+
// symlink prints "docker version …". `version` always reports the client
|
|
14
|
+
// engine. Match the client only: a Docker CLI pointed at a Podman socket
|
|
15
|
+
// still sends Docker flags.
|
|
16
|
+
const result = spawnSync(command, ["version"], { encoding: "utf8" });
|
|
17
|
+
engine = /^Client:\s*Podman/m.test(result.stdout ?? "") ? "podman" : "docker";
|
|
18
|
+
detected.set(command, engine);
|
|
19
|
+
}
|
|
20
|
+
return engine;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* tmpfs ownership option for a mount owned by `uid` (same gid). Docker takes
|
|
24
|
+
* `uid=`/`gid=`; Podman 4.x rejects them ("unknown mount option") and instead
|
|
25
|
+
* offers `U`, which chowns the mount to the container's `--user`. Root
|
|
26
|
+
* ownership is Podman's default, so uid 0 needs no option there.
|
|
27
|
+
*/
|
|
28
|
+
export function tmpfsOwnerOption(engine, uid) {
|
|
29
|
+
if (engine === "podman")
|
|
30
|
+
return uid === 0 ? "" : ",U";
|
|
31
|
+
return `,uid=${uid},gid=${uid}`;
|
|
32
|
+
}
|
|
@@ -23,6 +23,7 @@ import { spawnSync } from "node:child_process";
|
|
|
23
23
|
import { mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
24
24
|
import { tmpdir } from "node:os";
|
|
25
25
|
import { join } from "node:path";
|
|
26
|
+
import { detectContainerEngine, } from "./container-engine.js";
|
|
26
27
|
const SIDECAR_LABEL_KEY = "open-managed-agents.egress-sidecar";
|
|
27
28
|
const SIDECAR_LABEL_VALUE = "docker-local";
|
|
28
29
|
const DEFAULT_READINESS_TIMEOUT_MS = 20_000;
|
|
@@ -95,6 +96,7 @@ export async function createEgressSidecar(opts) {
|
|
|
95
96
|
repoMount: opts.sidecarRepoMount,
|
|
96
97
|
user: currentUserSpec(),
|
|
97
98
|
labels: sidecarLabels(opts.labels, opts.sessionId),
|
|
99
|
+
engine: detectContainerEngine(opts.dockerCommand),
|
|
98
100
|
}), opts.operationTimeoutMs);
|
|
99
101
|
containerStarted = true;
|
|
100
102
|
// Dual-home: the run above put the sidecar on the default bridge (upstream
|
|
@@ -173,6 +175,12 @@ export function buildSidecarRunArgs(opts) {
|
|
|
173
175
|
`/tmp:rw,nosuid,nodev,size=${opts.tmpfsSize ?? DEFAULT_SIDECAR_TMPFS_SIZE}`,
|
|
174
176
|
"--user",
|
|
175
177
|
opts.user,
|
|
178
|
+
// Rootless Podman maps the host uid to container root, so `--user <uid>`
|
|
179
|
+
// alone could not read the 0600 bundle; keep-id preserves the mapping.
|
|
180
|
+
...(opts.engine === "podman" ? ["--userns=keep-id"] : []),
|
|
181
|
+
// Node as PID 1 does not exit on SIGTERM; keep `rm -f` immediate.
|
|
182
|
+
"--stop-timeout",
|
|
183
|
+
"0",
|
|
176
184
|
"--memory",
|
|
177
185
|
opts.memory ?? DEFAULT_SIDECAR_MEMORY,
|
|
178
186
|
"--pids-limit",
|
|
@@ -10,6 +10,7 @@ import { matchGlob } from "./glob.js";
|
|
|
10
10
|
import { createEgressSidecar, egressProxyUrl, reapEgressSidecars, } from "./docker-egress.js";
|
|
11
11
|
import { createSandboxInvocationStats, createSandboxToolDefinitions, recordSandboxInvocation, } from "./provider.js";
|
|
12
12
|
import { MAX_SESSION_OUTPUT_BYTES, MAX_SESSION_OUTPUT_FILE_BYTES, MAX_SESSION_OUTPUT_FILES, } from "../../../files/types.js";
|
|
13
|
+
import { detectContainerEngine, tmpfsOwnerOption, } from "./container-engine.js";
|
|
13
14
|
import { DEFAULT_OMA_SANDBOX_IMAGE } from "./image.js";
|
|
14
15
|
export const DEFAULT_DOCKER_SANDBOX_IMAGE = DEFAULT_OMA_SANDBOX_IMAGE;
|
|
15
16
|
const DEFAULT_WORKSPACE = "/workspace";
|
|
@@ -115,6 +116,7 @@ export async function createDockerSandboxProvider(workspaceId, sessionId, opts =
|
|
|
115
116
|
tmpfsSize: resolved.tmpfsSize,
|
|
116
117
|
outputsTmpfsSize: resolved.outputsTmpfsSize,
|
|
117
118
|
egress: opts.egress?.wiring,
|
|
119
|
+
engine: detectContainerEngine(resolved.dockerCommand),
|
|
118
120
|
labels: {
|
|
119
121
|
[SANDBOX_LABEL_KEY]: SANDBOX_LABEL_VALUE,
|
|
120
122
|
[OWNER_LABEL_KEY]: OWNER_LABEL_VALUE,
|
|
@@ -611,6 +613,8 @@ export function buildDockerRunArgs(opts) {
|
|
|
611
613
|
// Default deny (ADR 0016 §2): no egress wiring -> --network none, no proxy.
|
|
612
614
|
const network = opts.egress ? opts.egress.networkName : "none";
|
|
613
615
|
const egressArgs = opts.egress ? buildEgressRunArgs(opts.egress) : [];
|
|
616
|
+
const engine = opts.engine ?? "docker";
|
|
617
|
+
const sandboxOwner = tmpfsOwnerOption(engine, 65534);
|
|
614
618
|
return [
|
|
615
619
|
"run",
|
|
616
620
|
"-d",
|
|
@@ -631,14 +635,18 @@ export function buildDockerRunArgs(opts) {
|
|
|
631
635
|
"--security-opt",
|
|
632
636
|
"no-new-privileges",
|
|
633
637
|
"--read-only",
|
|
638
|
+
// PID 1 is `tail -f /dev/null`, which ignores SIGTERM: without this,
|
|
639
|
+
// Podman's `rm -f` waits the full 10 s stop timeout before SIGKILL.
|
|
640
|
+
"--stop-timeout",
|
|
641
|
+
"0",
|
|
634
642
|
"--tmpfs",
|
|
635
|
-
`${opts.workspacePath}:rw,exec,nosuid,nodev,
|
|
643
|
+
`${opts.workspacePath}:rw,exec,nosuid,nodev${sandboxOwner},mode=700,size=${opts.tmpfsSize}`,
|
|
636
644
|
"--tmpfs",
|
|
637
645
|
`${uploadsPath}:rw,nosuid,nodev,noexec,mode=755,size=${opts.tmpfsSize}`,
|
|
638
646
|
"--tmpfs",
|
|
639
|
-
`${outputsPath}:rw,nosuid,nodev,noexec,
|
|
647
|
+
`${outputsPath}:rw,nosuid,nodev,noexec${sandboxOwner},mode=700,size=${outputsTmpfsSize}`,
|
|
640
648
|
"--tmpfs",
|
|
641
|
-
`${DEFAULT_SKILLS_PATH}:rw,exec,nosuid,nodev,
|
|
649
|
+
`${DEFAULT_SKILLS_PATH}:rw,exec,nosuid,nodev${tmpfsOwnerOption(engine, 0)},mode=755,size=${opts.tmpfsSize}`,
|
|
642
650
|
"--workdir",
|
|
643
651
|
opts.workspacePath,
|
|
644
652
|
"--user",
|
|
@@ -694,10 +702,15 @@ export function buildDockerExtractIntoContainerArgs(containerName, destinationDi
|
|
|
694
702
|
];
|
|
695
703
|
}
|
|
696
704
|
export function buildDockerNormalizeUploadsArgs(containerName, uploadsPath) {
|
|
705
|
+
// --workdir /: the container workdir is /workspace (mode 700, uid 65534) and
|
|
706
|
+
// root has no CAP_DAC_OVERRIDE under --cap-drop ALL, so GNU find in the OMA
|
|
707
|
+
// image fails to save/restore a /workspace cwd.
|
|
697
708
|
return [
|
|
698
709
|
"exec",
|
|
699
710
|
"--user",
|
|
700
711
|
"0:0",
|
|
712
|
+
"--workdir",
|
|
713
|
+
"/",
|
|
701
714
|
containerName,
|
|
702
715
|
"sh",
|
|
703
716
|
"-c",
|
|
@@ -707,7 +720,7 @@ export function buildDockerNormalizeUploadsArgs(containerName, uploadsPath) {
|
|
|
707
720
|
];
|
|
708
721
|
}
|
|
709
722
|
export function buildDockerNormalizeSkillsArgs(containerName, skillsPath) {
|
|
710
|
-
return ["exec", "--user", "0:0", containerName, "sh", "-c", "chown -R 0:0 \"$1\" && find \"$1\" -type d -exec chmod 755 {} + && find \"$1\" -type f -exec chmod 644 {} + && find \"$1\" -path '*/scripts/*' -type f -exec chmod 755 {} +", "sh", skillsPath];
|
|
723
|
+
return ["exec", "--user", "0:0", "--workdir", "/", containerName, "sh", "-c", "chown -R 0:0 \"$1\" && find \"$1\" -type d -exec chmod 755 {} + && find \"$1\" -type f -exec chmod 644 {} + && find \"$1\" -path '*/scripts/*' -type f -exec chmod 755 {} +", "sh", skillsPath];
|
|
711
724
|
}
|
|
712
725
|
export function buildDockerExecShellArgs(containerName, script, args, opts = {}) {
|
|
713
726
|
const out = ["exec"];
|
|
@@ -905,7 +918,9 @@ export function buildDockerCmaGrepSearchCommand(root, ownershipToken, pattern, g
|
|
|
905
918
|
return {
|
|
906
919
|
script: [
|
|
907
920
|
"set -eu",
|
|
908
|
-
|
|
921
|
+
// -w: when the exec'd shell is already a process-group leader (Podman's
|
|
922
|
+
// exec), plain setsid forks and exits at once, dropping rg's output.
|
|
923
|
+
"exec setsid -w env \"OMA_GREP_OWNER=$2\" bash -c '",
|
|
909
924
|
"set -euo pipefail",
|
|
910
925
|
"printf \"%s\\0\" __OMA_GREP_READY__",
|
|
911
926
|
"emit_text_matches() {",
|
|
@@ -1065,11 +1065,26 @@ export function microsandboxResourceName(opts) {
|
|
|
1065
1065
|
export function execMicrosandboxCommand(command, args, opts = {}) {
|
|
1066
1066
|
return new Promise((resolve, reject) => {
|
|
1067
1067
|
const maxBuffer = opts.maxBuffer ?? DEFAULT_MICROSANDBOX_MAX_BUFFER;
|
|
1068
|
+
// Own process group, so a kill reaches grandchildren too. Otherwise a
|
|
1069
|
+
// forked descendant (Linux /bin/sh does not exec its last command) keeps
|
|
1070
|
+
// the stdio pipes open and "close" waits for it to exit on its own.
|
|
1068
1071
|
const child = spawn(command, [...args], {
|
|
1069
1072
|
cwd: opts.cwd,
|
|
1070
1073
|
env: opts.env,
|
|
1071
1074
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1075
|
+
detached: process.platform !== "win32",
|
|
1072
1076
|
});
|
|
1077
|
+
const killTree = () => {
|
|
1078
|
+
try {
|
|
1079
|
+
if (child.pid !== undefined && process.platform !== "win32")
|
|
1080
|
+
process.kill(-child.pid, "SIGKILL");
|
|
1081
|
+
else
|
|
1082
|
+
child.kill("SIGKILL");
|
|
1083
|
+
}
|
|
1084
|
+
catch {
|
|
1085
|
+
child.kill("SIGKILL");
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1073
1088
|
const stdout = [];
|
|
1074
1089
|
const stderr = [];
|
|
1075
1090
|
let outputBytes = 0;
|
|
@@ -1102,14 +1117,14 @@ export function execMicrosandboxCommand(command, args, opts = {}) {
|
|
|
1102
1117
|
return;
|
|
1103
1118
|
terminalError = new Error("aborted");
|
|
1104
1119
|
terminalError.name = "AbortError";
|
|
1105
|
-
|
|
1120
|
+
killTree();
|
|
1106
1121
|
};
|
|
1107
1122
|
const collect = (target, chunk, onStreamData) => {
|
|
1108
1123
|
outputBytes += chunk.byteLength;
|
|
1109
1124
|
if (outputBytes > maxBuffer) {
|
|
1110
1125
|
if (!terminalError) {
|
|
1111
1126
|
terminalError = new Error(`Microsandbox command output exceeded ${maxBuffer} bytes`);
|
|
1112
|
-
|
|
1127
|
+
killTree();
|
|
1113
1128
|
}
|
|
1114
1129
|
return;
|
|
1115
1130
|
}
|
|
@@ -1143,7 +1158,7 @@ export function execMicrosandboxCommand(command, args, opts = {}) {
|
|
|
1143
1158
|
fail(error);
|
|
1144
1159
|
}
|
|
1145
1160
|
if (opts.timeoutMs !== undefined) {
|
|
1146
|
-
timeout = setTimeout(
|
|
1161
|
+
timeout = setTimeout(killTree, opts.timeoutMs);
|
|
1147
1162
|
}
|
|
1148
1163
|
});
|
|
1149
1164
|
}
|
|
@@ -311,13 +311,16 @@ function createCmaGrepToolDefinition(_cwd, operations) {
|
|
|
311
311
|
}
|
|
312
312
|
export function createSandboxToolDefinitions(cwd, operations, invocations, disposed) {
|
|
313
313
|
return [
|
|
314
|
-
withToolCallAccounting("bash",
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
withToolCallAccounting("
|
|
319
|
-
withToolCallAccounting("
|
|
320
|
-
withToolCallAccounting("
|
|
314
|
+
withToolCallAccounting("bash",
|
|
315
|
+
// Pi >= 0.82 exports PI_SESSION_ID/PI_MODEL/... into bash by default;
|
|
316
|
+
// control-plane session metadata does not belong in the sandbox.
|
|
317
|
+
createBashToolDefinition(cwd, { operations: operations.bash, exposeSessionEnvironment: false }), invocations, disposed, cwd),
|
|
318
|
+
withToolCallAccounting("read", createReadToolDefinition(cwd, { operations: operations.read }), invocations, disposed, cwd),
|
|
319
|
+
withToolCallAccounting("write", createWriteToolDefinition(cwd, { operations: operations.write }), invocations, disposed, cwd),
|
|
320
|
+
withToolCallAccounting("edit", createEditToolDefinition(cwd, { operations: operations.edit }), invocations, disposed, cwd),
|
|
321
|
+
withToolCallAccounting("glob", createCmaGlobToolDefinition(cwd, operations.glob), invocations, disposed, cwd),
|
|
322
|
+
withToolCallAccounting("grep", createCmaGrepToolDefinition(cwd, operations.grep), invocations, disposed, cwd),
|
|
323
|
+
withToolCallAccounting("ls", createLsToolDefinition(cwd, { operations: operations.ls }), invocations, disposed, cwd),
|
|
321
324
|
];
|
|
322
325
|
}
|
|
323
326
|
function emptyToolCounts() {
|
|
@@ -344,14 +347,18 @@ function emptyToolCallIds() {
|
|
|
344
347
|
ls: new Set(),
|
|
345
348
|
};
|
|
346
349
|
}
|
|
347
|
-
function withToolCallAccounting(toolName, tool, invocations, disposed) {
|
|
350
|
+
function withToolCallAccounting(toolName, tool, invocations, disposed, cwd) {
|
|
348
351
|
return {
|
|
349
352
|
...tool,
|
|
350
353
|
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
351
354
|
if (disposed.value) {
|
|
352
355
|
throw new Error("Sandbox provider is disposed");
|
|
353
356
|
}
|
|
354
|
-
|
|
357
|
+
// Pi >= 0.85 resolves paths against ctx.cwd ahead of the tool's own
|
|
358
|
+
// cwd, and ctx.cwd is the control plane's process.cwd(). Pin it to the
|
|
359
|
+
// sandbox so relative paths and bash never land on the host directory.
|
|
360
|
+
const sandboxCtx = ctx === undefined ? ctx : { ...ctx, cwd };
|
|
361
|
+
return toolExecutionContext.run({ toolName, toolCallId }, () => tool.execute(toolCallId, params, signal, onUpdate, sandboxCtx));
|
|
355
362
|
},
|
|
356
363
|
};
|
|
357
364
|
}
|