@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.
Files changed (34) hide show
  1. package/dist/local-extensions.js +108 -33
  2. package/dist/local-login.js +9 -3
  3. package/dist/local-upgrade.d.ts +63 -0
  4. package/dist/local-upgrade.js +358 -0
  5. package/dist/private-local-agent-control.d.ts +3 -0
  6. package/dist/private-local-agent-control.js +5 -1
  7. package/dist/private-local-delegation.js +12 -5
  8. package/dist/private-local-exec-review.d.ts +3 -1
  9. package/dist/private-local-exec-review.js +66 -17
  10. package/dist/private-local-exec-rules.d.ts +5 -3
  11. package/dist/private-local-exec-rules.js +30 -9
  12. package/dist/private-local-extension-archive-notice.d.ts +8 -0
  13. package/dist/private-local-extension-archive-notice.js +108 -0
  14. package/dist/private-local-extension-records.d.ts +25 -0
  15. package/dist/private-local-extension-records.js +51 -0
  16. package/dist/private-local-process-identity.d.ts +14 -0
  17. package/dist/private-local-process-identity.js +51 -0
  18. package/dist/private-local-runtime-host.d.ts +16 -1
  19. package/dist/private-local-runtime-host.js +92 -37
  20. package/dist/private-local-service-discovery.js +2 -3
  21. package/dist/private-local-service-listener.js +1 -1
  22. package/dist/private-local-service-log.d.ts +10 -0
  23. package/dist/private-local-service-log.js +3 -0
  24. package/dist/private-local-service-owner.d.ts +1 -1
  25. package/dist/private-local-service-owner.js +22 -8
  26. package/dist/private-local-service-termination.js +12 -11
  27. package/dist/private-local-service.js +12 -0
  28. package/dist/private-local-session-discovery.d.ts +6 -0
  29. package/dist/private-local-session-discovery.js +31 -0
  30. package/dist/private-mcp-form-contract.d.ts +15 -0
  31. package/dist/private-mcp-form-contract.js +8 -1
  32. package/dist/private-mcp-form-session.d.ts +32 -0
  33. package/dist/private-mcp-form-session.js +8 -5
  34. 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 frozenUndeliverable = delegation?.reply.outcome === "undeliverable" &&
119
- delegation.reply.agentId === mail.from.agentId;
120
- if (!agents.some(({ agentId }) => agentId === mail.from.agentId) &&
121
- !frozenUndeliverable)
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 && !frozenUndeliverable)
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);
@@ -7,4 +7,6 @@ export declare function localExecEscapeReview(request: {
7
7
  escape?: {
8
8
  justification: string;
9
9
  } | undefined;
10
- }, cwd: string): LocalApprovalReview;
10
+ }, cwd: string, gitGrant?: {
11
+ configuredRemotes: ReadonlySet<string>;
12
+ }): LocalApprovalReview;
@@ -1,4 +1,5 @@
1
- import { rulePrefix } from "./private-local-exec-rules.js";
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",
@@ -17,34 +18,82 @@ const gitHookGrantSubcommands = new Set([
17
18
  "tag",
18
19
  ]);
19
20
  /** Build the host's review before the approval record is staged or displayed. */
20
- export function localExecEscapeReview(request, cwd) {
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 command = request.command ??
24
- JSON.stringify([request.executable, ...(request.args ?? [])]);
25
- const prefix = request.executable === undefined
24
+ const argv = request.executable === undefined
26
25
  ? undefined
27
- : rulePrefix([request.executable, ...(request.args ?? [])]);
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);
28
31
  const review = {
29
32
  command,
30
33
  cwd,
31
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",
32
39
  };
33
- if (prefix !== undefined) {
40
+ if (argv !== undefined)
41
+ review.argv = argv;
42
+ if (prefix !== undefined && review.kind === "exec") {
34
43
  review.prefix = prefix;
44
+ review.alwaysAllowCoverage =
45
+ prefix[0] === "git"
46
+ ? gitRuleCoverage(prefix[1] ?? "")
47
+ : `Direct argv commands beginning ${JSON.stringify(prefix)} in this project; shell commands still ask.`;
35
48
  if (prefix[0] === "git") {
36
49
  const hookCapable = gitHookGrantSubcommands.has(prefix[1] ?? "");
37
- review.warning =
38
- (hookCapable
39
- ? "Project hooks can run code as you 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."
40
- : "This command runs as you outside the sandbox.") +
41
- (prefix[1] === "push"
42
- ? " This grant can push all branches, force-update them, or delete remote branches."
43
- : "") +
44
- (["push", "fetch", "pull"].includes(prefix[1] ?? "")
45
- ? " Remote URLs can invoke ssh with your HOME and keys."
46
- : "");
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 };
47
73
  }
48
74
  }
