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
@@ -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
- const MAX_TOOL_DESCRIPTION_LENGTH = 4_096;
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 { AuthStorage, createAgentSession, createSyntheticSourceInfo, DefaultResourceLoader, ModelRegistry, SessionManager, } from "@earendil-works/pi-coding-agent";
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
- const authStorage = AuthStorage.create();
40
- const modelRegistry = ModelRegistry.create(authStorage);
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
- authStorage,
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
- authStorage;
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
- const catalog = opts.modelCatalog ?? createPrivatePiModelCatalog(opts.provider, opts.model);
95
- this.modelCatalog = catalog;
96
- this.authStorage = catalog.authStorage;
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
- await handle.session.followUp(text);
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 becameFollowUp = false;
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
- .prompt(text)
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
- await handle.session.followUp(text);
332
- // Best effort for the losing prompt race: once Pi confirms this turn
333
- // is a follow-up, discard overlap events captured by this temporary
334
- // subscriber. A pre-rejection event can still escape; in practice that
335
- // should be limited to early status frames, while the winning prompt
336
- // subscriber owns the full turn and follow-up output.
337
- becameFollowUp = true;
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 (becameFollowUp)
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 || becameFollowUp || queue.length > 0) {
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
- : this.modelCatalog.resolve(agentRevision.model);
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
- !this.modelCatalog.hasConfiguredAuth(revisionModel)) {
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 provider = revision?.model.provider ?? this.modelCatalog.defaultModel.provider;
739
- const modelId = revision?.model.id ?? this.modelCatalog.defaultModel.id;
740
- const model = revisionModel ?? this.modelCatalog.resolve({ provider, id: modelId });
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
- authStorage: this.authStorage,
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 follow-up.
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,uid=65534,gid=65534,mode=700,size=${opts.tmpfsSize}`,
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,uid=65534,gid=65534,mode=700,size=${outputsTmpfsSize}`,
647
+ `${outputsPath}:rw,nosuid,nodev,noexec${sandboxOwner},mode=700,size=${outputsTmpfsSize}`,
640
648
  "--tmpfs",
641
- `${DEFAULT_SKILLS_PATH}:rw,exec,nosuid,nodev,uid=0,gid=0,mode=755,size=${opts.tmpfsSize}`,
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
- "exec setsid env \"OMA_GREP_OWNER=$2\" bash -c '",
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
- child.kill("SIGKILL");
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
- child.kill("SIGKILL");
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(() => child.kill("SIGKILL"), opts.timeoutMs);
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", createBashToolDefinition(cwd, { operations: operations.bash }), invocations, disposed),
315
- withToolCallAccounting("read", createReadToolDefinition(cwd, { operations: operations.read }), invocations, disposed),
316
- withToolCallAccounting("write", createWriteToolDefinition(cwd, { operations: operations.write }), invocations, disposed),
317
- withToolCallAccounting("edit", createEditToolDefinition(cwd, { operations: operations.edit }), invocations, disposed),
318
- withToolCallAccounting("glob", createCmaGlobToolDefinition(cwd, operations.glob), invocations, disposed),
319
- withToolCallAccounting("grep", createCmaGrepToolDefinition(cwd, operations.grep), invocations, disposed),
320
- withToolCallAccounting("ls", createLsToolDefinition(cwd, { operations: operations.ls }), invocations, disposed),
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
- return toolExecutionContext.run({ toolName, toolCallId }, () => tool.execute(toolCallId, params, signal, onUpdate, ctx));
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
  }