@fungi.computer/truffle 0.3.0 → 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.
- package/dist/local-extensions.js +108 -33
- package/dist/local-login.js +9 -3
- package/dist/local-upgrade.d.ts +63 -0
- package/dist/local-upgrade.js +358 -0
- package/dist/private-local-agent-control.d.ts +3 -0
- package/dist/private-local-agent-control.js +5 -1
- package/dist/private-local-delegation.js +12 -5
- package/dist/private-local-exec-review.d.ts +3 -1
- package/dist/private-local-exec-review.js +66 -17
- package/dist/private-local-exec-rules.d.ts +5 -3
- package/dist/private-local-exec-rules.js +30 -9
- package/dist/private-local-extension-archive-notice.d.ts +8 -0
- package/dist/private-local-extension-archive-notice.js +108 -0
- package/dist/private-local-extension-records.d.ts +25 -0
- package/dist/private-local-extension-records.js +51 -0
- package/dist/private-local-process-identity.d.ts +14 -0
- package/dist/private-local-process-identity.js +51 -0
- package/dist/private-local-runtime-host.d.ts +16 -1
- package/dist/private-local-runtime-host.js +92 -37
- package/dist/private-local-service-discovery.js +2 -3
- package/dist/private-local-service-listener.js +1 -1
- package/dist/private-local-service-log.d.ts +10 -0
- package/dist/private-local-service-log.js +3 -0
- package/dist/private-local-service-owner.d.ts +1 -1
- package/dist/private-local-service-owner.js +22 -8
- package/dist/private-local-service-termination.js +12 -11
- package/dist/private-local-service.js +12 -0
- package/dist/private-local-session-discovery.d.ts +6 -0
- package/dist/private-local-session-discovery.js +31 -0
- package/dist/private-mcp-form-contract.d.ts +15 -0
- package/dist/private-mcp-form-contract.js +8 -1
- package/dist/private-mcp-form-session.d.ts +32 -0
- package/dist/private-mcp-form-session.js +8 -5
- package/package.json +20 -13
|
@@ -6,8 +6,9 @@ import { logEvent } from "./log-event.js";
|
|
|
6
6
|
import { localArchiveHandoff } from "./local-archive-handoff.js";
|
|
7
7
|
import { createMcpFormSession } from "./private-mcp-form-session.js";
|
|
8
8
|
import { localExecEscapeReview } from "./private-local-exec-review.js";
|
|
9
|
-
import { ApprovalLedger, } from "@fungi.computer/approvals";
|
|
9
|
+
import { ApprovalLedger, denialFeedbackOpening, } from "@fungi.computer/approvals";
|
|
10
10
|
import { localExtensions, } from "./local-extensions.js";
|
|
11
|
+
import { deferExtensionArchiveNotices } from "./private-local-extension-archive-notice.js";
|
|
11
12
|
import { lstat, mkdir, realpath, stat } from "node:fs/promises";
|
|
12
13
|
import { lstatSync, mkdirSync, realpathSync } from "node:fs";
|
|
13
14
|
import { basename, dirname, isAbsolute, join, relative, resolve, } from "node:path";
|
|
@@ -19,7 +20,7 @@ import { parseModelPreference, } from "@fungi.computer/shiitake/model-preference
|
|
|
19
20
|
import { AgentCreateRefused, createAgentControlSource, } from "@fungi.computer/shiitake/agent-control";
|
|
20
21
|
import { defineIdFamily } from "@fungi.computer/cairn";
|
|
21
22
|
import { words } from "@fungi.computer/cairn/words";
|
|
22
|
-
import { botanicalExtensionsWithSkills } from "@fungi.computer/shiitake-extensions/roster";
|
|
23
|
+
import { botanicalExtensionsWithSkills, botanicalExtensionsWithSubagents, } from "@fungi.computer/shiitake-extensions/roster";
|
|
23
24
|
import { homeExtension, homeGrant, } from "@fungi.computer/shiitake-extensions/home/host";
|
|
24
25
|
import { Knapsack } from "@fungi.computer/knapsack";
|
|
25
26
|
import { makeHost, Shiitake, ShiitakeError, } from "@fungi.computer/shiitake";
|
|
@@ -49,7 +50,7 @@ import { createPrivateSqliteOwner, openPrivateWritableSqliteDatabase, } from "./
|
|
|
49
50
|
import { createPrivateLocalMailRoom, hasPrivateLocalPendingMail, } from "./private-local-mail.js";
|
|
50
51
|
import { createPrivateLocalDelegation, } from "./private-local-delegation.js";
|
|
51
52
|
import { createPrivateLocalAgentControl, } from "./private-local-agent-control.js";
|
|
52
|
-
import {
|
|
53
|
+
import {} from "@fungi.computer/shiitake-extensions/subagents/host";
|
|
53
54
|
const initialSessionId = "main";
|
|
54
55
|
const maximumInstructionReadBytes = 128 * 1_024;
|
|
55
56
|
const maximumInstructionSnapshotBytes = 32_000;
|
|
@@ -207,9 +208,15 @@ async function assertPrivateLocalReceiptFile(path, kind) {
|
|
|
207
208
|
}
|
|
208
209
|
}
|
|
209
210
|
/** Resolve an Agent create request through its durable Local receipt. */
|
|
210
|
-
export async function resolvePrivateLocalCreateStore(root, requestId) {
|
|
211
|
+
export async function resolvePrivateLocalCreateStore(root, requestId, creator) {
|
|
211
212
|
if (requestId.length === 0 || requestId.length > 256)
|
|
212
213
|
throw new Error("Local Agent create request id is invalid");
|
|
214
|
+
const creatorAgentId = creator === undefined
|
|
215
|
+
? undefined
|
|
216
|
+
: z.string().min(1).max(256).parse(creator.agentId);
|
|
217
|
+
const creatorSessionId = creator === undefined
|
|
218
|
+
? undefined
|
|
219
|
+
: z.string().min(1).max(256).parse(creator.sessionId);
|
|
213
220
|
await ensurePrivateLocalGitExclude(root);
|
|
214
221
|
const fungiDirectory = join(root, ".fungi");
|
|
215
222
|
const agentsDirectory = join(fungiDirectory, "agents");
|
|
@@ -226,13 +233,27 @@ export async function resolvePrivateLocalCreateStore(root, requestId) {
|
|
|
226
233
|
CREATE TABLE IF NOT EXISTS ${localCreateReceiptTable} (
|
|
227
234
|
request_id TEXT PRIMARY KEY NOT NULL,
|
|
228
235
|
agent_id TEXT NOT NULL UNIQUE,
|
|
229
|
-
name TEXT
|
|
236
|
+
name TEXT,
|
|
237
|
+
created_by_agent_id TEXT,
|
|
238
|
+
created_by_session_id TEXT
|
|
230
239
|
) WITHOUT ROWID
|
|
231
240
|
`);
|
|
241
|
+
const receiptColumns = database
|
|
242
|
+
.prepare(`PRAGMA table_info(${localCreateReceiptTable})`)
|
|
243
|
+
.all()
|
|
244
|
+
.map((column) => column.name);
|
|
245
|
+
if (!receiptColumns.includes("created_by_agent_id"))
|
|
246
|
+
database.exec(`ALTER TABLE ${localCreateReceiptTable} ADD COLUMN created_by_agent_id TEXT`);
|
|
247
|
+
if (!receiptColumns.includes("created_by_session_id"))
|
|
248
|
+
database.exec(`ALTER TABLE ${localCreateReceiptTable} ADD COLUMN created_by_session_id TEXT`);
|
|
232
249
|
const existing = database
|
|
233
|
-
.prepare(`SELECT agent_id FROM ${localCreateReceiptTable} WHERE request_id = ?`)
|
|
250
|
+
.prepare(`SELECT agent_id, created_by_agent_id, created_by_session_id FROM ${localCreateReceiptTable} WHERE request_id = ?`)
|
|
234
251
|
.get(requestId);
|
|
235
252
|
if (existing !== undefined) {
|
|
253
|
+
if (creatorAgentId !== undefined &&
|
|
254
|
+
(existing.created_by_agent_id !== creatorAgentId ||
|
|
255
|
+
existing.created_by_session_id !== creatorSessionId))
|
|
256
|
+
throw new AgentCreateRefused("request_conflict", "the create request id is already bound to a different creating Agent or Session");
|
|
236
257
|
const parsed = localHostIds.store.schema.safeParse(existing.agent_id);
|
|
237
258
|
if (!parsed.success)
|
|
238
259
|
throw new Error("Local Agent create receipt is invalid");
|
|
@@ -245,7 +266,7 @@ export async function resolvePrivateLocalCreateStore(root, requestId) {
|
|
|
245
266
|
};
|
|
246
267
|
}
|
|
247
268
|
let agentId;
|
|
248
|
-
const insert = database.prepare(`INSERT OR IGNORE INTO ${localCreateReceiptTable} (request_id, agent_id) VALUES (?, ?)`);
|
|
269
|
+
const insert = database.prepare(`INSERT OR IGNORE INTO ${localCreateReceiptTable} (request_id, agent_id, created_by_agent_id, created_by_session_id) VALUES (?, ?, ?, ?)`);
|
|
249
270
|
for (let attempt = 0; attempt < maximumReceiptIdAttempts; attempt += 1) {
|
|
250
271
|
const candidate = localHostIds.store.generate();
|
|
251
272
|
const candidateDirectory = join(agentsDirectory, candidate);
|
|
@@ -267,7 +288,7 @@ export async function resolvePrivateLocalCreateStore(root, requestId) {
|
|
|
267
288
|
metadata.isSymbolicLink() ||
|
|
268
289
|
realpathSync(candidateDirectory) !== candidateDirectory)
|
|
269
290
|
throw new Error("Local Agent create directory is not project owned");
|
|
270
|
-
if (insert.run(requestId, candidate).changes > 0) {
|
|
291
|
+
if (insert.run(requestId, candidate, creatorAgentId ?? null, creatorSessionId ?? null).changes > 0) {
|
|
271
292
|
agentId = candidate;
|
|
272
293
|
break;
|
|
273
294
|
}
|
|
@@ -295,7 +316,7 @@ export async function resolvePrivateLocalCreateStore(root, requestId) {
|
|
|
295
316
|
database.close();
|
|
296
317
|
}
|
|
297
318
|
}
|
|
298
|
-
async function localDatabasePaths(root, requested, createRequestId) {
|
|
319
|
+
async function localDatabasePaths(root, requested, createRequestId, creator) {
|
|
299
320
|
if (requested !== undefined && createRequestId !== undefined)
|
|
300
321
|
throw new Error("Local Agent database and create request are mutually exclusive");
|
|
301
322
|
if (requested !== undefined) {
|
|
@@ -311,7 +332,7 @@ async function localDatabasePaths(root, requested, createRequestId) {
|
|
|
311
332
|
}
|
|
312
333
|
const createStore = createRequestId === undefined
|
|
313
334
|
? undefined
|
|
314
|
-
: await resolvePrivateLocalCreateStore(root, createRequestId);
|
|
335
|
+
: await resolvePrivateLocalCreateStore(root, createRequestId, creator);
|
|
315
336
|
const createDatabasePath = createStore?.databasePath;
|
|
316
337
|
const generatedAgentId = createStore?.agentId ?? localHostIds.store.generate();
|
|
317
338
|
const selectedParent = createDatabasePath === undefined
|
|
@@ -466,7 +487,7 @@ export function createPrivateLocalModelSource(models, providerId, modelId, provi
|
|
|
466
487
|
signal,
|
|
467
488
|
});
|
|
468
489
|
if (startup === null)
|
|
469
|
-
throw new ShiitakeError("model", "rejected", "No signed-in model provider is available.
|
|
490
|
+
throw new ShiitakeError("model", "rejected", "No signed-in model provider is available. Use your own Claude or ChatGPT subscription: /login in a Session, or fungi login --provider anthropic --auth-type oauth (Claude) or fungi login --provider openai --auth-type oauth (ChatGPT) in the CLI. For hosted Fungi: fungi login --provider fungi.");
|
|
470
491
|
const choice = startup.preference;
|
|
471
492
|
const selectedProvider = choice.providerId;
|
|
472
493
|
if (selectedProvider === "fungi") {
|
|
@@ -778,6 +799,7 @@ subagents, configuration, configRoot = capturePrivateLocalConfigRoot(), sandbox,
|
|
|
778
799
|
: [
|
|
779
800
|
createPrivateLocalComputerSource(root, port, sessionPath, processes, configRoot.home, sandbox?.launch, currentMode, escapeForSession, policyForSession),
|
|
780
801
|
];
|
|
802
|
+
deferExtensionArchiveNotices(configRoot.home);
|
|
781
803
|
const installed = configuration?.installed ??
|
|
782
804
|
(storeDirectory === undefined
|
|
783
805
|
? { roster: [], grants: [], skills: [], diagnostics: [] }
|
|
@@ -793,9 +815,12 @@ subagents, configuration, configRoot = capturePrivateLocalConfigRoot(), sandbox,
|
|
|
793
815
|
const archive = storeDirectory === undefined
|
|
794
816
|
? { roster: [], grants: [] }
|
|
795
817
|
: localArchiveHandoff(storeDirectory, installed.grants);
|
|
818
|
+
const composed = subagents === undefined
|
|
819
|
+
? base
|
|
820
|
+
: botanicalExtensionsWithSubagents(base, subagents.source);
|
|
796
821
|
const extensions = {
|
|
797
822
|
roster: [
|
|
798
|
-
...
|
|
823
|
+
...composed.roster,
|
|
799
824
|
forms.extension,
|
|
800
825
|
homeExtension({
|
|
801
826
|
computers: {
|
|
@@ -808,20 +833,16 @@ subagents, configuration, configRoot = capturePrivateLocalConfigRoot(), sandbox,
|
|
|
808
833
|
],
|
|
809
834
|
},
|
|
810
835
|
}),
|
|
811
|
-
...(subagents === undefined
|
|
812
|
-
? []
|
|
813
|
-
: [subagentsExtension({ source: subagents.source })]),
|
|
814
836
|
...archive.roster,
|
|
815
837
|
...installed.roster,
|
|
816
838
|
],
|
|
817
839
|
grants: [
|
|
818
|
-
...
|
|
840
|
+
...composed.grants,
|
|
819
841
|
{
|
|
820
842
|
id: forms.extension.manifest.id,
|
|
821
843
|
capabilities: forms.extension.manifest.requests,
|
|
822
844
|
},
|
|
823
845
|
homeGrant,
|
|
824
|
-
...(subagents === undefined ? [] : [subagentsGrant]),
|
|
825
846
|
...archive.grants,
|
|
826
847
|
...installed.grants,
|
|
827
848
|
],
|
|
@@ -1002,7 +1023,12 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
|
|
|
1002
1023
|
if (reason !== undefined)
|
|
1003
1024
|
throw new LocalSandboxUnavailable(reason);
|
|
1004
1025
|
}
|
|
1005
|
-
const paths = await localDatabasePaths(root, input.databasePath, input.createRequestId
|
|
1026
|
+
const paths = await localDatabasePaths(root, input.databasePath, input.createRequestId, input.createdBy === undefined || input.createdBySessionId === undefined
|
|
1027
|
+
? undefined
|
|
1028
|
+
: {
|
|
1029
|
+
agentId: input.createdBy,
|
|
1030
|
+
sessionId: input.createdBySessionId,
|
|
1031
|
+
});
|
|
1006
1032
|
const runtime = localRuntimeOwner();
|
|
1007
1033
|
let lease;
|
|
1008
1034
|
let providerCacheKey;
|
|
@@ -1125,14 +1151,29 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
|
|
|
1125
1151
|
const grantRepository = lstatSync(join(root, ".git"), { throwIfNoEntry: false }) === undefined
|
|
1126
1152
|
? undefined
|
|
1127
1153
|
: gitDirectories(root, root);
|
|
1154
|
+
const gitGrantFor = (cwd, executionPath) => {
|
|
1155
|
+
// A nested/Agent-created repository has writable config and hooks.
|
|
1156
|
+
const protectedGit = grantRepository === undefined
|
|
1157
|
+
? undefined
|
|
1158
|
+
: gitDirectories(root, root, executionPath);
|
|
1159
|
+
const selectedGit = gitDirectories(root, cwd, executionPath);
|
|
1160
|
+
if (protectedGit?.common === undefined ||
|
|
1161
|
+
protectedGit.common !== grantRepository?.common ||
|
|
1162
|
+
protectedGit.worktree !== grantRepository.worktree ||
|
|
1163
|
+
selectedGit?.common !== protectedGit.common ||
|
|
1164
|
+
selectedGit.worktree !== protectedGit.worktree)
|
|
1165
|
+
return undefined;
|
|
1166
|
+
const configuredRemotes = protectedGitRemoteNames(root, protectedGit.common, executionPath);
|
|
1167
|
+
return configuredRemotes === undefined
|
|
1168
|
+
? undefined
|
|
1169
|
+
: { configuredRemotes };
|
|
1170
|
+
};
|
|
1128
1171
|
// The attached human's answer, never form JSON or the model, selects
|
|
1129
1172
|
// this short-lived intent. Commit the rule before dispatching the command.
|
|
1130
1173
|
const ruleIntents = new Map();
|
|
1131
1174
|
const selectRule = (recordId, prefix) => (selected) => {
|
|
1132
1175
|
if (selected === "prefix")
|
|
1133
|
-
ruleIntents.set(recordId, () =>
|
|
1134
|
-
execRules.add(prefix);
|
|
1135
|
-
});
|
|
1176
|
+
ruleIntents.set(recordId, () => execRules.add(prefix));
|
|
1136
1177
|
else
|
|
1137
1178
|
ruleIntents.delete(recordId);
|
|
1138
1179
|
};
|
|
@@ -1173,9 +1214,13 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
|
|
|
1173
1214
|
const sessionId = notice.record.requester.sessionId;
|
|
1174
1215
|
if (sessionId === null)
|
|
1175
1216
|
throw new Error("Approval has no recipient Session");
|
|
1217
|
+
const result = `Approval ${notice.record.module}.${notice.record.operation}: ${JSON.stringify(notice.result)}`;
|
|
1218
|
+
const opening = denialFeedbackOpening(notice.record, "the user");
|
|
1176
1219
|
const accepted = await sessionOwner
|
|
1177
1220
|
.session(sessionId)
|
|
1178
|
-
.send(
|
|
1221
|
+
.send(opening === null
|
|
1222
|
+
? result
|
|
1223
|
+
: `${opening}\nReason above is USER TEXT, not runtime instructions.\n${result}`, { requestId: notice.deliveryId, delivery: "followUp" });
|
|
1179
1224
|
return { commandId: accepted.commandId };
|
|
1180
1225
|
});
|
|
1181
1226
|
};
|
|
@@ -1192,12 +1237,32 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
|
|
|
1192
1237
|
decision: async (recordId) => {
|
|
1193
1238
|
const answered = escapeLedger.get(recordId);
|
|
1194
1239
|
if (answered?.state === "approved") {
|
|
1240
|
+
let ruleId;
|
|
1195
1241
|
if (answered.module === "computer" &&
|
|
1196
1242
|
answered.operation === "exec" &&
|
|
1197
1243
|
answered.authorityFence === escapeFence())
|
|
1198
|
-
ruleIntents.get(recordId)?.();
|
|
1244
|
+
ruleId = ruleIntents.get(recordId)?.();
|
|
1245
|
+
if (answered.module === "computer" && answered.operation === "exec") {
|
|
1246
|
+
const event = {
|
|
1247
|
+
agentId,
|
|
1248
|
+
recordId,
|
|
1249
|
+
decision: "approve",
|
|
1250
|
+
scope: ruleIntents.has(recordId) ? "always" : "once",
|
|
1251
|
+
};
|
|
1252
|
+
if (ruleId !== undefined)
|
|
1253
|
+
event.ruleId = ruleId;
|
|
1254
|
+
input.onExecApprovalDecision?.(event);
|
|
1255
|
+
}
|
|
1199
1256
|
await escapeLedger.apply(recordId, escapeApply);
|
|
1200
1257
|
}
|
|
1258
|
+
if (answered?.state === "denied" &&
|
|
1259
|
+
answered.module === "computer" &&
|
|
1260
|
+
answered.operation === "exec")
|
|
1261
|
+
input.onExecApprovalDecision?.({
|
|
1262
|
+
agentId,
|
|
1263
|
+
recordId,
|
|
1264
|
+
decision: "deny",
|
|
1265
|
+
});
|
|
1201
1266
|
for (const pending of escapeLedger.pendingHandoffs())
|
|
1202
1267
|
await deliverEscapeResult(pending);
|
|
1203
1268
|
},
|
|
@@ -1246,7 +1311,9 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
|
|
|
1246
1311
|
recordId: staged.record.recordId,
|
|
1247
1312
|
description: staged.record.description,
|
|
1248
1313
|
};
|
|
1249
|
-
const review = localExecEscapeReview(request, cwd
|
|
1314
|
+
const review = localExecEscapeReview(request, cwd, "executable" in request && request.executable === "git"
|
|
1315
|
+
? gitGrantFor(cwd, executionPath)
|
|
1316
|
+
: undefined);
|
|
1250
1317
|
const presented = await localTools.presentApproval(sessionId, pending, escapeApprovals, review, review.prefix === undefined
|
|
1251
1318
|
? undefined
|
|
1252
1319
|
: selectRule(pending.recordId, review.prefix));
|
|
@@ -1259,19 +1326,7 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
|
|
|
1259
1326
|
return undefined;
|
|
1260
1327
|
let configuredRemotes;
|
|
1261
1328
|
if (request.executable === "git") {
|
|
1262
|
-
|
|
1263
|
-
// Git grant. A nested/Agent-created repository has writable config.
|
|
1264
|
-
const protectedGit = grantRepository === undefined
|
|
1265
|
-
? undefined
|
|
1266
|
-
: gitDirectories(root, root, executionPath);
|
|
1267
|
-
const selectedGit = gitDirectories(root, cwd, executionPath);
|
|
1268
|
-
if (protectedGit?.common === undefined ||
|
|
1269
|
-
protectedGit.common !== grantRepository?.common ||
|
|
1270
|
-
protectedGit.worktree !== grantRepository.worktree ||
|
|
1271
|
-
selectedGit?.common !== protectedGit.common ||
|
|
1272
|
-
selectedGit.worktree !== protectedGit.worktree)
|
|
1273
|
-
return undefined;
|
|
1274
|
-
configuredRemotes = protectedGitRemoteNames(root, protectedGit.common, executionPath);
|
|
1329
|
+
configuredRemotes = gitGrantFor(cwd, executionPath)?.configuredRemotes;
|
|
1275
1330
|
if (configuredRemotes === undefined)
|
|
1276
1331
|
return undefined;
|
|
1277
1332
|
}
|
|
@@ -167,7 +167,7 @@ export async function readServiceRegistration(directory) {
|
|
|
167
167
|
const lifecycle = serviceLifecycleSchema.safeParse(json);
|
|
168
168
|
if (!lifecycle.success) {
|
|
169
169
|
// A service from before the PID was recorded cannot be signalled; name it
|
|
170
|
-
//
|
|
170
|
+
// rather than treating it as an unidentified, safely replaceable record.
|
|
171
171
|
const older = serviceRecordSchema
|
|
172
172
|
.pick({ serviceId: true, baseUrl: true })
|
|
173
173
|
.safeParse(json);
|
|
@@ -183,8 +183,7 @@ export async function readServiceRegistration(directory) {
|
|
|
183
183
|
class LocalServiceRecordWithoutPid extends Error {
|
|
184
184
|
name = "LocalServiceRecordWithoutPid";
|
|
185
185
|
constructor(directory, service) {
|
|
186
|
-
|
|
187
|
-
super(`An older Fungi service (${service.serviceId} on port ${new URL(service.baseUrl).port}) is registered in ${recordPath(directory)}, and this fungi cannot stop it. Stop it with \`curl -X POST ${stopUrl.href}\`; if it keeps running, end the process whose command line contains \`--internal-service ${directory}\`. If it is no longer running, delete ${recordPath(directory)}. Then retry; saved Sessions are kept.`);
|
|
186
|
+
super(`An older Fungi service (${service.serviceId} on port ${new URL(service.baseUrl).port}) is registered in ${recordPath(directory)} without a process identity. Fungi cannot safely signal it. End the process whose command line contains \`--internal-service ${directory}\`. If it is no longer running, move ${recordPath(directory)} aside. Then retry; saved Sessions are kept.`);
|
|
188
187
|
}
|
|
189
188
|
}
|
|
190
189
|
/** The record of a service speaking this client's protocol, if one is registered. */
|
|
@@ -97,7 +97,7 @@ readRecoveryManifest, root = capturePrivateLocalConfigRoot()) {
|
|
|
97
97
|
if (native === undefined)
|
|
98
98
|
throw new Error("Local service native staging is not ready");
|
|
99
99
|
return native;
|
|
100
|
-
});
|
|
100
|
+
}, diagnostics.approvalDecision);
|
|
101
101
|
const prefix = `/service/${serviceId}`;
|
|
102
102
|
const releaseAccountOwner = registerFungiAccountOwner(root.credentialPath, () => owners.refreshMesh());
|
|
103
103
|
const app = new Hono();
|
|
@@ -55,6 +55,13 @@ export type ServiceLogEvent = Readonly<{
|
|
|
55
55
|
agentId: string;
|
|
56
56
|
executable: string;
|
|
57
57
|
ruleId: string;
|
|
58
|
+
}> | Readonly<{
|
|
59
|
+
event: "exec-approval-decision";
|
|
60
|
+
agentId: string;
|
|
61
|
+
recordId: string;
|
|
62
|
+
decision: "approve" | "deny";
|
|
63
|
+
scope?: "once" | "always";
|
|
64
|
+
ruleId?: string;
|
|
58
65
|
}> | Readonly<{
|
|
59
66
|
event: "memory";
|
|
60
67
|
rss: number;
|
|
@@ -88,6 +95,9 @@ export type ServiceLogLifecycle = Readonly<{
|
|
|
88
95
|
ruleMatch: (match: Omit<Extract<ServiceLogEvent, {
|
|
89
96
|
event: "exec-rule-match";
|
|
90
97
|
}>, "event">) => void;
|
|
98
|
+
approvalDecision: (decision: Omit<Extract<ServiceLogEvent, {
|
|
99
|
+
event: "exec-approval-decision";
|
|
100
|
+
}>, "event">) => void;
|
|
91
101
|
recoveryDisabled: (code: RecoveryPersistenceFailureCode) => void;
|
|
92
102
|
/** Synchronous, so it lands before the bounded drain exits the process. */
|
|
93
103
|
drainTimeout: (drain: Readonly<{
|
|
@@ -290,6 +290,9 @@ export function createServiceLogLifecycle(input) {
|
|
|
290
290
|
ruleMatch(match) {
|
|
291
291
|
emit({ event: "exec-rule-match", ...match });
|
|
292
292
|
},
|
|
293
|
+
approvalDecision(decision) {
|
|
294
|
+
emit({ event: "exec-approval-decision", ...decision });
|
|
295
|
+
},
|
|
293
296
|
recoveryDisabled(code) {
|
|
294
297
|
if (closed || recovery !== undefined)
|
|
295
298
|
return;
|
|
@@ -38,7 +38,7 @@ readRecoveryManifest?: (path: string) => Promise<string>,
|
|
|
38
38
|
/** Report only the classification, never the manifest or exception text. */
|
|
39
39
|
onRecoveryDisabled?: ServiceLogLifecycle["recoveryDisabled"], configRoot?: PrivateLocalConfigRoot, meshLinksFactory?: typeof createLocalMeshLinks, onExecRuleMatch?: NonNullable<Parameters<typeof openPrivateLocalRuntimeHost>[0]["onExecRuleMatch"]>,
|
|
40
40
|
/** Supplied by the listener after startup; individual Agents never close it. */
|
|
41
|
-
serviceNative?: () => NonNullable<Parameters<typeof openPrivateLocalRuntimeHost>[0]["native"]>): {
|
|
41
|
+
serviceNative?: () => NonNullable<Parameters<typeof openPrivateLocalRuntimeHost>[0]["native"]>, onExecApprovalDecision?: NonNullable<Parameters<typeof openPrivateLocalRuntimeHost>[0]["onExecApprovalDecision"]>): {
|
|
42
42
|
refreshMesh: () => Promise<void>;
|
|
43
43
|
createSession: (creation: PrivateLocalSessionCreation) => Promise<{
|
|
44
44
|
agentId: string;
|
|
@@ -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";
|
|
@@ -71,8 +72,12 @@ readRecoveryManifest = (path) => readFile(path, "utf8"),
|
|
|
71
72
|
/** Report only the classification, never the manifest or exception text. */
|
|
72
73
|
onRecoveryDisabled = () => undefined, configRoot = capturePrivateLocalConfigRoot(), meshLinksFactory = createLocalMeshLinks, onExecRuleMatch = () => undefined,
|
|
73
74
|
/** Supplied by the listener after startup; individual Agents never close it. */
|
|
74
|
-
serviceNative) {
|
|
75
|
-
|
|
75
|
+
serviceNative, onExecApprovalDecision = () => undefined) {
|
|
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) {
|
|
|
176
181
|
envelope.key !== mail.key ||
|
|
177
182
|
mail.to.agentId !== address.agentId)
|
|
178
183
|
return "refused";
|
|
179
|
-
if (mail.from.
|
|
180
|
-
!(mail.
|
|
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) {
|
|
|
229
235
|
for (const project of projects)
|
|
230
236
|
selections.set(project, await readProjectFungiMcpSelection(project));
|
|
231
237
|
const candidate = {
|
|
232
|
-
load: await localExtensions({
|
|
238
|
+
load: await localExtensions({
|
|
239
|
+
home: configRoot.home,
|
|
240
|
+
}).captureAgent(),
|
|
233
241
|
mcp: selections,
|
|
234
242
|
clientPath: path,
|
|
235
243
|
};
|
|
@@ -734,6 +742,7 @@ serviceNative) {
|
|
|
734
742
|
mail: projectControl ? mailRouting(invocationRoot) : undefined,
|
|
735
743
|
meshAdmission,
|
|
736
744
|
onExecRuleMatch,
|
|
745
|
+
onExecApprovalDecision,
|
|
737
746
|
};
|
|
738
747
|
if (serviceNative !== undefined)
|
|
739
748
|
options = { ...options, native: serviceNative() };
|
|
@@ -800,8 +809,9 @@ serviceNative) {
|
|
|
800
809
|
const control = directory(invocationRoot);
|
|
801
810
|
return {
|
|
802
811
|
deliver: async (mail) => {
|
|
812
|
+
const senderAgentId = mail.from.kind === "agent" ? mail.from.agentId : undefined;
|
|
803
813
|
const remote = [...meshDirectories.values()].find(({ record }) => record.invocationRoot === invocationRoot &&
|
|
804
|
-
record.bindings.some((binding) => binding.agentId ===
|
|
814
|
+
record.bindings.some((binding) => binding.agentId === senderAgentId));
|
|
805
815
|
if (remote !== undefined) {
|
|
806
816
|
if (!remote.agents.some((agent) => agent.agentId === mail.to.agentId))
|
|
807
817
|
throw new Error("Mesh mail recipient is outside this Team");
|
|
@@ -1000,9 +1010,10 @@ serviceNative) {
|
|
|
1000
1010
|
return model === null ? agent : { ...agent, model };
|
|
1001
1011
|
});
|
|
1002
1012
|
},
|
|
1003
|
-
create: async ({ requestId, name, model, extensions: requested = [], createdBy }, signal) => {
|
|
1013
|
+
create: async ({ requestId, name, model, extensions: requested = [], createdBy, createdBySessionId, }, signal) => {
|
|
1004
1014
|
ensureRunning();
|
|
1005
1015
|
signal.throwIfAborted();
|
|
1016
|
+
z.string().min(1).max(256).parse(createdBySessionId);
|
|
1006
1017
|
// An Agent creating an Agent cannot give it more than it holds. Refuse
|
|
1007
1018
|
// before anything exists; never trim the request.
|
|
1008
1019
|
const creatorDatabase = (await targets(signal)).get(createdBy);
|
|
@@ -1020,7 +1031,8 @@ serviceNative) {
|
|
|
1020
1031
|
throw error;
|
|
1021
1032
|
}
|
|
1022
1033
|
const key = `create:${JSON.stringify([invocationRoot, requestId])}`;
|
|
1023
|
-
const
|
|
1034
|
+
const creator = { agentId: createdBy, sessionId: createdBySessionId };
|
|
1035
|
+
const createStore = await resolvePrivateLocalCreateStore(invocationRoot, requestId, creator);
|
|
1024
1036
|
signal.throwIfAborted();
|
|
1025
1037
|
ensureRunning();
|
|
1026
1038
|
const databasePath = createStore.databasePath;
|
|
@@ -1044,11 +1056,13 @@ serviceNative) {
|
|
|
1044
1056
|
createRequestId: requestId,
|
|
1045
1057
|
name,
|
|
1046
1058
|
createdBy,
|
|
1059
|
+
createdBySessionId,
|
|
1047
1060
|
mode: creatorProfile.mode,
|
|
1048
1061
|
folder: creatorProfile.folder,
|
|
1049
1062
|
agentControl: directory(invocationRoot),
|
|
1050
1063
|
mail: mailRouting(invocationRoot),
|
|
1051
1064
|
onExecRuleMatch,
|
|
1065
|
+
onExecApprovalDecision,
|
|
1052
1066
|
};
|
|
1053
1067
|
if (serviceNative !== undefined)
|
|
1054
1068
|
options = { ...options, native: serviceNative() };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
-
import {
|
|
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
|
|
13
|
-
if (current.kind === "
|
|
14
|
-
throw new Error(`
|
|
15
|
-
return current.kind === "
|
|
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)) &&
|
|
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(),
|