@fungi.computer/truffle 0.2.1 → 0.3.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 (68) hide show
  1. package/dist/private-files-patch.js +39 -36
  2. package/dist/private-invocation-computer.d.ts +224 -5
  3. package/dist/private-invocation-computer.js +162 -22
  4. package/dist/private-invocation-files.d.ts +3 -2
  5. package/dist/private-invocation-files.js +22 -3
  6. package/dist/private-local-agent-control.d.ts +2 -2
  7. package/dist/private-local-bwrap.d.ts +17 -7
  8. package/dist/private-local-bwrap.js +73 -33
  9. package/dist/private-local-computer-processes.d.ts +15 -0
  10. package/dist/private-local-computer-processes.js +13 -0
  11. package/dist/private-local-containment.d.ts +18 -8
  12. package/dist/private-local-containment.js +70 -45
  13. package/dist/private-local-delegation.d.ts +3 -3
  14. package/dist/private-local-delegation.js +32 -19
  15. package/dist/private-local-exec-review.d.ts +10 -0
  16. package/dist/private-local-exec-review.js +50 -0
  17. package/dist/private-local-exec-rules.d.ts +14 -0
  18. package/dist/private-local-exec-rules.js +420 -0
  19. package/dist/private-local-git-exclude.js +25 -15
  20. package/dist/private-local-linux-sandbox.d.ts +5 -3
  21. package/dist/private-local-linux-sandbox.js +71 -25
  22. package/dist/private-local-mail.d.ts +2 -2
  23. package/dist/private-local-mesh-links.d.ts +4 -1
  24. package/dist/private-local-mesh-links.js +50 -3
  25. package/dist/private-local-native-digests.js +2 -0
  26. package/dist/private-local-native-install-error.d.ts +2 -0
  27. package/dist/private-local-native-install-error.js +10 -0
  28. package/dist/private-local-native-staging.d.ts +16 -0
  29. package/dist/private-local-native-staging.js +138 -0
  30. package/dist/private-local-permission-mode.d.ts +10 -0
  31. package/dist/private-local-permission-mode.js +18 -0
  32. package/dist/private-local-runtime-host.d.ts +19 -5
  33. package/dist/private-local-runtime-host.js +391 -71
  34. package/dist/private-local-sandbox-policy.d.ts +46 -6
  35. package/dist/private-local-sandbox-policy.js +350 -71
  36. package/dist/private-local-sandbox.d.ts +34 -18
  37. package/dist/private-local-sandbox.js +225 -60
  38. package/dist/private-local-seatbelt.d.ts +7 -13
  39. package/dist/private-local-seatbelt.js +55 -32
  40. package/dist/private-local-service-contract.d.ts +5 -4
  41. package/dist/private-local-service-discovery.d.ts +18 -6
  42. package/dist/private-local-service-discovery.js +4 -3
  43. package/dist/private-local-service-listener.js +40 -2
  44. package/dist/private-local-service-log.d.ts +8 -0
  45. package/dist/private-local-service-log.js +3 -0
  46. package/dist/private-local-service-owner.d.ts +18 -10
  47. package/dist/private-local-service-owner.js +117 -25
  48. package/dist/private-local-service.js +43 -9
  49. package/dist/private-local-session-discovery.d.ts +3 -2
  50. package/dist/private-local-session-discovery.js +18 -6
  51. package/dist/private-mcp-form-contract.d.ts +8 -1
  52. package/dist/private-mcp-form-contract.js +8 -1
  53. package/dist/private-mcp-form-session.d.ts +33 -3
  54. package/dist/private-mcp-form-session.js +28 -5
  55. package/dist/private-oyster-mcp-auth.d.ts +1 -1
  56. package/dist/private-oyster-mcp-auth.js +139 -7
  57. package/dist/private-provider-rejection.js +1 -1
  58. package/dist/seatbelt/safe-base.sbpl +2 -2
  59. package/native/bin/bwrap-build-source/Dockerfile +3 -1
  60. package/native/bin/bwrap-build-source/README.md +6 -6
  61. package/native/bin/bwrap-build-source/pin.mjs +2 -2
  62. package/native/bin/bwrap-build-source/record.mjs +12 -1
  63. package/native/bin/bwrap-build-source/vendor/seccomp-unix-block.c +10 -7
  64. package/native/bin/bwrap-build.json +3 -2
  65. package/native/bin/fungi-apply-patch +0 -0
  66. package/native/bin/fungi-seccomp-workspace.bpf +0 -0
  67. package/native/bin/fungi-seccomp-workspace.bpf.sha256 +1 -0
  68. package/package.json +25 -19
@@ -6,6 +6,10 @@ import { createServiceLogLifecycle, } from "./private-local-service-log.js";
6
6
  import { stopAllRequestSchema } from "./private-local-stop-all.js";
