@fungi.computer/truffle 0.3.1 → 0.4.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.
@@ -10,6 +10,7 @@ import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
10
10
  import { setTimeout as delay } from "node:timers/promises";
11
11
  import { AgentCreateRefused, AgentArchiveRefused, } from "@fungi.computer/shiitake/agent-control";
12
12
  import { localExtensions, LocalExtensionError, } from "./local-extensions.js";
13
+ import { deferExtensionArchiveNotices } from "./private-local-extension-archive-notice.js";
13
14
  import { readFile, realpath } from "node:fs/promises";
14
15
  import writeFileAtomic from "write-file-atomic";
15
16
  import { z } from "zod";
@@ -72,7 +73,11 @@ readRecoveryManifest = (path) => readFile(path, "utf8"),
72
73
  onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot(), meshLinksFactory = createLocalMeshLinks, onExecRuleMatch = () => undefined,
73
74
  /** Supplied by the listener after startup; individual Agents never close it. */
74
75
  serviceNative, onExecApprovalDecision = () => undefined) {
75
- const extensions = suppliedExtensions ?? localExtensions({ home: configRoot.home });
76
+ deferExtensionArchiveNotices(configRoot.home);
77
+ const extensions = suppliedExtensions ??
78
+ localExtensions({
79
+ home: configRoot.home,
80
+ });
76
81
  const recoveryPath = recoveryDirectory === undefined
77
82
  ? undefined
78
83
  : resolve(recoveryDirectory, "owners.json");
@@ -176,8 +181,9 @@ serviceNative, onExecApprovalDecision = () => undefined) {
176
181
  envelope.key !== mail.key ||
177
182
  mail.to.agentId !== address.agentId)
178
183
  return "refused";
179
- if (mail.from.sessionId === undefined &&
180
- !(mail.replyTo.length === 0 && mail.reply?.outcome === "undeliverable"))
184
+ if (mail.from.kind === "system" &&
185
+ !(mail.reply?.outcome === "undeliverable" &&
186
+ mail.reply.reason === "recipient_offline"))
181
187
  return "refused";
182
188
  const sessionlessAcceptance = mail.to.sessionId === undefined;
183
189
  // Only select the physical store here. Never bypass that runtime's
@@ -229,7 +235,9 @@ serviceNative, onExecApprovalDecision = () => undefined) {
229
235
  for (const project of projects)
230
236
  selections.set(project, await readProjectFungiMcpSelection(project));
231
237
  const candidate = {
232
- load: await localExtensions({ home: configRoot.home }).captureAgent(),
238
+ load: await localExtensions({
239
+ home: configRoot.home,
240
+ }).captureAgent(),
233
241
  mcp: selections,
234
242
  clientPath: path,
235
243
  };
@@ -801,8 +809,9 @@ serviceNative, onExecApprovalDecision = () => undefined) {
801
809
  const control = directory(invocationRoot);
802
810
  return {
803
811
  deliver: async (mail) => {
812
+ const senderAgentId = mail.from.kind === "agent" ? mail.from.agentId : undefined;
804
813
  const remote = [...meshDirectories.values()].find(({ record }) => record.invocationRoot === invocationRoot &&
805
- record.bindings.some((binding) => binding.agentId === mail.from.agentId));
814
+ record.bindings.some((binding) => binding.agentId === senderAgentId));
806
815
  if (remote !== undefined) {
807
816
  if (!remote.agents.some((agent) => agent.agentId === mail.to.agentId))
808
817
  throw new Error("Mesh mail recipient is outside this Team");
@@ -1001,9 +1010,10 @@ serviceNative, onExecApprovalDecision = () => undefined) {
1001
1010
  return model === null ? agent : { ...agent, model };
1002
1011
  });
1003
1012
  },
