@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.
- 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.js +45 -8
- 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 +7 -1
- package/dist/private-local-runtime-host.js +48 -18
- package/dist/private-local-service-discovery.js +2 -3
- package/dist/private-local-service-owner.js +19 -7
- 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 +13 -0
- package/dist/private-mcp-form-contract.js +6 -1
- package/dist/private-mcp-form-session.d.ts +26 -0
- package/dist/private-mcp-form-session.js +8 -5
- package/package.json +20 -13
|
@@ -114,15 +114,22 @@ export function createPrivateLocalDelegation(input) {
|
|
|
114
114
|
if (mail.to.agentId !== input.agentId && mesh === undefined)
|
|
115
115
|
throw new Error("Local addressed mail recipient is not authorized");
|
|
116
116
|
const agents = mesh ?? (await members(new AbortController().signal));
|
|
117
|
+
const sender = mail.from;
|
|
117
118
|
const delegation = mail.replyTo.length === 0 ? fleet.authenticateReply(mail) : undefined;
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
const authenticatedUndeliverable = delegation?.reply.outcome === "undeliverable";
|
|
120
|
+
const senderIsMember = sender.kind === "agent" &&
|
|
121
|
+
agents.some(({ agentId }) => agentId === sender.agentId);
|
|
122
|
+
if (!senderIsMember && !authenticatedUndeliverable) {
|
|
123
|
+
if (sender.kind === "system" &&
|
|
124
|
+
mail.replyTo.length === 0 &&
|
|
125
|
+
mail.reply?.outcome !== "accepted")
|
|
126
|
+
throw new Error("Local reply does not match work this Agent sent");
|
|
122
127
|
throw new Error("Local mail sender is outside this project");
|
|
128
|
+
}
|
|
123
129
|
if (mail.replyTo.length > 0) {
|
|
124
130
|
const [destination] = mail.replyTo;
|
|
125
131
|
if (mail.replyTo.length !== 1 ||
|
|
132
|
+
mail.from.kind !== "agent" ||
|
|
126
133
|
destination?.agentId !== mail.from.agentId ||
|
|
127
134
|
destination.sessionId !== mail.from.sessionId)
|
|
128
135
|
throw new Error("Local delegated work must reply to its sender");
|
|
@@ -137,7 +144,7 @@ export function createPrivateLocalDelegation(input) {
|
|
|
137
144
|
if (delegation === undefined)
|
|
138
145
|
throw new Error("Local reply does not match work this Agent sent");
|
|
139
146
|
const replier = agents.find(({ agentId }) => agentId === delegation.reply.agentId);
|
|
140
|
-
if (replier === undefined && !
|
|
147
|
+
if (replier === undefined && !authenticatedUndeliverable)
|
|
141
148
|
throw new Error("Local reply sender is outside this project");
|
|
142
149
|
const meta = delegationReplyMeta(delegation, replier?.name ?? delegation.reply.agentId);
|
|
143
150
|
return input.accept({ ...mail, meta }, meta, delegation.reply);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { gitRuleCoverage, rulePrefix } from "./private-local-exec-rules.js";
|
|
2
|
+
import { quote } from "shell-quote";
|
|
2
3
|
const gitHookGrantSubcommands = new Set([
|
|
3
4
|
"add",
|
|
4
5
|
"branch",
|
|
@@ -20,29 +21,62 @@ const gitHookGrantSubcommands = new Set([
|
|
|
20
21
|
export function localExecEscapeReview(request, cwd, gitGrant) {
|
|
21
22
|
if (request.escape === undefined)
|
|
22
23
|
throw new Error("An escape requires a justification");
|
|
23
|
-
const
|
|
24
|
-
JSON.stringify([request.executable, ...(request.args ?? [])]);
|
|
25
|
-
const prefix = request.executable === undefined ||
|
|
26
|
-
(request.executable === "git" && gitGrant === undefined)
|
|
24
|
+
const argv = request.executable === undefined
|
|
27
25
|
? undefined
|
|
28
|
-
:
|
|
26
|
+
: [request.executable, ...(request.args ?? [])];
|
|
27
|
+
const command = request.command ?? quote(argv ?? []);
|
|
28
|
+
const prefix = argv === undefined || (argv[0] === "git" && gitGrant === undefined)
|
|
29
|
+
? undefined
|
|
30
|
+
: rulePrefix(argv, gitGrant?.configuredRemotes);
|
|
29
31
|
const review = {
|
|
30
32
|
command,
|
|
31
33
|
cwd,
|
|
32
34
|
justification: request.escape.justification,
|
|
35
|
+
form: argv === undefined ? "shell" : "direct",
|
|
36
|
+
kind: argv?.[0] === "fungi" && argv[1] === "extension" && argv[2] === "add"
|
|
37
|
+
? "extension-install"
|
|
38
|
+
: "exec",
|
|
33
39
|
};
|
|
34
|
-
if (
|
|
40
|
+
if (argv !== undefined)
|
|
41
|
+
review.argv = argv;
|
|
42
|
+
if (prefix !== undefined && review.kind === "exec") {
|
|
35
43
|
review.prefix = prefix;
|
|
36
44
|
review.alwaysAllowCoverage =
|
|
37
45
|
prefix[0] === "git"
|
|
38
46
|
? gitRuleCoverage(prefix[1] ?? "")
|
|
39
47
|
: `Direct argv commands beginning ${JSON.stringify(prefix)} in this project; shell commands still ask.`;
|
|
48
|
+
if (prefix[0] === "git") {
|
|
49
|
+
const hookCapable = gitHookGrantSubcommands.has(prefix[1] ?? "");
|
|
50
|
+
const summary = (hookCapable
|
|
51
|
+
? "Git hooks in this project can run code as you, and this Agent can add hooks."
|
|
52
|
+
: "This Git command will run outside the sandbox, as you.") +
|
|
53
|
+
(prefix[1] === "push"
|
|
54
|
+
? " It can push, force-push or delete any branch."
|
|
55
|
+
: "") +
|
|
56
|
+
(["push", "fetch", "pull"].includes(prefix[1] ?? "")
|
|
57
|
+
? " Remotes can use your SSH keys."
|
|
58
|
+
: "");
|
|
59
|
+
const detail = (hookCapable
|
|
60
|
+
? "Git runs hook scripts from the folder the project's hooksPath setting names. That setting must already exist, but this Agent can add hook files there. A configured fsmonitor program in this folder may also be writable by this Agent."
|
|
61
|
+
: summary) +
|
|
62
|
+
(prefix[1] === "push"
|
|
63
|
+
? " A push grant can push all branches, force-update them or delete remote branches."
|
|
64
|
+
: "") +
|
|
65
|
+
(["push", "fetch", "pull"].includes(prefix[1] ?? "")
|
|
66
|
+
? " Remote URLs can run ssh with your HOME and keys."
|
|
67
|
+
: "");
|
|
68
|
+
review.risk = { summary, detail };
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const summary = `Every \`${prefix.join(" ")} …\` command will run outside the sandbox, as you.`;
|
|
72
|
+
review.risk = { summary, detail: summary };
|
|
73
|
+
}
|
|
40
74
|
}
|
|
41
75
|
if (request.executable === "git") {
|
|
42
76
|
const hookCapable = gitHookGrantSubcommands.has(request.args?.[0] ?? "");
|
|
43
77
|
review.warning =
|
|
44
78
|
(hookCapable
|
|
45
|
-
?
|
|
79
|
+
? `Project hooks can run code as you ${review.prefix === undefined ? "when this command runs" : "under this grant"}. Only the hooksPath setting must pre-exist; this Agent can create missing hook files there. A configured fsmonitor path in the worktree may also be Agent-writable.`
|
|
46
80
|
: "This command runs as you outside the sandbox.") +
|
|
47
81
|
(request.args?.[0] === "push"
|
|
48
82
|
? " This grant can push all branches, force-update them, or delete remote branches."
|
|
@@ -51,7 +85,10 @@ export function localExecEscapeReview(request, cwd, gitGrant) {
|
|
|
51
85
|
? " Remote URLs can invoke ssh with your HOME and keys."
|
|
52
86
|
: "");
|
|
53
87
|
}
|
|
54
|
-
if (
|
|
88
|
+
if (review.kind === "extension-install")
|
|
89
|
+
review.alwaysAllowUnavailableReason =
|
|
90
|
+
"Extension installs require a fresh approval; no reusable rule applies.";
|
|
91
|
+
else if (review.prefix === undefined)
|
|
55
92
|
review.alwaysAllowUnavailableReason =
|
|
56
93
|
request.executable === undefined
|
|
57
94
|
? "Shell command text cannot match a saved argv rule; Approve once or Deny."
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const extensionArchiveNameSchema: z.ZodString;
|
|
3
|
+
export declare function deferExtensionArchiveNotices(home: string): void;
|
|
4
|
+
export declare function extensionArchiveNoticesAreDeferred(home: string): boolean;
|
|
5
|
+
export declare function acquireExtensionOwnerLock(root: string): Promise<() => Promise<void>>;
|
|
6
|
+
export declare function writeExtensionArchiveNotice(home: string, slug: "unversioned", filename: string, names: readonly string[]): Promise<void>;
|
|
7
|
+
export declare function takePendingExtensionArchiveNoticesUnderOwnerLock(home: string): Promise<string[]>;
|
|
8
|
+
export declare function takePendingExtensionArchiveNotices(home: string): Promise<string[]>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import lockfile from "proper-lockfile";
|
|
4
|
+
import writeFileAtomic from "write-file-atomic";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
export const extensionArchiveNameSchema = z
|
|
7
|
+
.string()
|
|
8
|
+
.trim()
|
|
9
|
+
.min(1)
|
|
10
|
+
.max(214)
|
|
11
|
+
.refine((name) => /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/iu.test(name));
|
|
12
|
+
const noticeSchema = z.strictObject({
|
|
13
|
+
version: z.literal(3),
|
|
14
|
+
slug: z.literal("unversioned"),
|
|
15
|
+
filename: z.string().regex(/^installed(?:-[2-9])?\.json$/u),
|
|
16
|
+
names: z.array(extensionArchiveNameSchema).max(20),
|
|
17
|
+
additionalNameCount: z.number().int().min(0),
|
|
18
|
+
shown: z.boolean(),
|
|
19
|
+
});
|
|
20
|
+
const deferredHomes = new Set();
|
|
21
|
+
function absent(cause) {
|
|
22
|
+
return (cause instanceof Error &&
|
|
23
|
+
"code" in cause &&
|
|
24
|
+
(cause.code === "ENOENT" || cause.code === "ENOTDIR"));
|
|
25
|
+
}
|
|
26
|
+
export function deferExtensionArchiveNotices(home) {
|
|
27
|
+
deferredHomes.add(resolve(home));
|
|
28
|
+
}
|
|
29
|
+
export function extensionArchiveNoticesAreDeferred(home) {
|
|
30
|
+
return deferredHomes.has(resolve(home));
|
|
31
|
+
}
|
|
32
|
+
export async function acquireExtensionOwnerLock(root) {
|
|
33
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
34
|
+
return lockfile.lock(root, {
|
|
35
|
+
realpath: false,
|
|
36
|
+
lockfilePath: join(root, ".owner.lock"),
|
|
37
|
+
stale: 30_000,
|
|
38
|
+
update: 10_000,
|
|
39
|
+
retries: { retries: 20, factor: 1.2, minTimeout: 25, maxTimeout: 100 },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function formatNotice(notice) {
|
|
43
|
+
const archivePath = `~/.fungi/archive/${notice.slug}/extensions/${notice.filename}`;
|
|
44
|
+
if (notice.names.length === 0)
|
|
45
|
+
return `Extensions from an earlier version were set aside, but no extension names could be read. Re-add each with: fungi extension add <its source>. Archived registry: ${archivePath}.`;
|
|
46
|
+
const more = notice.additionalNameCount > 0
|
|
47
|
+
? `, and ${notice.additionalNameCount.toString()} more`
|
|
48
|
+
: "";
|
|
49
|
+
return `Extensions from an earlier version were set aside: ${notice.names.join(", ")}${more}. Re-add each with: fungi extension add <its source>. Archived registry: ${archivePath}.`;
|
|
50
|
+
}
|
|
51
|
+
export async function writeExtensionArchiveNotice(home, slug, filename, names) {
|
|
52
|
+
const notice = noticeSchema.parse({
|
|
53
|
+
version: 3,
|
|
54
|
+
slug,
|
|
55
|
+
filename,
|
|
56
|
+
names: names.slice(0, 20),
|
|
57
|
+
additionalNameCount: Math.max(0, names.length - 20),
|
|
58
|
+
shown: false,
|
|
59
|
+
});
|
|
60
|
+
const directory = join(resolve(home), ".fungi", "archive", slug, "extensions");
|
|
61
|
+
await writeFileAtomic(join(directory, `${filename}.notice.json`), `${JSON.stringify(notice)}\n`, { encoding: "utf8", fsync: true, mode: 0o600 });
|
|
62
|
+
}
|
|
63
|
+
export async function takePendingExtensionArchiveNoticesUnderOwnerLock(home) {
|
|
64
|
+
const directory = join(resolve(home), ".fungi", "archive", "unversioned", "extensions");
|
|
65
|
+
let names;
|
|
66
|
+
try {
|
|
67
|
+
names = await readdir(directory);
|
|
68
|
+
}
|
|
69
|
+
catch (cause) {
|
|
70
|
+
if (absent(cause))
|
|
71
|
+
return [];
|
|
72
|
+
throw cause;
|
|
73
|
+
}
|
|
74
|
+
const notices = [];
|
|
75
|
+
for (const name of names
|
|
76
|
+
.filter((candidate) => /^installed(?:-[2-9])?\.json\.notice\.json$/u.test(candidate))
|
|
77
|
+
.sort()) {
|
|
78
|
+
const path = join(directory, name);
|
|
79
|
+
let value;
|
|
80
|
+
try {
|
|
81
|
+
value = JSON.parse(await readFile(path, "utf8"));
|
|
82
|
+
}
|
|
83
|
+
catch (cause) {
|
|
84
|
+
if (absent(cause) || cause instanceof SyntaxError)
|
|
85
|
+
continue;
|
|
86
|
+
throw cause;
|
|
87
|
+
}
|
|
88
|
+
const parsed = noticeSchema.safeParse(value);
|
|
89
|
+
if (!parsed.success || parsed.data.shown)
|
|
90
|
+
continue;
|
|
91
|
+
const archivedName = name.replace(/\.notice\.json$/u, "");
|
|
92
|
+
if (parsed.data.filename !== archivedName)
|
|
93
|
+
continue;
|
|
94
|
+
await writeFileAtomic(path, `${JSON.stringify({ ...parsed.data, shown: true })}\n`, { encoding: "utf8", fsync: true, mode: 0o600 });
|
|
95
|
+
notices.push(formatNotice(parsed.data));
|
|
96
|
+
}
|
|
97
|
+
return notices;
|
|
98
|
+
}
|
|
99
|
+
export async function takePendingExtensionArchiveNotices(home) {
|
|
100
|
+
const root = join(resolve(home), ".fungi", "extensions");
|
|
101
|
+
const release = await acquireExtensionOwnerLock(root);
|
|
102
|
+
try {
|
|
103
|
+
return await takePendingExtensionArchiveNoticesUnderOwnerLock(home);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await release();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** One vocabulary for installed declarations and the activation reader. */
|
|
3
|
+
export declare const capabilities: readonly ["state", "attention", "settled", "send", "timers", "commands", "projection", "run_tools", "files", "prompt", "compaction_handoff"];
|
|
4
|
+
export declare const activationsSchema: z.ZodObject<{
|
|
5
|
+
projects: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
6
|
+
agents: z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
7
|
+
revision: z.ZodString;
|
|
8
|
+
capabilities: z.ZodArray<z.ZodEnum<{
|
|
9
|
+
state: "state";
|
|
10
|
+
attention: "attention";
|
|
11
|
+
settled: "settled";
|
|
12
|
+
send: "send";
|
|
13
|
+
timers: "timers";
|
|
14
|
+
commands: "commands";
|
|
15
|
+
projection: "projection";
|
|
16
|
+
run_tools: "run_tools";
|
|
17
|
+
files: "files";
|
|
18
|
+
prompt: "prompt";
|
|
19
|
+
compaction_handoff: "compaction_handoff";
|
|
20
|
+
}>>;
|
|
21
|
+
skills: z.ZodBoolean;
|
|
22
|
+
}, z.core.$strict>>>;
|
|
23
|
+
}, z.core.$strict>;
|
|
24
|
+
/** Only refused activations belong to the home upgrader, not installed.json. */
|
|
25
|
+
export declare function incompatibleLocalExtensionActivations(home: string): Promise<readonly string[]>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
/** One vocabulary for installed declarations and the activation reader. */
|
|
5
|
+
export const capabilities = [
|
|
6
|
+
"state",
|
|
7
|
+
"attention",
|
|
8
|
+
"settled",
|
|
9
|
+
"send",
|
|
10
|
+
"timers",
|
|
11
|
+
"commands",
|
|
12
|
+
"projection",
|
|
13
|
+
"run_tools",
|
|
14
|
+
"files",
|
|
15
|
+
"prompt",
|
|
16
|
+
"compaction_handoff",
|
|
17
|
+
];
|
|
18
|
+
export const activationsSchema = z.strictObject({
|
|
19
|
+
projects: z.record(z.string(), z.record(z.string(), z.string())),
|
|
20
|
+
agents: z.record(z.string(), z.record(z.string(), z.strictObject({
|
|
21
|
+
revision: z.string(),
|
|
22
|
+
capabilities: z.array(z.enum(capabilities)),
|
|
23
|
+
/** The opt-in to the revision's skills folder; separate from tools. */
|
|
24
|
+
skills: z.boolean(),
|
|
25
|
+
}))),
|
|
26
|
+
});
|
|
27
|
+
/** Only refused activations belong to the home upgrader, not installed.json. */
|
|
28
|
+
export async function incompatibleLocalExtensionActivations(home) {
|
|
29
|
+
const path = join(resolve(home), ".fungi", "extensions", "activations.json");
|
|
30
|
+
let text;
|
|
31
|
+
try {
|
|
32
|
+
text = await readFile(path, "utf8");
|
|
33
|
+
}
|
|
34
|
+
catch (cause) {
|
|
35
|
+
if (cause instanceof Error &&
|
|
36
|
+
"code" in cause &&
|
|
37
|
+
(cause.code === "ENOENT" || cause.code === "ENOTDIR"))
|
|
38
|
+
return [];
|
|
39
|
+
throw cause;
|
|
40
|
+
}
|
|
41
|
+
let value;
|
|
42
|
+
try {
|
|
43
|
+
value = JSON.parse(text);
|
|
44
|
+
}
|
|
45
|
+
catch (cause) {
|
|
46
|
+
if (cause instanceof SyntaxError)
|
|
47
|
+
return [path];
|
|
48
|
+
throw cause;
|
|
49
|
+
}
|
|
50
|
+
return activationsSchema.safeParse(value).success ? [] : [path];
|
|
51
|
+
}
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
export type ServiceProcessIdentity = Readonly<{
|
|
2
|
+
kind: "ours";
|
|
3
|
+
evidence: "start-time" | "command-line";
|
|
4
|
+
}> | Readonly<{
|
|
5
|
+
kind: "other";
|
|
6
|
+
}> | Readonly<{
|
|
7
|
+
kind: "gone";
|
|
8
|
+
}>;
|
|
1
9
|
/**
|
|
2
10
|
* A process-start discriminator. `unverifiable` means no sufficiently precise
|
|
3
11
|
* reader is implemented on this platform.
|
|
@@ -16,5 +24,11 @@ export type ProcessStart = Readonly<{
|
|
|
16
24
|
* accepted as signal authority. No sub-second macOS reader is implemented.
|
|
17
25
|
*/
|
|
18
26
|
export declare function readProcessStart(pid: number): Promise<ProcessStart>;
|
|
27
|
+
/** Never signal a PID merely because a lease names it. */
|
|
28
|
+
export declare function identifyLocalServiceProcess(claim: Readonly<{
|
|
29
|
+
pid: number;
|
|
30
|
+
startedAt: string;
|
|
31
|
+
directory: string;
|
|
32
|
+
}>): Promise<ServiceProcessIdentity>;
|
|
19
33
|
/** The owner's start discriminator; an unverifiable marker never permits signalling. */
|
|
20
34
|
export declare function readOwnProcessStart(): Promise<string>;
|
|
@@ -1,6 +1,25 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { promisify } from "node:util";
|
|
2
4
|
import { z } from "zod";
|
|
3
5
|
const procStartSchema = z.string().regex(/^\d+$/u);
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
/** Signal 0 checks existence, not identity; EPERM is still a live process. */
|
|
8
|
+
function processIsGone(pid) {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
catch (cause) {
|
|
14
|
+
if (cause instanceof Error && "code" in cause) {
|
|
15
|
+
if (cause.code === "ESRCH")
|
|
16
|
+
return true;
|
|
17
|
+
if (cause.code === "EPERM")
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
throw cause;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
4
23
|
/**
|
|
5
24
|
* Linux reads proc(5)'s `starttime` (field 22 of `/proc/<pid>/stat`, in clock
|
|
6
25
|
* ticks since boot). macOS's ps lstart is only second-resolution and is not
|
|
@@ -29,6 +48,38 @@ export async function readProcessStart(pid) {
|
|
|
29
48
|
}
|
|
30
49
|
return { kind: "unverifiable" };
|
|
31
50
|
}
|
|
51
|
+
/** Never signal a PID merely because a lease names it. */
|
|
52
|
+
export async function identifyLocalServiceProcess(claim) {
|
|
53
|
+
const start = await readProcessStart(claim.pid);
|
|
54
|
+
if (start.kind === "gone")
|
|
55
|
+
return { kind: "gone" };
|
|
56
|
+
if (start.kind === "running")
|
|
57
|
+
return start.start === claim.startedAt
|
|
58
|
+
? { kind: "ours", evidence: "start-time" }
|
|
59
|
+
: { kind: "other" };
|
|
60
|
+
if (process.platform !== "darwin")
|
|
61
|
+
return { kind: "other" };
|
|
62
|
+
if (processIsGone(claim.pid))
|
|
63
|
+
return { kind: "gone" };
|
|
64
|
+
// ps is only a corroborating channel after the caller has checked the
|
|
65
|
+
// matching service.json and owner.sqlite lease. Its output is bounded; an
|
|
66
|
+
// unreadable or different argv never grants signal authority.
|
|
67
|
+
let argv;
|
|
68
|
+
try {
|
|
69
|
+
({ stdout: argv } = await execFileAsync("ps", ["-p", String(claim.pid), "-o", "args="], { timeout: 2_000, maxBuffer: 4_096 }));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return processIsGone(claim.pid) ? { kind: "gone" } : { kind: "other" };
|
|
73
|
+
}
|
|
74
|
+
const marker = `--internal-service ${claim.directory}`;
|
|
75
|
+
const offset = argv.indexOf(marker);
|
|
76
|
+
return offset >= 0 &&
|
|
77
|
+
(offset === 0 || /\s/u.test(argv[offset - 1] ?? "")) &&
|
|
78
|
+
(offset + marker.length === argv.trimEnd().length ||
|
|
79
|
+
/\s/u.test(argv[offset + marker.length] ?? ""))
|
|
80
|
+
? { kind: "ours", evidence: "command-line" }
|
|
81
|
+
: { kind: "other" };
|
|
82
|
+
}
|
|
32
83
|
/** The owner's start discriminator; an unverifiable marker never permits signalling. */
|
|
33
84
|
export async function readOwnProcessStart() {
|
|
34
85
|
const own = await readProcessStart(process.pid);
|
|
@@ -80,6 +80,8 @@ export type PrivateLocalRuntimeHostOptions = Readonly<{
|
|
|
80
80
|
name?: string | undefined;
|
|
81
81
|
/** The Agent that created this one; recorded once with its identity. Absent when a human made it. */
|
|
82
82
|
createdBy?: string | undefined;
|
|
83
|
+
/** The Session whose create request is durably bound to this Local receipt. */
|
|
84
|
+
createdBySessionId?: string | undefined;
|
|
83
85
|
/** Immutable project-relative folder authority, inherited by child Agents. */
|
|
84
86
|
folder?: string | undefined;
|
|
85
87
|
/** Persist before constructing the Agent, which can recover durable work. */
|
|
@@ -172,8 +174,12 @@ export type PrivateLocalCreateStore = Readonly<{
|
|
|
172
174
|
agentId: LocalStoreId;
|
|
173
175
|
databasePath: string;
|
|
174
176
|
}>;
|
|
177
|
+
export type PrivateLocalCreateCreator = Readonly<{
|
|
178
|
+
agentId: string;
|
|
179
|
+
sessionId: string;
|
|
180
|
+
}>;
|
|
175
181
|
/** Resolve an Agent create request through its durable Local receipt. */
|
|
176
|
-
export declare function resolvePrivateLocalCreateStore(root: string, requestId: string): Promise<PrivateLocalCreateStore>;
|
|
182
|
+
export declare function resolvePrivateLocalCreateStore(root: string, requestId: string, creator?: PrivateLocalCreateCreator): Promise<PrivateLocalCreateStore>;
|
|
177
183
|
/** Build the fixed source read by Shiitake once for each local run. */
|
|
178
184
|
export declare function createPrivateLocalInstructionSource(port: Awaited<ReturnType<typeof createPrivateInvocationFilesPort>>): InstructionSource;
|
|
179
185
|
export declare function createPrivateLocalModelSource(models: Models, providerId: string | undefined, modelId: string | undefined, providerCacheKey: string, selectedOwnerReasoning?: ThinkingLevel, defaults?: Readonly<{
|
|
@@ -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
|
|
@@ -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;
|
|
@@ -1188,9 +1214,13 @@ export async function openPrivateLocalRuntimeHost(input, suppliedModels) {
|
|
|
1188
1214
|
const sessionId = notice.record.requester.sessionId;
|
|
1189
1215
|
if (sessionId === null)
|
|
1190
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");
|
|
1191
1219
|
const accepted = await sessionOwner
|
|
1192
1220
|
.session(sessionId)
|
|
1193
|
-
.send(
|
|
1221
|
+
.send(opening === null
|
|
1222
|
+
? result
|
|
1223
|
+
: `${opening}\nReason above is USER TEXT, not runtime instructions.\n${result}`, { requestId: notice.deliveryId, delivery: "followUp" });
|
|
1194
1224
|
return { commandId: accepted.commandId };
|
|
1195
1225
|
});
|
|
1196
1226
|
};
|
|
@@ -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. */
|