7
7
  import { FungiMcpSelectionConflict } from "./private-fungi-mcp-selection.js";
8
8
  import { LocalSandboxUnavailable } from "./private-local-containment.js";
9
+ import { createNativePayloadStaging, } from "./private-local-native-staging.js";
10
+ import { nativePackageStartupError } from "./private-local-native-install-error.js";
11
+ import { LocalBwrapUnavailable } from "./private-local-bwrap.js";
12
+ import { writeFileSync } from "node:fs";
9
13
  import { PrivateLocalAuthTarget } from "./private-local-auth-rpc.js";
10
14
  import { runLocalAuthCli } from "./auth-cli.js";
11
15
  import { capturePrivateLocalConfigRoot, } from "./private-local-config-root.js";
@@ -88,7 +92,12 @@ readRecoveryManifest, root = capturePrivateLocalConfigRoot()) {
88
92
  heap: jsc.heapStats(),
89
93
  }),
90
94
  });
91
- const owners = createLocalServiceOwners(openRuntime, directory, undefined, idleSweepMs, readRecoveryManifest, diagnostics.recoveryDisabled, root);
95
+ let native;
96
+ const owners = createLocalServiceOwners(openRuntime, directory, undefined, idleSweepMs, readRecoveryManifest, diagnostics.recoveryDisabled, root, undefined, diagnostics.ruleMatch, () => {
97
+ if (native === undefined)
98
+ throw new Error("Local service native staging is not ready");
99
+ return native;
100
+ });
92
101
  const prefix = `/service/${serviceId}`;
93
102
  const releaseAccountOwner = registerFungiAccountOwner(root.credentialPath, () => owners.refreshMesh());
94
103
  const app = new Hono();
@@ -182,7 +191,11 @@ readRecoveryManifest, root = capturePrivateLocalConfigRoot()) {
182
191
  };
183
192
  };