1004
- create: async ({ requestId, name, model, extensions: requested = [], createdBy }, signal) => {
1013
+ create: async ({ requestId, name, model, extensions: requested = [], createdBy, createdBySessionId, }, signal) => {
1005
1014
  ensureRunning();
1006
1015
  signal.throwIfAborted();
1016
+ z.string().min(1).max(256).parse(createdBySessionId);
1007
1017
  // An Agent creating an Agent cannot give it more than it holds. Refuse
1008
1018
  // before anything exists; never trim the request.
1009
1019
  const creatorDatabase = (await targets(signal)).get(createdBy);
@@ -1021,7 +1031,8 @@ serviceNative, onExecApprovalDecision = () => undefined) {
1021
1031
  throw error;
1022
1032
  }
1023
1033
  const key = `create:${JSON.stringify([invocationRoot, requestId])}`;
1024
- const createStore = await resolvePrivateLocalCreateStore(invocationRoot, requestId);
1034
+ const creator = { agentId: createdBy, sessionId: createdBySessionId };
1035
+ const createStore = await resolvePrivateLocalCreateStore(invocationRoot, requestId, creator);
1025
1036
  signal.throwIfAborted();
1026
1037
  ensureRunning();
1027
1038
  const databasePath = createStore.databasePath;
@@ -1045,6 +1056,7 @@ serviceNative, onExecApprovalDecision = () => undefined) {
1045
1056
  createRequestId: requestId,
1046
1057
  name,
1047
1058
  createdBy,
1059
+ createdBySessionId,
1048
1060
  mode: creatorProfile.mode,
1049
1061
  folder: creatorProfile.folder,
1050
1062
  agentControl: directory(invocationRoot),
@@ -1,5 +1,5 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
- import { readProcessStart } from "./private-local-process-identity.js";
2
+ import { identifyLocalServiceProcess } from "./private-local-process-identity.js";
3
3
  import { readServiceLeaseOwner, } from "./private-local-service-lease.js";
4
4
  /** How long a SIGTERM'd service may drain its owners before it is killed. */
5
5
  const terminationGraceMs = 5_000;
@@ -8,11 +8,11 @@ const terminationPollMs = 50;
8
8
  * Whether `service` is still the process running under its PID. A PID whose
9
9
  * start differs is refused. A separate read and signal still has a race window.
10
10
  */
11
- async function isServiceProcess(service) {
12
- const current = await readProcessStart(service.pid);
13
- if (current.kind === "unverifiable")
14
- throw new Error(`Fungi cannot verify the service process ${String(service.pid)} with sub-second precision on ${process.platform}, so it refuses to signal it. Close that service manually, then reopen Fungi.`);
15
- return current.kind === "running" && current.start === service.startedAt;
11
+ async function isServiceProcess(directory, service) {
12
+ const current = await identifyLocalServiceProcess({ ...service, directory });
13
+ if (current.kind === "other" && process.platform !== "linux")
14
+ throw new Error(`Cannot safely stop Fungi service pid ${String(service.pid)} in ${directory}: its command line does not identify this service. No process was signalled.`);
15
+ return current.kind === "ours";
16
16
  }
17
17
  /** Signal `pid`; false when it has already exited. */
18
18
  function signalService(pid, signal) {
@@ -33,12 +33,13 @@ async function signalIfStillRunning(directory, service, signal) {
33
33
  const owner = await readServiceLeaseOwner(directory);
34
34
  if (owner?.pid !== service.pid || owner.startedAt !== service.startedAt)
35
35
  return false;
36
- return (await isServiceProcess(owner)) && signalService(owner.pid, signal);
36
+ return ((await isServiceProcess(directory, owner)) &&
37
+ signalService(owner.pid, signal));
37
38
  }
38
- async function exitedWithin(service, ms) {
39
+ async function exitedWithin(directory, service, ms) {
39
40
  const deadline = Date.now() + ms;
40
41
  for (;;) {
41
- if (!(await isServiceProcess(service)))
42
+ if (!(await isServiceProcess(directory, service)))
42
43
  return true;
43
44
  if (Date.now() >= deadline)
44
45
  return false;
@@ -57,11 +58,11 @@ export async function terminatePrivateLocalService(directory, service) {
57
58
  throw new Error("The Fungi service runs inside this process; stop it from within instead of with fungi stop.");
58
59
  if (!(await signalIfStillRunning(directory, service, "SIGTERM")))
59
60
  return "stopped";
60
- if (await exitedWithin(service, terminationGraceMs))
61
+ if (await exitedWithin(directory, service, terminationGraceMs))
61
62
  return "stopped";
62
63
  if (!(await signalIfStillRunning(directory, service, "SIGKILL")))
63
64
  return "stopped";
64
- if (await exitedWithin(service, terminationGraceMs))
65
+ if (await exitedWithin(directory, service, terminationGraceMs))
65
66
  return "killed";
66
67
  throw new Error(`Local service process ${String(service.pid)} is still running after SIGKILL`);
67
68
  }
@@ -4,6 +4,7 @@ import { readBoundedResponse } from "./private-bounded-response.js";
4
4
  import { LocalSandboxUnavailable, sandboxUnavailableReasonSchema, } from "./private-local-containment.js";
5
5
  export { LocalSandboxUnavailable } from "./private-local-containment.js";
6
6
  import { capturePrivateLocalConfigRoot } from "./private-local-config-root.js";
7
+ import { takePendingExtensionArchiveNotices } from "./private-local-extension-archive-notice.js";
7
8
  import { stopAllResultSchema } from "./private-local-stop-all.js";
8
9
  import { LocalAgentSelectionAmbiguous, LocalSessionPathConflict, } from "./private-local-service-owner.js";
9
10
  export { LocalAgentSelectionAmbiguous, LocalSessionPathConflict, } from "./private-local-service-owner.js";
@@ -111,6 +112,16 @@ export function createPrivateLocalServiceManager(options = {}) {
111
112
  ? capturePrivateLocalExecPath(process.env.PATH ?? "/usr/bin:/bin", process.cwd())
112
113
  : parsePrivateLocalExecPath(options.executionPath);
113
114
  const expectedVersion = privateLocalServiceVersion();
115
+ async function reportPendingExtensionArchiveNotices() {
116
+ try {
117
+ const notices = await takePendingExtensionArchiveNotices(root.home);
118
+ for (const notice of notices)
119
+ process.stderr.write(`${notice}\n`);
120
+ }
121
+ catch (cause) {
122
+ logEvent("extension.archive-notice", cause);
123
+ }
124
+ }
114
125
  /**
115
126
  * Set once this client has reached a service. Only a starting client
116
127
  * replaces a service speaking another protocol. Same-protocol build changes
@@ -344,6 +355,7 @@ export function createPrivateLocalServiceManager(options = {}) {
344
355
  request.signal?.throwIfAborted();
345
356
  const service = await start(request);
346
357
  const selected = await postSelection(endpoint, input, service, request.signal);
358
+ await reportPendingExtensionArchiveNotices();
347
359
  const firstBase = routeBase(service, selected);
348
360
  /**
349
361
  * Where this connection's client currently talks. A restarted service
@@ -17,6 +17,12 @@ export declare function unreadableStoreReason(agentId: string): string;
17
17
  */
18
18
  export declare function isArchivedStore(path: string): boolean;
19
19
  export declare function listPrivateLocalAgentStores(root: string, visibility?: LocalStoreVisibility): Promise<readonly string[]>;
20
+ /** Entry-preserving survey: a symlink is archived as a link, not its target. */
21
+ export declare function probePrivateLocalAgentStoreEntries(home: string, maximum?: number): Promise<readonly Readonly<{
22
+ agentId: string;
23
+ entry: string;
24
+ databasePath: string;
25
+ }>[]>;
20
26
  /** A Local or Woodstock store outside the current schema; Local never migrates it. */
21
27
  export declare class LocalStoreSchemaError extends Error {
22
28
  readonly databasePath: string;
@@ -102,6 +102,37 @@ export async function listPrivateLocalAgentStores(root, visibility = "active") {
102
102
  }
103
103
  return [...paths];
104
104
  }
105
+ /** Entry-preserving survey: a symlink is archived as a link, not its target. */
106
+ export async function probePrivateLocalAgentStoreEntries(home, maximum = 512) {
107
+ const directory = join(home, ".fungi", "agents");
108
+ let entries;
109
+ try {
110
+ entries = await readdir(directory, { withFileTypes: true });
111
+ }
112
+ catch (cause) {
113
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
114
+ return [];
115
+ throw cause;
116
+ }
117
+ const stores = entries.filter((entry) => entry.isDirectory() || entry.isSymbolicLink());
118
+ if (stores.length > maximum)
119
+ throw new Error(`Fungi cannot survey ${String(stores.length)} Agent stores (maximum ${String(maximum)})`);
120
+ const candidates = [];
121
+ for (const store of stores) {
122
+ const entry = join(directory, store.name);
123
+ const databasePath = join(entry, "agent.sqlite");
124
+ try {
125
+ if ((await stat(databasePath)).isFile())
126
+ candidates.push({ agentId: store.name, entry, databasePath });
127
+ }
128
+ catch (cause) {
129
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
130
+ continue;
131
+ throw cause;
132
+ }
133
+ }
134
+ return candidates;
135
+ }
105
136
  const storedProfileSchema = z.strictObject({
106
137
  name: z.string().nullable(),
107
138
  createdBy: z.string().nullable(),
@@ -51,6 +51,7 @@ declare const answerResult: Schema.Union<readonly [Schema.Struct<{
51
51
  readonly kind: Schema.Literal<"policy">;
52
52
  }>]>>;
53
53
  readonly reason: Schema.NullOr<Schema.Literals<readonly ["not-found", "not-eligible", "already-answered", "stale-revision", "expired", "declined", "fenced", "not-granted", "read-only", "pending", "in-flight", "outcome-unknown", "identity-conflict"]>>;
54
+ readonly denialNote: Schema.optionalKey<Schema.String>;
54
55
  readonly requestedAt: Schema.Int;
55
56
  readonly expiresAt: Schema.Int;
56
57
  readonly updatedAt: Schema.Int;
@@ -62,6 +63,7 @@ declare const answerResult: Schema.Union<readonly [Schema.Struct<{
62
63
  }>, Schema.Struct<{
63
64
  readonly kind: Schema.Literal<"refused">;
64
65
  readonly reason: Schema.Literals<readonly ["not-found", "not-eligible", "already-answered", "stale-revision", "expired", "declined", "fenced", "not-granted", "read-only", "pending", "in-flight", "outcome-unknown", "identity-conflict"]>;
66
+ readonly denialNote: Schema.optionalKey<Schema.String>;
65
67
  }>, Schema.Struct<{
66
68
  readonly kind: Schema.Literal<"submitted">;
67
69
  }>, Schema.Struct<{
@@ -79,6 +81,7 @@ export declare const mcpFormApprovalContract: {
79
81
  readonly id: Schema.String;
80
82
  readonly decision: Schema.Literals<readonly ["approve", "deny", "always-allow"]>;
81
83
  readonly expectedRevision: Schema.Int;
84
+ readonly denialNote: Schema.optionalKey<Schema.String>;
82
85
  }>;
83
86
  output: Schema.Union<readonly [Schema.Struct<{
84
87
  readonly kind: Schema.Literal<"answered">;
@@ -115,6 +118,7 @@ export declare const mcpFormApprovalContract: {
115
118
  readonly kind: Schema.Literal<"policy">;
116
119
  }>]>>;
117
120
  readonly reason: Schema.NullOr<Schema.Literals<readonly ["not-found", "not-eligible", "already-answered", "stale-revision", "expired", "declined", "fenced", "not-granted", "read-only", "pending", "in-flight", "outcome-unknown", "identity-conflict"]>>;
121
+ readonly denialNote: Schema.optionalKey<Schema.String>;
118
122
  readonly requestedAt: Schema.Int;
119
123
  readonly expiresAt: Schema.Int;
120
124
  readonly updatedAt: Schema.Int;
@@ -126,6 +130,7 @@ export declare const mcpFormApprovalContract: {
126
130
  }>, Schema.Struct<{
127
131
  readonly kind: Schema.Literal<"refused">;
128
132
  readonly reason: Schema.Literals<readonly ["not-found", "not-eligible", "already-answered", "stale-revision", "expired", "declined", "fenced", "not-granted", "read-only", "pending", "in-flight", "outcome-unknown", "identity-conflict"]>;
133
+ readonly denialNote: Schema.optionalKey<Schema.String>;
129
134
  }>, Schema.Struct<{
130
135
  readonly kind: Schema.Literal<"submitted">;
131
136
  }>, Schema.Struct<{
@@ -171,6 +176,7 @@ export declare const mcpFormApprovalContract: {
171
176
  readonly kind: Schema.Literal<"policy">;
172
177
  }>]>>;
173
178
  readonly reason: Schema.NullOr<Schema.Literals<readonly ["not-found", "not-eligible", "already-answered", "stale-revision", "expired", "declined", "fenced", "not-granted", "read-only", "pending", "in-flight", "outcome-unknown", "identity-conflict"]>>;
179
+ readonly denialNote: Schema.optionalKey<Schema.String>;
174
180
  readonly requestedAt: Schema.Int;
175
181
  readonly expiresAt: Schema.Int;
176
182
  readonly updatedAt: Schema.Int;
@@ -183,10 +189,17 @@ export declare const mcpFormApprovalContract: {
183
189
  readonly command: Schema.String;
184
190
  readonly cwd: Schema.String;
185
191
  readonly justification: Schema.String;
192
+ readonly form: Schema.Literals<readonly ["direct", "shell"]>;
193
+ readonly kind: Schema.Literals<readonly ["exec", "extension-install"]>;
194
+ readonly argv: Schema.optionalKey<Schema.$Array<Schema.String>>;
186
195
  readonly prefix: Schema.optionalKey<Schema.$Array<Schema.String>>;
187
196
  readonly warning: Schema.optionalKey<Schema.String>;
188
197
  readonly alwaysAllowCoverage: Schema.optionalKey<Schema.String>;
189
198
  readonly alwaysAllowUnavailableReason: Schema.optionalKey<Schema.String>;
199
+ readonly risk: Schema.optionalKey<Schema.Struct<{
200
+ readonly summary: Schema.String;
201
+ readonly detail: Schema.String;
202
+ }>>;
190
203
  }>>;
191
204
  }>>;
192
205
  }>;
@@ -1,6 +1,6 @@
1
1
  import { ExtensionId, } from "@fungi.computer/shiitake/extensions";
2
2
  import * as Schema from "effect/Schema";
3
- import { ApprovalAnswerInputSchema, ApprovalRefusalSchema, ApprovalViewSchema, } from "@fungi.computer/approvals";
3
+ import { ApprovalAnswerInputSchema, ApprovalDenialNoteSchema, ApprovalRefusalSchema, ApprovalViewSchema, } from "@fungi.computer/approvals";
4
4
  /** The actual MCP form, not a ledger record or an authorization decision. */
5
5
  export const McpFormApprovalRequestSchema = Schema.Struct({
6
6
  message: Schema.String,
@@ -36,10 +36,14 @@ const McpFormPendingSchema = Schema.Struct({
36
36
  command: Schema.String,
37
37
  cwd: Schema.String,
38
38
  justification: Schema.String,
39
+ form: Schema.Literals(["direct", "shell"]),
40
+ kind: Schema.Literals(["exec", "extension-install"]),
41
+ argv: Schema.optionalKey(Schema.Array(Schema.String)),
39
42
  prefix: Schema.optionalKey(Schema.Array(Schema.String)),
40
43
  warning: Schema.optionalKey(Schema.String),
41
44
  alwaysAllowCoverage: Schema.optionalKey(Schema.String),
42
45
  alwaysAllowUnavailableReason: Schema.optionalKey(Schema.String),
46
+ risk: Schema.optionalKey(Schema.Struct({ summary: Schema.String, detail: Schema.String })),
43
47
  })),
44
48
  });
45
49
  /** The existing Session extension slot carries only display-safe form data. */
@@ -52,6 +56,7 @@ export const mcpFormApprovalContract = {
52
56
  id: Schema.String,
53
57
  decision: Schema.Literals(["approve", "deny", "always-allow"]),
54
58
  expectedRevision: ApprovalAnswerInputSchema.fields.expectedRevision,
59
+ denialNote: Schema.optionalKey(ApprovalDenialNoteSchema),
55
60
  }),
56
61
  output: answerResult,
57
62
  },
@@ -5,10 +5,17 @@ export type LocalApprovalReview = {
5
5
  command: string;
6
6
  cwd: string;
7
7
  justification: string;
8
+ form: "direct" | "shell";
9
+ kind: "exec" | "extension-install";
10
+ argv?: string[];
8
11
  prefix?: readonly string[];
9
12
  warning?: string;
10
13
  alwaysAllowCoverage?: string;
11
14
  alwaysAllowUnavailableReason?: string;
15
+ risk?: {
16
+ summary: string;
17
+ detail: string;
18
+ };
12
19
  };
13
20
  type Pending = {
14
21
  sessionId: string;
@@ -27,6 +34,7 @@ export declare function createMcpFormSession(): {
27
34
  readonly id: import("effect/Schema").String;
28
35
  readonly decision: import("effect/Schema").Literals<readonly ["approve", "deny", "always-allow"]>;
29
36
  readonly expectedRevision: import("effect/Schema").Int;
37
+ readonly denialNote: import("effect/Schema").optionalKey<import("effect/Schema").String>;
30
38
  }>;
31
39
  output: import("effect/Schema").Union<readonly [import("effect/Schema").Struct<{
32
40
  readonly kind: import("effect/Schema").Literal<"answered">;
@@ -63,6 +71,7 @@ export declare function createMcpFormSession(): {
63
71
  readonly kind: import("effect/Schema").Literal<"policy">;
64
72
  }>]>>;
65
73
  readonly reason: import("effect/Schema").NullOr<import("effect/Schema").Literals<readonly ["not-found", "not-eligible", "already-answered", "stale-revision", "expired", "declined", "fenced", "not-granted", "read-only", "pending", "in-flight", "outcome-unknown", "identity-conflict"]>>;
74
+ readonly denialNote: import("effect/Schema").optionalKey<import("effect/Schema").String>;
66
75
  readonly requestedAt: import("effect/Schema").Int;
67
76
  readonly expiresAt: import("effect/Schema").Int;
68
77
  readonly updatedAt: import("effect/Schema").Int;
@@ -74,6 +83,7 @@ export declare function createMcpFormSession(): {
74
83
  }>, import("effect/Schema").Struct<{
75
84
  readonly kind: import("effect/Schema").Literal<"refused">;
76
85
  readonly reason: import("effect/Schema").Literals<readonly ["not-found", "not-eligible", "already-answered", "stale-revision", "expired", "declined", "fenced", "not-granted", "read-only", "pending", "in-flight", "outcome-unknown", "identity-conflict"]>;
86
+ readonly denialNote: import("effect/Schema").optionalKey<import("effect/Schema").String>;
77
87
  }>, import("effect/Schema").Struct<{
78
88
  readonly kind: import("effect/Schema").Literal<"submitted">;
79
89
  }>, import("effect/Schema").Struct<{
@@ -115,6 +125,7 @@ export declare function createMcpFormSession(): {
115
125
  readonly expiresAt: number;
116
126
  readonly updatedAt: number;
117
127
  readonly revision: number;
128
+ readonly denialNote?: string;
118
129
  readonly evidence?: readonly {
119
130
  readonly kind: "artifact";
120
131
  readonly ref: string;
@@ -126,13 +137,20 @@ export declare function createMcpFormSession(): {
126
137
  readonly canSetAutoApprove: boolean;
127
138
  };
128
139
  readonly review?: {
140
+ readonly kind: "exec" | "extension-install";
141
+ readonly form: "direct" | "shell";
129
142
  readonly command: string;
130
143
  readonly cwd: string;
131
144
  readonly justification: string;
145
+ readonly argv?: readonly string[];
132
146
  readonly prefix?: readonly string[];
133
147
  readonly warning?: string;
134
148
  readonly alwaysAllowCoverage?: string;
135
149
  readonly alwaysAllowUnavailableReason?: string;
150
+ readonly risk?: {
151
+ readonly summary: string;
152
+ readonly detail: string;
153
+ };
136
154
  };
137
155
  }[];
138
156
  }, {
@@ -169,6 +187,7 @@ export declare function createMcpFormSession(): {
169
187
  readonly expiresAt: number;
170
188
  readonly updatedAt: number;
171
189
  readonly revision: number;
190
+ readonly denialNote?: string;
172
191
  readonly evidence?: readonly {
173
192
  readonly kind: "artifact";
174
193
  readonly ref: string;
@@ -180,13 +199,20 @@ export declare function createMcpFormSession(): {
180
199
  readonly canSetAutoApprove: boolean;
181
200
  };
182
201
  readonly review?: {
202
+ readonly kind: "exec" | "extension-install";
203
+ readonly form: "direct" | "shell";
183
204
  readonly command: string;
184
205
  readonly cwd: string;
185
206
  readonly justification: string;
207
+ readonly argv?: readonly string[];
186
208
  readonly prefix?: readonly string[];
187
209
  readonly warning?: string;
188
210
  readonly alwaysAllowCoverage?: string;
189
211
  readonly alwaysAllowUnavailableReason?: string;
212
+ readonly risk?: {
213
+ readonly summary: string;
214
+ readonly detail: string;
215
+ };
190
216
  };
191
217
  }[];
192
218
  }>;
@@ -49,15 +49,18 @@ export function createMcpFormSession() {
49
49
  if (input.decision === "always-allow" &&
50
50
  entry.alwaysAllow === undefined)
51
51
  return { kind: "refused", reason: "not-pending" };
52
+ if (input.denialNote !== undefined && input.decision !== "deny")
53
+ return { kind: "refused", reason: "not-pending" };
52
54
  if (input.decision === "always-allow")
53
55
  entry.alwaysAllow?.("prefix");
54
- const result = await entry.approvals.answer({
56
+ const answer = {
55
57
  recordId: entry.approval.record.recordId,
56
- decision: input.decision === "always-allow"
57
- ? "approve"
58
- : input.decision,
58
+ decision: input.decision === "deny" ? "deny" : "approve",
59
59
  expectedRevision: input.expectedRevision,
60
- });
60
+ };
61
+ const result = await entry.approvals.answer(input.denialNote === undefined
62
+ ? answer
63
+ : { ...answer, denialNote: input.denialNote });
61
64
  return result;
62
65
  }
63
66
  finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fungi.computer/truffle",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Truffle: Fungi's local Agent runtime, bundled into the fungi command",
6
6
  "type": "module",
@@ -50,6 +50,10 @@
50
50
  "types": "./dist/local-extensions.d.ts",
51
51
  "import": "./dist/local-extensions.js"
52
52
  },
53
+ "./upgrade": {
54
+ "types": "./dist/local-upgrade.d.ts",
55
+ "import": "./dist/local-upgrade.js"
56
+ },
53
57
  "./rules": {
54
58
  "types": "./dist/private-local-exec-rules.d.ts",
55
59
  "import": "./dist/private-local-exec-rules.js"
@@ -65,26 +69,26 @@
65
69
  "@cf-wasm/photon": "0.4.0",
66
70
  "@earendil-works/pi-ai": "1.3.0",
67
71
  "@effect/platform-node": "4.0.2",
68
- "@fungi.computer/agar": "0.3.1",
69
- "@fungi.computer/approvals": "0.1.0",
70
- "@fungi.computer/bird-dog": "0.2.0",
72
+ "@fungi.computer/agar": "0.4.0",
73
+ "@fungi.computer/approvals": "0.2.0",
74
+ "@fungi.computer/bird-dog": "0.2.1",
71
75
  "@fungi.computer/cairn": "0.1.0",
72
76
  "@fungi.computer/capnweb": "0.12.0",
73
77
  "@fungi.computer/carrier": "0.2.0",
74
78
  "@fungi.computer/cordyceps": "0.4.0",
75
79
  "@fungi.computer/gills": "0.2.0",
76
- "@fungi.computer/graft": "0.1.2",
77
- "@fungi.computer/knapsack": "0.3.0",
80
+ "@fungi.computer/graft": "0.1.3",
81
+ "@fungi.computer/knapsack": "0.3.1",
78
82
  "@fungi.computer/lantern": "0.3.0",
79
83
  "@fungi.computer/mycelium": "0.2.2",
80
- "@fungi.computer/nacre": "0.0.2",
84
+ "@fungi.computer/nacre": "0.0.3",
81
85
  "@fungi.computer/seam": "0.2.0",
82
- "@fungi.computer/shiitake": "0.4.0",
83
- "@fungi.computer/shiitake-extensions": "0.2.0",
84
- "@fungi.computer/veil": "0.2.0",
86
+ "@fungi.computer/shiitake": "0.5.0",
87
+ "@fungi.computer/shiitake-extensions": "0.3.0",
88
+ "@fungi.computer/veil": "0.2.1",
85
89
  "@fungi.computer/watchdog": "0.1.0",
86
- "@fungi.computer/whistle": "0.1.0",
87
- "@fungi.computer/zookeeper": "0.0.3",
90
+ "@fungi.computer/whistle": "0.2.0",
91
+ "@fungi.computer/zookeeper": "0.0.4",
88
92
  "@jellybrick/dbus-next": "0.11.4",
89
93
  "@modelcontextprotocol/client": "2.0.0",
90
94
  "@napi-rs/keyring": "2.1.2",
@@ -97,6 +101,8 @@
97
101
  "miniflare": "5.20260828.0-alpha",
98
102
  "proper-lockfile": "4.1.2",
99
103
  "re2js": "1.3.3",
104
+ "semver": "7.8.5",
105
+ "shell-quote": "1.10.0",
100
106
  "write-file-atomic": "8.0.0",
101
107
  "yaml": "2.9.0",
102
108
  "zod": "4.4.3"
@@ -107,6 +113,7 @@
107
113
  "@modelcontextprotocol/server": "2.0.0",
108
114
  "@types/node": "24.13.3",
109
115
  "@types/proper-lockfile": "4.1.4",
116
+ "@types/semver": "7.8.0",
110
117
  "@types/write-file-atomic": "4.0.3",
111
118
  "bun": "1.4.2",
112
119
  "bun-types": "1.4.1",
@@ -154,7 +161,7 @@
154
161
  "test:node": "../../node_modules/.bin/vitest run",
155
162
  "test:bun": "pnpm run test:mesh-link && pnpm run test:bun:batch && bun test ./test/private-workerd-deadline.bun.test.ts",
156
163
  "test:mesh-link": "bun test ./test/private-local-mesh-links.bun.test.ts",
157
- "test:bun:batch": "bun test ./test/private-sqlite-approval-driver.bun.test.ts ./test/mcp-form-service.bun.test.ts ./test/private-local-service-discovery.bun.test.ts ./test/private-local-service-listener.bun.test.ts ./test/private-local-service-manager.bun.test.ts ./test/private-local-service-rebind.bun.test.ts ./test/private-local-service-stop.bun.test.ts ./test/private-local-service-ensure.bun.test.ts ./test/private-local-service-identity.bun.test.ts ./test/private-local-service-kill-resume.bun.test.ts ./test/private-local-service-idle-owners.bun.test.ts ./test/private-local-service-idle-delegation.bun.test.ts ./test/private-local-mail-retry.bun.test.ts ./test/private-local-service-log-native.bun.test.ts ./test/private-local-service-log.bun.test.ts ./test/private-local-sqlite-lifetime.bun.test.ts ./test/private-sqlite-driver.bun.test.ts ./test/private-local-reload.bun.test.ts ./test/private-local-auth-rpc.bun.test.ts ./test/private-workerd-forage.bun.test.ts ./test/local-extensions.bun.test.ts ./test/local-zookeeper-slot.bun.test.ts ./test/local-extension-skills.bun.test.ts ./test/private-local-sandbox-service.bun.test.ts ./test/private-local-native-service.bun.test.ts",
164
+ "test:bun:batch": "bun test ./test/private-sqlite-approval-driver.bun.test.ts ./test/mcp-form-service.bun.test.ts ./test/private-local-service-discovery.bun.test.ts ./test/private-local-service-listener.bun.test.ts ./test/private-local-service-manager.bun.test.ts ./test/private-local-service-rebind.bun.test.ts ./test/private-local-service-stop.bun.test.ts ./test/private-local-service-ensure.bun.test.ts ./test/private-local-service-identity.bun.test.ts ./test/private-local-service-kill-resume.bun.test.ts ./test/private-local-service-idle-owners.bun.test.ts ./test/private-local-service-idle-delegation.bun.test.ts ./test/private-local-mail-retry.bun.test.ts ./test/private-local-service-log-native.bun.test.ts ./test/private-local-service-log.bun.test.ts ./test/private-local-sqlite-lifetime.bun.test.ts ./test/private-sqlite-driver.bun.test.ts ./test/private-local-reload.bun.test.ts ./test/private-local-auth-rpc.bun.test.ts ./test/private-workerd-forage.bun.test.ts ./test/local-extensions.bun.test.ts ./test/local-extension-archive.bun.test.ts ./test/local-zookeeper-slot.bun.test.ts ./test/local-extension-skills.bun.test.ts ./test/private-local-sandbox-service.bun.test.ts ./test/private-local-native-service.bun.test.ts",
158
165
  "build:native": "fungi-apply-patch-build --out native/bin && node scripts/build-bwrap.mjs --out native/bin && node scripts/generate-native-digests.mjs",
159
166
  "test:patch": "node --test --test-timeout=30000 test/apply-patch.native.mjs",
160
167
  "test:sandbox:linux": "node --import tsx test/fixtures/local-sandbox-proof.mts",