75
+ if (request.executable === "git") {
76
+ const hookCapable = gitHookGrantSubcommands.has(request.args?.[0] ?? "");
77
+ review.warning =
78
+ (hookCapable
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.`
80
+ : "This command runs as you outside the sandbox.") +
81
+ (request.args?.[0] === "push"
82
+ ? " This grant can push all branches, force-update them, or delete remote branches."
83
+ : "") +
84
+ (["push", "fetch", "pull"].includes(request.args?.[0] ?? "")
85
+ ? " Remote URLs can invoke ssh with your HOME and keys."
86
+ : "");
87
+ }
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)
92
+ review.alwaysAllowUnavailableReason =
93
+ request.executable === undefined
94
+ ? "Shell command text cannot match a saved argv rule; Approve once or Deny."
95
+ : request.executable === "git" && gitGrant === undefined
96
+ ? "A protected project Git repository is unavailable; no reusable Git rule can match."
97
+ : "This command or option cannot match a safe reusable rule; Approve once or Deny.";
49
98
  return review;
50
99
  }
@@ -1,9 +1,11 @@
1
- /** Offer only direct, narrow grants; extension installs require a fresh card. */
2
- export declare function rulePrefix(argv: readonly string[]): string[] | undefined;
1
+ /** Offer only direct commands that this same matcher can authorize. */
2
+ export declare function rulePrefix(argv: readonly string[], configuredRemotes?: ReadonlySet<string>): string[] | undefined;
3
+ /** Text for the same Git option profile that gates matching, not a second list. */
4
+ export declare function gitRuleCoverage(subcommand: string): string;
3
5
  /** One host-owned project file; every Agent in the project sees the same rules. */
4
6
  export declare function localExecRules(project: string): {
5
7
  list: () => string[][];
6
- add: (prefix: readonly string[]) => void;
8
+ add: (prefix: readonly string[]) => import("@fungi.computer/cairn").CairnId<"local-exec-rules", "rule", 1>;
7
9
  remove: (prefix: readonly string[]) => void;
8
10
  match: (argv: readonly string[], configuredRemotes?: ReadonlySet<string>) => {
9
11
  ruleId: import("@fungi.computer/cairn").CairnId<"local-exec-rules", "rule", 1>;
@@ -62,7 +62,7 @@ const gitOptions = {
62
62
  "-s",
63
63
  "--signoff",
64
64
  ],
65
- values: ["-m", "--message"],
65
+ values: ["-m", "--message", "-am"],
66
66
  },
67
67
  diff: {
68
68
  flags: [
@@ -250,8 +250,8 @@ function validPrefix(prefix) {
250
250
  prefix.length === 2 &&
251
251
  gitBuiltins.has(prefix[1] ?? ""))));
252
252
  }
253
- /** Offer only direct, narrow grants; extension installs require a fresh card. */
254
- export function rulePrefix(argv) {
253
+ /** Offer only direct commands that this same matcher can authorize. */
254
+ export function rulePrefix(argv, configuredRemotes) {
255
255
  if (argv.length === 0 ||
256
256
  argv[0]?.startsWith(".") ||
257
257
  argv[0]?.includes("/") ||
@@ -260,7 +260,9 @@ export function rulePrefix(argv) {
260
260
  return undefined;
261
261
  if (basename(argv[0] ?? "") === "git") {
262
262
  const prefix = argv.slice(0, 2);
263
- return validPrefix(prefix) ? prefix : undefined;
263
+ return validPrefix(prefix) && safeGitCommand(argv, configuredRemotes)
264
+ ? prefix
265
+ : undefined;
264
266
  }
265
267
  if (basename(argv[0] ?? "") === "fungi" &&
266
268
  argv[1] === "extension" &&
@@ -269,6 +271,21 @@ export function rulePrefix(argv) {
269
271
  const parsed = prefixSchema.safeParse(argv.slice(0, 2));
270
272
  return parsed.success && validPrefix(parsed.data) ? parsed.data : undefined;
271
273
  }
274
+ /** Text for the same Git option profile that gates matching, not a second list. */
275
+ export function gitRuleCoverage(subcommand) {
276
+ const profile = gitProfiles.get(subcommand);
277
+ if (profile === undefined)
278
+ throw new Error("No reusable Git option profile");
279
+ const flags = profile.flags.filter((flag) => flag !== "--").join(", ");
280
+ const values = profile.values.map((option) => `${option} VALUE`).join(", ");
281
+ const remote = subcommand === "push" ||
282
+ subcommand === "fetch" ||
283
+ subcommand === "pull" ||
284
+ subcommand === "remote"
285
+ ? " Remote operands must be configured names or permitted explicit URLs."
286
+ : "";
287
+ return `Direct argv git ${subcommand} for Workspace Agents in this protected project repository. Only these options are reusable: ${flags || "none"}${values ? `; ${values}` : ""}; -- and permitted operands.${remote} Shell commands and unlisted Git options still ask.`;
288
+ }
272
289
  function safeRemote(remote, configuredRemotes) {
273
290
  if (remote === undefined)
274
291
  return true;
@@ -378,14 +395,18 @@ export function localExecRules(project) {
378
395
  const parsed = prefixSchema.parse(prefix);
379
396
  if (!validPrefix(parsed))
380
397
  throw new Error("Unsafe Always-allow prefix");
398
+ let ruleId;
381
399
  lockedUpdate(project, () => {
382
400
  const existing = readProject(project).rules;
383
- if (!existing.some((rule) => JSON.stringify(rule.prefix) === JSON.stringify(parsed)))
384
- writeProject(project, [
385
- ...existing,
386
- { id: projectRuleIds.rule.generate(), prefix: parsed },
387
- ]);
401
+ ruleId = existing.find((rule) => JSON.stringify(rule.prefix) === JSON.stringify(parsed))?.id;
402
+ if (ruleId === undefined) {
403
+ ruleId = projectRuleIds.rule.generate();
404
+ writeProject(project, [...existing, { id: ruleId, prefix: parsed }]);
405
+ }
388
406
  });
407
+ if (ruleId === undefined)
408
+ throw new Error("Exec rule was not persisted");
409
+ return ruleId;
389
410
  };
390
411
  const remove = (prefix) => {
391
412
  const parsed = prefixSchema.parse(prefix);
@@ -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);
@@ -48,6 +48,13 @@ export type PrivateLocalTools = Readonly<{
48
48
  /** Background Computer processes, scoped per Session; the host closes them. */
49
49
  processes: PrivateLocalComputerProcesses;
50
50
  }>;
51
+ export type ExecApprovalDecision = {
52
+ agentId: string;
53
+ recordId: string;
54
+ decision: "approve" | "deny";
55
+ scope?: "once" | "always";
56
+ ruleId?: string;
57
+ };
51
58
  /** Inputs for the one private local Agent opener. */
52
59
  export type PrivateLocalRuntimeHostOptions = Readonly<{
53
60
  /** Service-selected host configuration, independent of this Agent's Files root. */
@@ -63,6 +70,8 @@ export type PrivateLocalRuntimeHostOptions = Readonly<{
63
70
  executable: string;
64
71
  ruleId: string;
65
72
  }>) => void;
73
+ /** The human's committed approval choice, without command text or secrets. */
74
+ onExecApprovalDecision?: (event: Readonly<ExecApprovalDecision>) => void;
66
75
  /** Existing Agent database to reopen instead of creating a fresh local store. */
67
76
  databasePath?: string | undefined;
68
77
  /** Stable Local store identity for replaying one Agent create request. */
@@ -71,6 +80,8 @@ export type PrivateLocalRuntimeHostOptions = Readonly<{
71
80
  name?: string | undefined;
72
81
  /** The Agent that created this one; recorded once with its identity. Absent when a human made it. */
73
82
  createdBy?: string | undefined;
83
+ /** The Session whose create request is durably bound to this Local receipt. */
84
+ createdBySessionId?: string | undefined;
74
85
  /** Immutable project-relative folder authority, inherited by child Agents. */
75
86
  folder?: string | undefined;
76
87
  /** Persist before constructing the Agent, which can recover durable work. */
@@ -163,8 +174,12 @@ export type PrivateLocalCreateStore = Readonly<{
163
174
  agentId: LocalStoreId;
164
175
  databasePath: string;
165
176
  }>;
177
+ export type PrivateLocalCreateCreator = Readonly<{
178
+ agentId: string;
179
+ sessionId: string;
180
+ }>;
166
181
  /** Resolve an Agent create request through its durable Local receipt. */
167
- export declare function resolvePrivateLocalCreateStore(root: string, requestId: string): Promise<PrivateLocalCreateStore>;
182
+ export declare function resolvePrivateLocalCreateStore(root: string, requestId: string, creator?: PrivateLocalCreateCreator): Promise<PrivateLocalCreateStore>;
168
183
  /** Build the fixed source read by Shiitake once for each local run. */
169
184
  export declare function createPrivateLocalInstructionSource(port: Awaited<ReturnType<typeof createPrivateInvocationFilesPort>>): InstructionSource;
170
185
  export declare function createPrivateLocalModelSource(models: Models, providerId: string | undefined, modelId: string | undefined, providerCacheKey: string, selectedOwnerReasoning?: ThinkingLevel, defaults?: Readonly<{