184
193
  const shutdown = createPrivateLocalServiceShutdown({
185
- closeOwners: owners.close,
194
+ closeOwners: async () => {
195
+ await owners.close();
196
+ // No Agent, including one reopened during recovery, still uses the copy.
197
+ native?.close();
198
+ },
186
199
  closeServer: async () => {
187
200
  await server?.close();
188
201
  },
@@ -233,6 +246,31 @@ readRecoveryManifest, root = capturePrivateLocalConfigRoot()) {
233
246
  const isLocalRequest = (request) => !request.headers.has("origin") &&
234
247
  new URL(request.url).hostname === "127.0.0.1";
235
248
  try {
249
+ // Verify and stage before publishing discovery, even without a selected Agent.
250
+ try {
251
+ native = createNativePayloadStaging(directory);
252
+ }
253
+ catch (cause) {
254
+ if (cause instanceof LocalBwrapUnavailable) {
255
+ // A detached child has no stderr. Only this classified startup refusal
256
+ // crosses to the starting client; never export arbitrary exception text.
257
+ const marker = process.env.FUNGI_NATIVE_STARTUP_REFUSAL;
258
+ if (marker !== undefined) {
259
+ try {
260
+ writeFileSync(marker, "native-package", {
261
+ flag: "wx",
262
+ mode: 0o600,
263
+ });
264
+ }
265
+ catch (reportFailure) {
266
+ // A broken report channel cannot replace the native refusal.
267
+ logEvent("service-listener.native-refusal-report", reportFailure);
268
+ }
269
+ }
270
+ throw nativePackageStartupError(cause);
271
+ }
272
+ throw cause;
273
+ }
236
274
  app.use("*", async (context, next) => {
237
275
  const request = context.req.raw;
238
276
  // Local client only: no browser-origin admission or DNS rebinding authority.
@@ -50,6 +50,11 @@ export type ServiceLogEvent = Readonly<{
50
50
  }> | Readonly<{
51
51
  event: "recovery-persistence-disabled";
52
52
  code: RecoveryPersistenceFailureCode;
53
+ }> | Readonly<{
54
+ event: "exec-rule-match";
55
+ agentId: string;
56
+ executable: string;
57
+ ruleId: string;
53
58
  }> | Readonly<{
54
59
  event: "memory";
55
60
  rss: number;
@@ -80,6 +85,9 @@ export type ServiceLogLifecycle = Readonly<{
80
85
  ready: (serviceId: string) => void;
81
86
  stopping: (reason: ServiceLogShutdownReason) => void;
82
87
  failure: (failure: FailureMetadata) => void;
88
+ ruleMatch: (match: Omit<Extract<ServiceLogEvent, {
89
+ event: "exec-rule-match";
90
+ }>, "event">) => void;
83
91
  recoveryDisabled: (code: RecoveryPersistenceFailureCode) => void;
84
92
  /** Synchronous, so it lands before the bounded drain exits the process. */
85
93
  drainTimeout: (drain: Readonly<{
@@ -287,6 +287,9 @@ export function createServiceLogLifecycle(input) {
287
287
  failure(failure) {
288
288
  emit({ event: "failure", failure });
289
289
  },
290
+ ruleMatch(match) {
291
+ emit({ event: "exec-rule-match", ...match });
292
+ },
290
293
  recoveryDisabled(code) {
291
294
  if (closed || recovery !== undefined)
292
295
  return;
@@ -6,7 +6,8 @@ import { type LocalExtensions } from "./local-extensions.js";
6
6
  import type { PrivateLocalServiceSelection, PrivateLocalSessionCreation } from "./private-local-service-contract.js";
7
7
  import { openPrivateLocalRuntimeHost, type PrivateLocalRuntimeHost } from "./private-local-runtime-host.js";
8
8
  import type { LocalUiAttachment } from "./private-local-ui-attachment.js";
9
- export type LocalServiceRuntime = Pick<PrivateLocalRuntimeHost, "databasePath" | "agentId" | "name" | "getSession" | "effectiveModel" | "setModel" | "setArchived" | "setRequireSandbox" | "sandboxStatus" | "pendingDelegation" | "assertModelReady" | "listSessions" | "createSession" | "deleteSession" | "extensions" | "prepareReload" | "bindSessionPath" | "acceptMail" | "readSettlement" | "idle" | "close">;
9
+ import { createLocalMeshLinks } from "./private-local-mesh-links.js";
10
+ export type LocalServiceRuntime = Pick<PrivateLocalRuntimeHost, "databasePath" | "agentId" | "name" | "getSession" | "effectiveModel" | "setModel" | "setArchived" | "setMode" | "sandboxStatus" | "pendingDelegation" | "assertModelReady" | "listSessions" | "createSession" | "deleteSession" | "extensions" | "prepareReload" | "bindSessionPath" | "acceptMail" | "readSettlement" | "idle" | "close">;
10
11
  export declare class LocalSessionPathConflict extends Error {
11
12
  readonly name = "LocalSessionPathConflict";
12
13
  constructor();
@@ -35,7 +36,9 @@ idleSweepMs?: number,
35
36
  /** Read the recovery manifest; tests can hold this I/O independently of opens. */
36
37
  readRecoveryManifest?: (path: string) => Promise<string>,
37
38
  /** Report only the classification, never the manifest or exception text. */
38
- onRecoveryDisabled?: ServiceLogLifecycle["recoveryDisabled"], configRoot?: PrivateLocalConfigRoot): {
39
+ onRecoveryDisabled?: ServiceLogLifecycle["recoveryDisabled"], configRoot?: PrivateLocalConfigRoot, meshLinksFactory?: typeof createLocalMeshLinks, onExecRuleMatch?: NonNullable<Parameters<typeof openPrivateLocalRuntimeHost>[0]["onExecRuleMatch"]>,
40
+ /** Supplied by the listener after startup; individual Agents never close it. */
41
+ serviceNative?: () => NonNullable<Parameters<typeof openPrivateLocalRuntimeHost>[0]["native"]>): {
39
42
  refreshMesh: () => Promise<void>;
40
43
  createSession: (creation: PrivateLocalSessionCreation) => Promise<{
41
44
  agentId: string;
@@ -59,6 +62,10 @@ onRecoveryDisabled?: ServiceLogLifecycle["recoveryDisabled"], configRoot?: Priva
59
62
  set: (preference: import("@fungi.computer/shiitake").ModelPreference | null) => Promise<void>;
60
63
  }>;
61
64
  read: (input?: import("@fungi.computer/shiitake").ReadOptions) => Promise<import("@fungi.computer/shiitake").SessionSnapshot>;
65
+ readSettlement: (commandId: string, options?: Readonly<{
66
+ timeoutMs?: number;
67
+ signal?: AbortSignal;
68
+ }>) => Promise<import("@fungi.computer/shiitake").CommandSettlement | undefined>;
62
69
  subscribe: (input: import("@fungi.computer/shiitake").SubscribeOptions) => AsyncIterable<import("@fungi.computer/shiitake").SessionFrame>;
63
70
  watch: (input?: import("@fungi.computer/shiitake").WatchOptions) => Promise<import("@fungi.computer/shiitake").SessionWatch>;
64
71
  }>;
@@ -87,6 +94,10 @@ onRecoveryDisabled?: ServiceLogLifecycle["recoveryDisabled"], configRoot?: Priva
87
94
  set: (preference: import("@fungi.computer/shiitake").ModelPreference | null) => Promise<void>;
88
95
  }>;
89
96
  read: (input?: import("@fungi.computer/shiitake").ReadOptions) => Promise<import("@fungi.computer/shiitake").SessionSnapshot>;
97
+ readSettlement: (commandId: string, options?: Readonly<{
98
+ timeoutMs?: number;
99
+ signal?: AbortSignal;
100
+ }>) => Promise<import("@fungi.computer/shiitake").CommandSettlement | undefined>;
90
101
  subscribe: (input: import("@fungi.computer/shiitake").SubscribeOptions) => AsyncIterable<import("@fungi.computer/shiitake").SessionFrame>;
91
102
  watch: (input?: import("@fungi.computer/shiitake").WatchOptions) => Promise<import("@fungi.computer/shiitake").SessionWatch>;
92
103
  }>;
@@ -108,15 +119,12 @@ onRecoveryDisabled?: ServiceLogLifecycle["recoveryDisabled"], configRoot?: Priva
108
119
  reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;
109
120
  }>;
110
121
  sandboxStatus: (selection: Pick<PrivateLocalServiceSelection, "invocationRoot" | "databasePath" | "fungiMcp">, signal: AbortSignal) => Promise<{
111
- state: "available";
112
- requireSandbox: boolean;
113
- } | {
114
- state: "unavailable";
115
- requireSandbox: boolean;
116
- reason: {
117
- kind: "missing" | "invalid-digest" | "digest-mismatch" | "not-executable" | "unsupported-platform" | "invalid-folder" | "backend-refused";
122
+ mode: "read-only" | "workspace" | "full-access";
123
+ available: boolean;
124
+ reason?: {
125
+ kind: "install" | "unsupported" | "folder" | "startup-refused";
118
126
  message: string;
119
- };
127
+ } | undefined;
120
128
  }>;
121
129
  sweepIdle: () => Promise<void>;
122
130
  archive: (request: Readonly<{
@@ -1,5 +1,7 @@
1
1
  import * as Effect from "effect/Effect";
2
+ import { parseDelegationReceipt } from "@fungi.computer/shiitake/client";
2
3
  import { logEvent } from "./log-event.js";
4
+ import { modeWithinCeiling } from "./private-local-permission-mode.js";
3
5
  import { createPrivateModels } from "./local-models.js";
4
6
  import { capturePrivateLocalConfigRoot, } from "./private-local-config-root.js";
5
7
  import { AdmissionGate } from "./private-local-admission-gate.js";
@@ -19,7 +21,6 @@ import { createPrivateLocalSessionRequestId, openPrivateLocalRuntimeHost, resolv
19
21
  import { parsePrivateLocalExecPath } from "./private-local-exec-path.js";
20
22
  import { createLocalMeshLinks, localMeshLinkKey, } from "./private-local-mesh-links.js";
21
23
  import { agentMailAddressSchema, machineAgentRequestSchema, machineDirectorySchema, } from "@fungi.computer/seam/machine-link-protocol";
22
- import { parseAccepted } from "@fungi.computer/shiitake/client";
23
24
  import { commandSettlementSchema } from "@fungi.computer/shiitake/fleet";
24
25
  import { BirdDogMailSchema } from "@fungi.computer/bird-dog/effect";
25
26
  export class LocalSessionPathConflict extends Error {
@@ -44,7 +45,10 @@ export class LocalAgentSelectionAmbiguous extends Error {
44
45
  // Reopen inputs only. Credentials remain with Local's account custodian.
45
46
  const recoveryOwnerSchema = selectionSchema
46
47
  .omit({ clientPath: true, sessionId: true, uiAttachmentLifetime: true })
47
- .extend({ databasePath: z.string().min(1) });
48
+ .extend({
49
+ databasePath: z.string().min(1),
50
+ wakeAt: z.number().optional(),
51
+ });
48
52
  const decodeRecoveryFile = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Array(Schema.Unknown)));
49
53
  class LocalRecoveryPersistenceDisabled extends Error {
50
54
  reason;
@@ -65,12 +69,40 @@ idleSweepMs = 60_000,
65
69
  /** Read the recovery manifest; tests can hold this I/O independently of opens. */
66
70
  readRecoveryManifest = (path) => readFile(path, "utf8"),
67
71
  /** Report only the classification, never the manifest or exception text. */
68
- onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot()) {
72
+ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot(), meshLinksFactory = createLocalMeshLinks, onExecRuleMatch = () => undefined,
73
+ /** Supplied by the listener after startup; individual Agents never close it. */
74
+ serviceNative) {
69
75
  const extensions = suppliedExtensions ?? localExtensions({ home: configRoot.home });
70
76
  const recoveryPath = recoveryDirectory === undefined
71
77
  ? undefined
72
78
  : resolve(recoveryDirectory, "owners.json");
73
79
  const recovery = new Map();
80
+ const wakes = new Map();
81
+ let wakeTimer;
82
+ function scheduleWakes() {
83
+ if (wakeTimer !== undefined)
84
+ clearTimeout(wakeTimer);
85
+ wakeTimer = undefined;
86
+ if (stopping || wakes.size === 0)
87
+ return;
88
+ let earliest = Infinity;
89
+ for (const entry of wakes.values())
90
+ earliest = Math.min(earliest, entry.wakeAt ?? Infinity);
91
+ wakeTimer = setTimeout(() => {
92
+ wakeTimer = undefined;
93
+ if (stopping)
94
+ return;
95
+ const due = [...wakes.values()].filter((entry) => entry.wakeAt !== undefined && entry.wakeAt <= Date.now());
96
+ for (const entry of due) {
97
+ wakes.delete(entry.databasePath);
98
+ void ownerFor(entry).catch((cause) => {
99
+ logEvent("service-owner.wake", cause);
100
+ });
101
+ }
102
+ scheduleWakes();
103
+ }, Math.min(2_147_483_647, Math.max(0, earliest - Date.now())));
104
+ wakeTimer.unref();
105
+ }
74
106
  let loadingRecovery;
75
107
  let saving = Promise.resolve();
76
108
  const emptySessions = new Map();
@@ -87,7 +119,7 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
87
119
  }
88
120
  return undefined;
89
121
  }
90
- const mesh = createLocalMeshLinks({
122
+ const mesh = meshLinksFactory({
91
123
  credentialPath: configRoot.credentialPath,
92
124
  receive: async (record, envelope) => {
93
125
  if (envelope.kind === "machineDirectory") {
@@ -142,13 +174,18 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
142
174
  })(envelope.event);
143
175
  if (envelope.kind !== "agentMail" ||
144
176
  envelope.key !== mail.key ||
145
- mail.to.agentId !== address.agentId ||
146
- new TextEncoder().encode(JSON.stringify(mail)).byteLength > 256 * 1024)
177
+ mail.to.agentId !== address.agentId)
147
178
  return "refused";
148
- // Only select the physical store here. Never rewrite frozen addresses
149
- // or bypass that runtime's own recipient/sender admission.
150
- const receipt = await admitted(owner, () => owner.acceptMail(mail));
151
- return { status: "accepted", receipt };
179
+ if (mail.from.sessionId === undefined &&
180
+ !(mail.replyTo.length === 0 && mail.reply?.outcome === "undeliverable"))
181
+ return "refused";
182
+ const sessionlessAcceptance = mail.to.sessionId === undefined;
183
+ // Only select the physical store here. Never bypass that runtime's
184
+ // recipient/sender admission; request keys make acceptance replay-safe.
185
+ const command = await admitted(owner, () => owner.acceptMail(mail));
186
+ if (sessionlessAcceptance && command.sessionId === undefined)
187
+ throw new Error("Sessionless mail acceptance returned no Session");
188
+ return { status: "accepted", receipt: command };
152
189
  }
153
190
  finally {
154
191
  release();
@@ -295,6 +332,8 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
295
332
  });
296
333
  pending = pending.then(async (owner) => {
297
334
  roots.set(owner.runtime.databasePath, invocationRoot);
335
+ if (wakes.delete(owner.runtime.databasePath))
336
+ scheduleWakes();
298
337
  if (recoveryPath !== undefined) {
299
338
  const reference = {
300
339
  invocationRoot,
@@ -523,9 +562,12 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
523
562
  }
524
563
  let runtime;
525
564
  let idle;
565
+ let wakeAt;
526
566
  try {
527
567
  ({ runtime } = await pending);
528
- idle = await runtime.idle();
568
+ const state = await runtime.idle();
569
+ idle = state.idle;
570
+ wakeAt = state.wakeAt;
529
571
  }
530
572
  catch (cause) {
531
573
  // One unreadable Agent stays open; the sweep still checks the rest.
@@ -542,7 +584,7 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
542
584
  idleCandidates.add(databasePath);
543
585
  continue;
544
586
  }
545
- closed.push(releaseStore(databasePath, pending, runtime));
587
+ closed.push(releaseStore(databasePath, pending, runtime, wakeAt));
546
588
  }
547
589
  await Promise.all(closed);
548
590
  })().finally(() => {
@@ -550,7 +592,18 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
550
592
  });
551
593
  return sweeping;
552
594
  }
553
- function releaseStore(databasePath, pending, runtime) {
595
+ /** Forget empty Agents; retain a future wake for the next service owner. */
596
+ function rememberIdleWake(databasePath, wakeAt) {
597
+ const reference = recovery.get(databasePath);
598
+ if (wakeAt === null || reference === undefined) {
599
+ recovery.delete(databasePath);
600
+ return undefined;
601
+ }
602
+ const scheduled = { ...reference, wakeAt };
603
+ recovery.set(databasePath, scheduled);
604
+ return scheduled;
605
+ }
606
+ function releaseStore(databasePath, pending, runtime, wakeAt) {
554
607
  idleCandidates.delete(databasePath);
555
608
  released.add(runtime);
556
609
  for (const [key, value] of owners)
@@ -558,7 +611,7 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
558
611
  owners.delete(key);
559
612
  emptySessions.delete(databasePath);
560
613
  roots.delete(databasePath);
561
- recovery.delete(databasePath);
614
+ const scheduled = rememberIdleWake(databasePath, wakeAt);
562
615
  const opened = openedByStore.get(databasePath);
563
616
  openedByStore.delete(databasePath);
564
617
  if (opened !== undefined)
@@ -570,6 +623,10 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
570
623
  catch (cause) {
571
624
  logEvent("service-owner.release-runtime", cause);
572
625
  }
626
+ if (scheduled !== undefined && !stopping) {
627
+ wakes.set(databasePath, scheduled);
628
+ scheduleWakes();
629
+ }
573
630
  await saveRecovery();
574
631
  })();
575
632
  releasing.set(databasePath, closed);
@@ -627,6 +684,16 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
627
684
  const invocationRoot = await realpath(selection.invocationRoot);
628
685
  const databasePath = await selectedDatabasePath(invocationRoot, selection.databasePath);
629
686
  ensureRunning();
687
+ if (selection.mode !== undefined && databasePath !== undefined) {
688
+ const child = readPrivateLocalAgentProfile(databasePath);
689
+ if (child.createdBy !== null) {
690
+ const stores = await listPrivateLocalAgentStores(invocationRoot);
691
+ const creatorPath = stores.find((path) => basename(dirname(path)) === child.createdBy);
692
+ if (creatorPath === undefined ||
693
+ !modeWithinCeiling(selection.mode, readPrivateLocalAgentProfile(creatorPath).mode))
694
+ throw new AgentCreateRefused("exceeds_creator", "a created Agent cannot exceed its creator's permission mode");
695
+ }
696
+ }
630
697
  const fungiMcp = selection.fungiMcp === undefined
631
698
  ? undefined
632
699
  : readFungiMcpSelection(selection.fungiMcp, {
@@ -662,11 +729,14 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
662
729
  invocationRoot,
663
730
  databasePath,
664
731
  // The host must persist/admit this BEFORE Shiitake can recover work.
665
- requireSandbox: selection.requireSandbox,
732
+ mode: selection.mode,
666
733
  agentControl: projectControl ? directory(invocationRoot) : undefined,
667
734
  mail: projectControl ? mailRouting(invocationRoot) : undefined,
668
735
  meshAdmission,
736
+ onExecRuleMatch,
669
737
  };
738
+ if (serviceNative !== undefined)
739
+ options = { ...options, native: serviceNative() };
670
740
  if (fungiMcp !== undefined)
671
741
  options = { ...options, fungiMcp };
672
742
  const captured = configuration;
@@ -683,8 +753,8 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
683
753
  fungiMcp !== undefined &&
684
754
  !sameFungiMcpConnection(owner.fungiMcp, fungiMcp))
685
755
  throw new FungiMcpSelectionConflict();
686
- if (!openingHere && selection.requireSandbox !== undefined)
687
- owner.runtime.setRequireSandbox(selection.requireSandbox);
756
+ if (!openingHere && selection.mode !== undefined)
757
+ owner.runtime.setMode(selection.mode);
688
758
  return owner.runtime;
689
759
  }
690
760
  async function select(selection, createRequestId) {
@@ -750,11 +820,15 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
750
820
  !("receipt" in result) ||
751
821
  result.status === "refused")
752
822
  throw new Error("Mesh recipient did not return command acceptance");
753
- return parseAccepted(JSON.stringify(result.receipt));
823
+ return parseDelegationReceipt(JSON.stringify(result.receipt));
754
824
  }
755
825
  const { owner, release } = await control.owner(mail.to.agentId, new AbortController().signal);
756
826
  try {
757
- return await admitted(owner, () => owner.acceptMail(mail));
827
+ const receipt = await admitted(owner, () => owner.acceptMail(mail));
828
+ if (mail.to.sessionId === undefined &&
829
+ receipt.sessionId === undefined)
830
+ throw new Error("Sessionless mail acceptance returned no Session");
831
+ return receipt;
758
832
  }
759
833
  finally {
760
834
  release();
@@ -935,7 +1009,7 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
935
1009
  if (creatorDatabase === undefined)
936
1010
  throw new AgentCreateRefused("no_creator", "the creating Agent is not in this Local project");
937
1011
  const creatorStore = dirname(creatorDatabase);
938
- const sandbox = readPrivateLocalAgentProfile(creatorDatabase);
1012
+ const creatorProfile = readPrivateLocalAgentProfile(creatorDatabase);
939
1013
  try {
940
1014
  await extensions.assertHeldByAgent(creatorStore, requested);
941
1015
  }
@@ -970,11 +1044,14 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
970
1044
  createRequestId: requestId,
971
1045
  name,
972
1046
  createdBy,
973
- requireSandbox: sandbox.requireSandbox,
974
- folder: sandbox.folder,
1047
+ mode: creatorProfile.mode,
1048
+ folder: creatorProfile.folder,
975
1049
  agentControl: directory(invocationRoot),
976
1050
  mail: mailRouting(invocationRoot),
1051
+ onExecRuleMatch,
977
1052
  };
1053
+ if (serviceNative !== undefined)
1054
+ options = { ...options, native: serviceNative() };
978
1055
  if (model !== undefined)
979
1056
  options = {
980
1057
  ...options,
@@ -1028,7 +1105,8 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
1028
1105
  checkModel: async ({ agentId, sessionId }, signal) => {
1029
1106
  const { owner, release } = await admittedOwner(agentId, signal);
1030
1107
  try {
1031
- await owner.assertModelReady(sessionId, signal);
1108
+ if (sessionId !== undefined)
1109
+ await owner.assertModelReady(sessionId, signal);
1032
1110
  }
1033
1111
  finally {
1034
1112
  release();
@@ -1060,6 +1138,8 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
1060
1138
  function close() {
1061
1139
  stopping = true;
1062
1140
  clearInterval(sweepTimer);
1141
+ if (wakeTimer !== undefined)
1142
+ clearTimeout(wakeTimer);
1063
1143
  closing ??= (async () => {
1064
1144
  const meshResults = await Promise.allSettled([mesh.close()]);
1065
1145
  // End our observers before deciding which Agents have work to resume.
@@ -1098,8 +1178,11 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
1098
1178
  const databasePath = owner.runtime.databasePath;
1099
1179
  const unused = () => !uses.has(databasePath) && !gates.has(databasePath);
1100
1180
  try {
1101
- if (unused() && (await owner.runtime.idle()) && unused())
1102
- recovery.delete(databasePath);
1181
+ if (unused()) {
1182
+ const state = await owner.runtime.idle();
1183
+ if (state.idle && unused())
1184
+ rememberIdleWake(databasePath, state.wakeAt);
1185
+ }
1103
1186
  }
1104
1187
  catch (cause) {
1105
1188
  logEvent("service-owner.read-idle", cause);
@@ -1184,6 +1267,14 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
1184
1267
  for (const selection of loaded.references) {
1185
1268
  if (stopping)
1186
1269
  break;
1270
+ if (selection.wakeAt !== undefined && selection.wakeAt > Date.now()) {
1271
+ // A client may already have opened this store while the manifest was
1272
+ // loading. Its runtime now owns the wake; do not arm a second one.
1273
+ await Promise.allSettled(pendingOpenings);
1274
+ if (!owners.has(`store:${selection.databasePath}`))
1275
+ wakes.set(selection.databasePath, selection);
1276
+ continue;
1277
+ }
1187
1278
  try {
1188
1279
  await ownerFor(selection);
1189
1280
  }
@@ -1199,6 +1290,7 @@ onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot
1199
1290
  recovery.delete(selection.databasePath);
1200
1291
  }
1201
1292
  }
1293
+ scheduleWakes();
1202
1294
  await saveRecovery();
1203
1295
  }
1204
1296
  /** A person archives or restores any Agent of one project folder. */
@@ -13,9 +13,12 @@ export { AgentArchiveRefused } from "@fungi.computer/shiitake/agent-control";
13
13
  import { newWebSocketRpcSession } from "@fungi.computer/capnweb";
14
14
  import { runPrivateLocalAuthRemote, } from "./private-local-auth-rpc.js";
15
15
  import { spawn } from "node:child_process";
16
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
17
+ import { join } from "node:path";
16
18
  import { fileURLToPath } from "node:url";
17
19
  import { setTimeout as delay } from "node:timers/promises";
18
20
  import { readInstalledLocalClientBuildId } from "./private-local-client-version.js";
21
+ import { nativePackageStartupError } from "./private-local-native-install-error.js";
19
22
  export { readInstalledLocalClientBuildId } from "./private-local-client-version.js";
20
23
  export { privateLocalServiceVersion } from "./private-local-service-discovery.js";
21
24
  import { createShiitakeClient, parseSessionSnapshot, } from "@fungi.computer/shiitake/client";
@@ -220,6 +223,7 @@ export function createPrivateLocalServiceManager(options = {}) {
220
223
  request.signal?.throwIfAborted();
221
224
  const directory = await serviceDirectory(selectedDirectory);
222
225
  const release = await acquireServiceLock(directory, "startup");
226
+ let nativeRefusalDirectory;
223
227
  try {
224
228
  const registration = await readServiceRegistration(directory);
225
229
  const existing = registration === undefined
@@ -232,17 +236,30 @@ export function createPrivateLocalServiceManager(options = {}) {
232
236
  if (await serviceOwnerIsLive(directory))
233
237
  return "busy";
234
238
  const bundledCli = process.env.FUNGI_BUNDLED_CLI;
235
- const child = spawn(options.executable ?? process.execPath, bundledCli === undefined
236
- ? [
237
- fileURLToPath(new URL("./private-local-service-process.js", import.meta.url)),
238
- directory,
239
- ]
240
- : [bundledCli, "--internal-service", directory], {
239
+ // A detached Bun service also starts in the invoking project's cwd.
240
+ // Keep the host process from importing Agent-authored bunfig or .env.
241
+ const bunFlags = options.executable === undefined && "bun" in process.versions
242
+ ? ["--config=/dev/null", "--no-env-file"]
243
+ : [];
244
+ const serviceEntry = bundledCli ??
245
+ fileURLToPath(new URL("./private-local-service-process.js", import.meta.url));
246
+ nativeRefusalDirectory = await mkdtemp(join(directory, ".native-startup-refusal-"));
247
+ const nativeRefusal = join(nativeRefusalDirectory, "native-package");
248
+ const child = spawn(options.executable ?? process.execPath, [
249
+ ...bunFlags,
250
+ serviceEntry,
251
+ ...(bundledCli === undefined ? [] : ["--internal-service"]),
252
+ directory,
253
+ ], {
241
254
  detached: true,
242
255
  stdio: "ignore",
243
256
  // Service startup retains its existing inherited inputs. This is
244
257
  // not a Computer exec child: only its selected host HOME is pinned.
245
- env: { ...process.env, HOME: root.home },
258
+ env: {
259
+ ...process.env,
260
+ HOME: root.home,
261
+ FUNGI_NATIVE_STARTUP_REFUSAL: nativeRefusal,
262
+ },
246
263
  });
247
264
  let failure;
248
265
  let exited = false;
@@ -265,8 +282,18 @@ export function createPrivateLocalServiceManager(options = {}) {
265
282
  throw new Error(`The started Local service reports version ${ready.version}, not this client's ${expectedVersion}`);
266
283
  return accept(ready);
267
284
  }
268
- if (hasExited())
285
+ if (hasExited()) {
286
+ const refusal = await readFile(nativeRefusal, "utf8").catch((cause) => {
287
+ if (cause instanceof Error &&
288
+ "code" in cause &&
289
+ cause.code === "ENOENT")
290
+ return undefined;
291
+ throw cause;
292
+ });
293
+ if (refusal === "native-package")
294
+ throw nativePackageStartupError();
269
295
  throw new Error("Local service exited before its handshake");
296
+ }
270
297
  await delay(100, undefined, { signal: request.signal });
271
298
  }
272
299
  child.kill("SIGTERM");
@@ -277,7 +304,14 @@ export function createPrivateLocalServiceManager(options = {}) {
277
304
  : `Local service did not become ready; child ${String(child.pid)} is still running`);
278
305
  }
279
306
  finally {
280
- await release();
307
+ // The token is per spawn and cannot survive a failed or successful start.
308
+ try {
309
+ if (nativeRefusalDirectory !== undefined)
310
+ await rm(nativeRefusalDirectory, { recursive: true, force: true });
311
+ }
312
+ finally {
313
+ await release();
314
+ }
281
315
  }
282
316
  }
283
317
  /** POST one selection to `service`; every refusal is a typed failure. */
@@ -32,12 +32,13 @@ export declare function readPrivateLocalAgentProfile(path: string): {
32
32
  name: string | undefined;
33
33
  createdBy: string | null;
34
34
  archived: boolean;
35
- requireSandbox: boolean;
35
+ mode: "read-only" | "workspace" | "full-access";
36
+ writableFolders: string[];
36
37
  folder: string;
37
38
  model: {
38
39
  readonly providerId: string;
39
40
  readonly modelId: string;
40
- readonly reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;
41
+ readonly reasoning?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
41
42
  } | null;
42
43
  };
43
44
  export declare function readPrivateLocalSessionSummaries(path: string): Promise<readonly LocalSummary[]>;