toolroll 0.9.72 → 0.9.73
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/CHANGELOG.md +76 -0
- package/dist/agent-fence.d.ts +5 -0
- package/dist/agent-fence.js +42 -7
- package/dist/browser/workspace.js +28 -28
- package/dist/chat-actions.js +13 -8
- package/dist/chat-decide.d.ts +47 -1
- package/dist/chat-decide.js +170 -4
- package/dist/check-report.js +2 -1
- package/dist/coding-provider.js +4 -2
- package/dist/commit-extract.d.ts +17 -0
- package/dist/commit-extract.js +82 -4
- package/dist/contracts/console-api.d.ts +3 -3
- package/dist/contracts/console-api.js +4 -4
- package/dist/contracts/release-terms.d.ts +7 -1
- package/dist/contracts/release-terms.js +6 -2
- package/dist/exec.d.ts +1 -1
- package/dist/exec.js +7 -3
- package/dist/flow-gallery-ui.js +4 -2
- package/dist/flow-gallery.d.ts +3 -0
- package/dist/flow-gallery.js +6 -2
- package/dist/flow-triggers.d.ts +8 -0
- package/dist/flow-triggers.js +15 -0
- package/dist/flows-cli.js +13 -3
- package/dist/guides.js +10 -1
- package/dist/kits-ui.js +4 -2
- package/dist/kits.d.ts +6 -4
- package/dist/kits.js +9 -11
- package/dist/mcp-connect.d.ts +10 -0
- package/dist/mcp-connect.js +2 -0
- package/dist/project-tools.js +3 -1
- package/dist/protected-storage.d.ts +67 -0
- package/dist/protected-storage.js +398 -0
- package/dist/release-adapters.d.ts +8 -1
- package/dist/release-adapters.js +80 -14
- package/dist/release-authority.d.ts +6 -0
- package/dist/release-authority.js +9 -0
- package/dist/release-card.d.ts +76 -0
- package/dist/release-card.js +252 -0
- package/dist/release-carry.d.ts +3 -0
- package/dist/release-carry.js +23 -4
- package/dist/release-cli.js +36 -3
- package/dist/release-git.d.ts +43 -0
- package/dist/release-git.js +106 -0
- package/dist/release.d.ts +8 -2
- package/dist/release.js +79 -6
- package/dist/scope.d.ts +7 -0
- package/dist/scope.js +22 -5
- package/dist/server/flows.js +40 -10
- package/dist/server/render-settings.js +1 -1
- package/dist/server/settings.js +26 -5
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +3 -0
- package/dist/store.js +6 -0
- package/dist/subagent-memory.d.ts +1 -0
- package/dist/subagent-memory.js +1 -1
- package/dist/subagent-tools.d.ts +22 -6
- package/dist/subagent-tools.js +44 -8
- package/dist/subagent-work.d.ts +2 -0
- package/dist/subagent-work.js +5 -2
- package/dist/subagents-ui.js +6 -4
- package/dist/telegram.d.ts +6 -1
- package/dist/telegram.js +58 -6
- package/package.json +1 -1
|
@@ -13,6 +13,7 @@ export declare const releasePolicySchema: z.ZodObject<{
|
|
|
13
13
|
files: z.ZodInt;
|
|
14
14
|
churn: z.ZodInt;
|
|
15
15
|
}, z.core.$strict>;
|
|
16
|
+
unsandboxedCarry: z.ZodOptional<z.ZodBoolean>;
|
|
16
17
|
}, z.core.$strict>;
|
|
17
18
|
export type ReleasePolicy = z.infer<typeof releasePolicySchema>;
|
|
18
19
|
export declare const releaseTermsSchema: z.ZodObject<{
|
|
@@ -35,12 +36,17 @@ export declare const releaseTermsSchema: z.ZodObject<{
|
|
|
35
36
|
files: z.ZodInt;
|
|
36
37
|
churn: z.ZodInt;
|
|
37
38
|
}, z.core.$strict>;
|
|
39
|
+
unsandboxedCarry: z.ZodOptional<z.ZodBoolean>;
|
|
38
40
|
}, z.core.$strict>;
|
|
39
41
|
grant: z.ZodObject<{
|
|
40
42
|
id: z.ZodInt;
|
|
41
43
|
digest: z.ZodString;
|
|
42
44
|
}, z.core.$strict>;
|
|
43
|
-
access: z.
|
|
45
|
+
access: z.ZodEnum<{
|
|
46
|
+
sandboxed: "sandboxed";
|
|
47
|
+
"sign-ins": "sign-ins";
|
|
48
|
+
unsandboxed: "unsandboxed";
|
|
49
|
+
}>;
|
|
44
50
|
expiresAt: z.ZodISODateTime;
|
|
45
51
|
}, z.core.$strict>;
|
|
46
52
|
export type ReleaseTerms = z.infer<typeof releaseTermsSchema>;
|
|
@@ -17,6 +17,9 @@ export const releasePolicySchema = versioned(1, {
|
|
|
17
17
|
protectProject: z.boolean(),
|
|
18
18
|
protectedPaths: z.array(z.string().min(1).max(400)).max(200),
|
|
19
19
|
limits: z.strictObject({ lines: z.int().min(0).max(1_000_000), files: z.int().min(0).max(100_000), churn: z.int().min(0).max(1_000_000) }),
|
|
20
|
+
/** "Carry release approvals when checks aren't sandboxed" (a person's project setting, off by default). Absent (a
|
|
21
|
+
* policy sealed before it existed): off. */
|
|
22
|
+
unsandboxedCarry: z.boolean().optional(),
|
|
20
23
|
});
|
|
21
24
|
const sha = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/);
|
|
22
25
|
export const releaseTermsSchema = versioned(1, {
|
|
@@ -29,8 +32,9 @@ export const releaseTermsSchema = versioned(1, {
|
|
|
29
32
|
approvedTree: sha,
|
|
30
33
|
policy: releasePolicySchema,
|
|
31
34
|
grant: z.strictObject({ id: z.int(), digest: z.string().min(1).max(128) }),
|
|
32
|
-
/** The check access the release check runs with:
|
|
33
|
-
|
|
35
|
+
/** The check access the release check runs with at approval: the fence, or (only with unsandboxedCarry sealed in the
|
|
36
|
+
* policy) a wider one. Never wider later: a check that reaches further than this stops the release. */
|
|
37
|
+
access: z.enum(["sandboxed", "sign-ins", "unsandboxed"]),
|
|
34
38
|
expiresAt: z.iso.datetime(),
|
|
35
39
|
});
|
|
36
40
|
export function readReleaseTerms(input) {
|
package/dist/exec.d.ts
CHANGED
|
@@ -200,7 +200,7 @@ export declare function confined(file: string, args: readonly string[], bag: {
|
|
|
200
200
|
env: Record<string, string>;
|
|
201
201
|
};
|
|
202
202
|
/** A spawn's environment with what its sandbox needs added (nothing added: the caller's, as it was). */
|
|
203
|
-
export declare function withSandboxEnv<T extends Record<string, string | undefined>>(env: T | undefined, added: Record<string, string
|
|
203
|
+
export declare function withSandboxEnv<T extends Record<string, string | undefined>>(env: T | undefined, added: Record<string, string>, fenced?: boolean): T | Record<string, string | undefined> | undefined;
|
|
204
204
|
/** Run IDs are local to a database. Connections to the same file share
|
|
205
205
|
* custody; separate files and separate in-memory databases never do. */
|
|
206
206
|
export declare function runOwnerTag(store: Store, runId: number): string;
|
package/dist/exec.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { ACP_LINE_CAP, createAcpConversation } from "./acp-client.js";
|
|
10
10
|
import { linuxFenceAvailable, macosFenceAvailable, sandboxedSpawn } from "./agent-fence.js";
|
|
11
|
+
import { withoutProtectedEnv } from "./protected-storage.js";
|
|
11
12
|
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
12
13
|
import { readlinkSync, realpathSync } from "node:fs";
|
|
13
14
|
import { observeProcessTree, stopProcessTree } from "./process-tree.js";
|
|
@@ -75,7 +76,10 @@ export function confined(file, args, bag) {
|
|
|
75
76
|
return { file, args: [...args], env: {} };
|
|
76
77
|
}
|
|
77
78
|
/** A spawn's environment with what its sandbox needs added (nothing added: the caller's, as it was). */
|
|
78
|
-
export function withSandboxEnv(env, added) {
|
|
79
|
+
export function withSandboxEnv(env, added, fenced = false) {
|
|
80
|
+
// A fenced launch never gets a chat or vault variable (protected-storage.ts), whoever made its environment.
|
|
81
|
+
if (fenced)
|
|
82
|
+
return withoutProtectedEnv({ ...(env ?? process.env), ...added });
|
|
79
83
|
return Object.keys(added).length === 0 ? env : { ...(env ?? process.env), ...added };
|
|
80
84
|
}
|
|
81
85
|
/** A spawn callback refused custody AFTER the child existed: the caller
|
|
@@ -107,7 +111,7 @@ function spawnContained(file, args, spawnOptions, label, bag, contain = true) {
|
|
|
107
111
|
// The agent fence wraps the target itself (sandbox-exec then execs it, same pid).
|
|
108
112
|
const sandboxed = confined(file, args, { ...bag, cwd: spawnOptions.cwd, temp: spawnOptions.env?.["TMPDIR"] });
|
|
109
113
|
({ file, args } = sandboxed);
|
|
110
|
-
spawnOptions = { ...spawnOptions, env: withSandboxEnv(spawnOptions.env, sandboxed.env) };
|
|
114
|
+
spawnOptions = { ...spawnOptions, env: withSandboxEnv(spawnOptions.env, sandboxed.env, (bag.fence?.length ?? 0) > 0) };
|
|
111
115
|
// The policy refusal comes first: nothing is reserved, recorded or
|
|
112
116
|
// spawned for a spawn that cannot be contained as required.
|
|
113
117
|
const effective = contain ? currentContainment() : null;
|
|
@@ -583,7 +587,7 @@ export function run(file, args, options = {}) {
|
|
|
583
587
|
encoding: "utf8",
|
|
584
588
|
shell: false,
|
|
585
589
|
windowsHide: true,
|
|
586
|
-
...((env => env === undefined ? {} : { env }))(withSandboxEnv(childEnv, wrapped.env)),
|
|
590
|
+
...((env => env === undefined ? {} : { env }))(withSandboxEnv(childEnv, wrapped.env, (options.fence?.length ?? 0) > 0)),
|
|
587
591
|
}, (error, stdout, stderr) => {
|
|
588
592
|
if (error === null) {
|
|
589
593
|
resolve({ result: { code: 0, stdout, stderr, timedOut: false, notFound: false }, transient: false });
|
package/dist/flow-gallery-ui.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* A template's tools show their marks and whether the project has them; one it lacks offers Connect first.
|
|
6
6
|
*/
|
|
7
7
|
import { brandMarkHtml } from "./brand-mark.js";
|
|
8
|
-
import { BLANK, GALLERY, GALLERY_GROUPS, galleryDiagram, galleryToolsOf, OUTDATED_COMMANDS, SEND_RESULT, sendsAlready } from "./flow-gallery.js";
|
|
8
|
+
import { BLANK, GALLERY, GALLERY_GROUPS, galleryAdmitsAnyone, galleryDiagram, galleryToolsOf, OUTDATED_COMMANDS, SEND_RESULT, sendsAlready } from "./flow-gallery.js";
|
|
9
9
|
import { choiceTargets } from "./flows.js";
|
|
10
10
|
import { html, joinHtml, postForm, reauth } from "./html.js";
|
|
11
11
|
export const GALLERY_CSS = `.gallery{max-width:1120px;min-width:0}.gallery h2{margin:28px 0 10px;font-size:.9375rem}.gallery h2:first-of-type{margin-top:8px}` +
|
|
@@ -107,7 +107,9 @@ export function galleryUseHtml(input) {
|
|
|
107
107
|
: html `<input type="hidden" name="repo" value="${input.repo}">`;
|
|
108
108
|
const preview = input.preview === null ? "" : html `<section class="gallery-preview" aria-labelledby="gallery-will"><h2 id="gallery-will">What it will do</h2><ul>${input.preview.built.does.map(one => html `<li>${one}</li>`)}</ul><p class="gallery-never">${input.preview.built.never}</p>${needing}${input.preview.startsFrom.length === 0 ? html `<p class="meta">Cards start when you add them.</p>` : input.preview.startsFrom.map(one => html `<p class="meta">Starts from: ${one}</p>`)}<details><summary>Every step</summary><ol>${input.preview.steps.map(one => html `<li>${one.replace(/^\d+\.\s/, "")}</li>`)}</ol></details></section>`;
|
|
109
109
|
const sendResult = !sendsAlready(template) && html `<label class="gallery-check"><input type="checkbox" name="${SEND_RESULT.key}" value="yes"${input.answers[SEND_RESULT.key] === "yes" && html ` checked`} data-send-result><span>${SEND_RESULT.title}<small>When a card finishes, what was done comes to you in your chat apps.</small></span></label>`;
|
|
110
|
-
|
|
110
|
+
// A flow that brings in items from anyone takes the password to make (chat-management.md §0a.3).
|
|
111
|
+
const step = input.preview !== null && galleryAdmitsAnyone(template) ? reauth(html `<label>Your password <span class="meta">(it brings in items from anyone)</span><input type="password" name="password" autocomplete="current-password"></label>`) : "";
|
|
112
|
+
const actions = html `${step}<p class="gallery-actions">${input.preview !== null && html `<button type="submit" name="intent" value="create"${missing.length > 0 && html ` class="secondary" data-without-tools`}>Create flow</button>`}<button type="submit" name="intent" value="preview" class="secondary">${input.preview === null ? "Preview" : "Update preview"}</button></p>`;
|
|
111
113
|
const form = postForm(`/flows/new/${template.id}`, html `${project}${template.asks.map(field)}<label>Name<input name="name" value="${input.name}" maxlength="80"></label>${sendResult}${preview}${actions}`, { attrs: { "data-gallery-use": true }, hidden: { previewed: input.preview?.digest ?? "" } });
|
|
112
114
|
return html `<section class="gallery-use" data-gallery-template="${template.id}">${input.problem !== null && html `<p class="problem" role="alert">${input.problem}</p>`}${input.said ? html `<p role="status">${input.said}</p>` : ""}<p>${template.promise}</p>${input.diagram !== null && stepStrip(input.diagram)}${needsList(template)}${toolsBlock}${form}<p class="meta"><a href="/flows/new">All templates</a></p></section>`;
|
|
113
115
|
}
|
package/dist/flow-gallery.d.ts
CHANGED
|
@@ -136,9 +136,12 @@ export type GalleryUsed = {
|
|
|
136
136
|
said: string;
|
|
137
137
|
};
|
|
138
138
|
/** Use a template in a project: its scripts, its flow and its triggers, together or not at all. */
|
|
139
|
+
/** Whether a template's triggers bring in items from anyone: making it takes the person's password. */
|
|
140
|
+
export declare function galleryAdmitsAnyone(template: GalleryTemplate): boolean;
|
|
139
141
|
export declare function useGalleryTemplate(store: Store, template: GalleryTemplate, repo: string, given: GalleryAnswers, input: {
|
|
140
142
|
name?: string | null;
|
|
141
143
|
by: string;
|
|
142
144
|
now: Date;
|
|
143
145
|
dir: string | null;
|
|
146
|
+
widening?: "proved";
|
|
144
147
|
}): GalleryUsed;
|
package/dist/flow-gallery.js
CHANGED
|
@@ -20,7 +20,7 @@ import { scanForSecrets } from "./evidence.js";
|
|
|
20
20
|
import { flowDefinitionOf } from "./flow-engine.js";
|
|
21
21
|
import { saveScript, validateScript } from "./flow-scripts.js";
|
|
22
22
|
import { isToolrollRepo, planeReviewSteps } from "./flow-starters.js";
|
|
23
|
-
import { addFlowTriggerTo, describeTrigger, githubRepoOf, scheduleFromWords, validateTriggerConfig } from "./flow-triggers.js";
|
|
23
|
+
import { addFlowTriggerTo, describeTrigger, githubRepoOf, rawAdmitsAnyone, scheduleFromWords, validateTriggerConfig } from "./flow-triggers.js";
|
|
24
24
|
import { choiceTargets, clockTime, flowFromSteps, flowTerms, FLOW_TEMPLATES, ISSUE_LABEL } from "./flows.js";
|
|
25
25
|
import { connectionsOf, localConnectOf, oneClickOf } from "./mcp-connect.js";
|
|
26
26
|
import { publishingOf } from "./pull-request-flow.js";
|
|
@@ -640,6 +640,10 @@ export function galleryDigest(id, repo, answers) {
|
|
|
640
640
|
return createHash("sha256").update(JSON.stringify([id, repo, Object.entries(answers).sort(([a], [b]) => a.localeCompare(b))])).digest("hex").slice(0, 24);
|
|
641
641
|
}
|
|
642
642
|
/** Use a template in a project: its scripts, its flow and its triggers, together or not at all. */
|
|
643
|
+
/** Whether a template's triggers bring in items from anyone: making it takes the person's password. */
|
|
644
|
+
export function galleryAdmitsAnyone(template) {
|
|
645
|
+
return template.triggers.some(one => rawAdmitsAnyone(one));
|
|
646
|
+
}
|
|
643
647
|
export function useGalleryTemplate(store, template, repo, given, input) {
|
|
644
648
|
try {
|
|
645
649
|
const built = buildFromGallery(store, template, repo, given);
|
|
@@ -656,7 +660,7 @@ export function useGalleryTemplate(store, template, repo, given, input) {
|
|
|
656
660
|
const made = store.getFlow(id);
|
|
657
661
|
for (const trigger of built.triggers) {
|
|
658
662
|
// No one is shown the gallery's addresses: a webhook is made with none until its panel makes one (v126).
|
|
659
|
-
const added = addFlowTriggerTo(store, made, { ...trigger, zone: trigger["zone"] ?? flowDefinitionOf(made).start }, input.by, input.now, input.dir, { shown: false });
|
|
663
|
+
const added = addFlowTriggerTo(store, made, { ...trigger, zone: trigger["zone"] ?? flowDefinitionOf(made).start }, input.by, input.now, input.dir, { shown: false, ...(input.widening === undefined ? {} : { widening: input.widening }) });
|
|
660
664
|
if (!added.ok)
|
|
661
665
|
throw new Error(added.message);
|
|
662
666
|
if (added.reveal !== null)
|
package/dist/flow-triggers.d.ts
CHANGED
|
@@ -57,6 +57,11 @@ export declare const senderOf: (config: Extract<TriggerConfig, {
|
|
|
57
57
|
/** Whether this trigger takes deliveries at a secret address rather than being checked. */
|
|
58
58
|
/** Whether a raw trigger the caller is about to add would take deliveries (an address): read leniently, before validation. */
|
|
59
59
|
export declare function rawTakesDeliveries(raw: unknown): boolean;
|
|
60
|
+
/** Whether a trigger brings in items from anyone (chat-management.md §0a.3): GitHub issues or pull requests from anyone,
|
|
61
|
+
* or mail from any sender. A widening: it takes a person's password, on every road that adds one. */
|
|
62
|
+
export declare function admitsAnyone(config: TriggerConfig): boolean;
|
|
63
|
+
/** The same, read leniently from a raw trigger before validation (so the form can ask for the password first). */
|
|
64
|
+
export declare function rawAdmitsAnyone(raw: unknown): boolean;
|
|
60
65
|
export declare function takesDeliveries(config: TriggerConfig): boolean;
|
|
61
66
|
/** A webhook's signing secret (made here, or pasted from its sender). In the keychain (or an owner-only file where there's none), never a column. */
|
|
62
67
|
export declare function saveHookSecret(dir: string, trigger: number, secret: string, credential?: string): void;
|
|
@@ -104,6 +109,9 @@ export type TriggerMade = {
|
|
|
104
109
|
export declare function addFlowTriggerTo(store: Store, flow: FlowRow, raw: unknown, actor: string, now: Date, dir: string | null, options?: {
|
|
105
110
|
shown?: boolean;
|
|
106
111
|
ingress?: "proved";
|
|
112
|
+
/** The person proved themselves for this act (a password, or a session under Passwordless): a trigger that admits
|
|
113
|
+
* anyone is added only then. */
|
|
114
|
+
widening?: "proved";
|
|
107
115
|
}): TriggerMade;
|
|
108
116
|
/** A new secret address for a webhook trigger (the old one stops working at once). */
|
|
109
117
|
export declare function renewFlowHook(store: Store, trigger: FlowTriggerRow, now: Date, dir: string): TriggerMade;
|
package/dist/flow-triggers.js
CHANGED
|
@@ -368,6 +368,18 @@ export function rawTakesDeliveries(raw) {
|
|
|
368
368
|
const one = raw;
|
|
369
369
|
return one["kind"] === "webhook" || ((one["kind"] === "github" || one["kind"] === "linear") && one["delivery"] === "webhook");
|
|
370
370
|
}
|
|
371
|
+
/** Whether a trigger brings in items from anyone (chat-management.md §0a.3): GitHub issues or pull requests from anyone,
|
|
372
|
+
* or mail from any sender. A widening: it takes a person's password, on every road that adds one. */
|
|
373
|
+
export function admitsAnyone(config) {
|
|
374
|
+
return (config.kind === "github" && config.from === "anyone") || (config.kind === "email" && config.sender === null);
|
|
375
|
+
}
|
|
376
|
+
/** The same, read leniently from a raw trigger before validation (so the form can ask for the password first). */
|
|
377
|
+
export function rawAdmitsAnyone(raw) {
|
|
378
|
+
if (raw === null || typeof raw !== "object")
|
|
379
|
+
return false;
|
|
380
|
+
const one = raw;
|
|
381
|
+
return (one["kind"] === "github" && one["from"] === "anyone") || (one["kind"] === "email" && (typeof one["sender"] !== "string" || one["sender"].trim() === ""));
|
|
382
|
+
}
|
|
371
383
|
export function takesDeliveries(config) {
|
|
372
384
|
return config.kind === "webhook" || ((config.kind === "github" || config.kind === "linear") && config.delivery === "webhook");
|
|
373
385
|
}
|
|
@@ -495,6 +507,9 @@ export function addFlowTriggerTo(store, flow, raw, actor, now, dir, options = {}
|
|
|
495
507
|
return { ok: false, message: "Webhook addresses are set up on the console." };
|
|
496
508
|
if (takesDeliveries(config) && config.kind !== "webhook" && options.ingress !== "proved")
|
|
497
509
|
return { ok: false, message: "Webhook delivery is set up on the flow's trigger panel, with your password." };
|
|
510
|
+
// Items from anyone come in only on a person's proof (chat-management.md §0a.3): never from a card, an import or a template alone.
|
|
511
|
+
if (admitsAnyone(config) && options.ingress !== "proved" && options.widening !== "proved")
|
|
512
|
+
return { ok: false, message: "A trigger that brings in items from anyone takes your password. Add it on the flow's Triggers panel." };
|
|
498
513
|
// v126: a webhook made where no one is shown its address (the gallery, an import) is made with none: its first
|
|
499
514
|
// set-up or New address makes one and shows it. A webhook made on its panel is shown its address now.
|
|
500
515
|
const token = takesDeliveries(config) && options.ingress === "proved" && (options.shown !== false || config.kind !== "webhook") ? randomBytes(24).toString("base64url") : null;
|
package/dist/flows-cli.js
CHANGED
|
@@ -21,7 +21,8 @@ import { deciderOf, FLOW_KIND_WORDS, FLOW_TEMPLATES, flowDigest, flowFromSteps,
|
|
|
21
21
|
import { prepareUnattendedCandidates, activeUnattendedGrant, parseUnattendedAsk, revokeUnattended, signUnattended, unattendedViewOf, unattendedWords, untilWords, UNATTENDED_MAX_RUNS } from './flow-unattended.js';
|
|
22
22
|
import { MODE_MAX_DAYS } from './modes.js';
|
|
23
23
|
import { verifyApproverByPassword } from './principal.js';
|
|
24
|
-
import { proveCredential } from './scope.js';
|
|
24
|
+
import { authenticateApprover, proveCredential } from './scope.js';
|
|
25
|
+
import { personProof } from './person-proof.js';
|
|
25
26
|
import { projectName } from './project.js';
|
|
26
27
|
const EXIT = { ok: 0, failed: 1, usage: 2, refused: 3 };
|
|
27
28
|
const json = { name: 'json', takesValue: false, meaning: 'answer with one machine envelope on stdout' };
|
|
@@ -157,6 +158,12 @@ export async function runFlowsCommand(positional, flags, context) {
|
|
|
157
158
|
store.recordAction({ at: context.clock().toISOString(), actor: who, repo: project, taskId: null, runId: null, action: command, outcome, source: 'request', detail: `command line${detail === null ? '' : ` · ${detail}`}` });
|
|
158
159
|
};
|
|
159
160
|
const refuse = (project, reason, message, code = EXIT.refused) => { record(project, 'refused', reason); return fail(reason, message, code); };
|
|
161
|
+
// A trigger from anyone widens what comes in (chat-management.md §0a.3): only the person's own proof counts, never a
|
|
162
|
+
// machine credential standing in for them (a remote run's API token, a lead's token), though both pass the check above.
|
|
163
|
+
const widening = (project) => {
|
|
164
|
+
const proved = authenticateApprover(store, acting.name, acting.token, project);
|
|
165
|
+
return proved.ok && personProof(proved.method) ? { widening: 'proved' } : {};
|
|
166
|
+
};
|
|
160
167
|
const settle = (project) => { try {
|
|
161
168
|
advanceFlows(store, project, context.clock(), { evidenceRoot: context.evidenceRoot });
|
|
162
169
|
}
|
|
@@ -243,11 +250,13 @@ export async function runFlowsCommand(positional, flags, context) {
|
|
|
243
250
|
if (!flags.has('yes'))
|
|
244
251
|
return preview(project, `Create the ${flowName} flow in ${projectName(project)}`, terms, { definition });
|
|
245
252
|
let id;
|
|
253
|
+
const proof = template?.trigger === undefined ? {} : widening(project);
|
|
246
254
|
try {
|
|
247
255
|
id = store.transact(() => {
|
|
248
256
|
const made = store.createFlow({ repo: project, name: flowName, definitionJson: JSON.stringify(definition), by: who }, now);
|
|
249
257
|
if (template?.trigger !== undefined) {
|
|
250
|
-
|
|
258
|
+
// A trigger from anyone only on the person's own proof (widening above); a machine credential's is refused.
|
|
259
|
+
const trigger = addFlowTriggerTo(store, store.getFlow(made), template.trigger, who, now, context.configDir, proof);
|
|
251
260
|
if (!trigger.ok)
|
|
252
261
|
throw new Error(trigger.message);
|
|
253
262
|
}
|
|
@@ -508,7 +517,8 @@ export async function runFlowsCommand(positional, flags, context) {
|
|
|
508
517
|
const ingress = rawTakesDeliveries(input.value);
|
|
509
518
|
if (ingress && !proveCredential(store, acting.name, acting.token).ok)
|
|
510
519
|
return refuse(flow.repo, 'unauthenticated', 'Making a webhook address takes your password, typed: pass --token.');
|
|
511
|
-
|
|
520
|
+
// A trigger from anyone only on the person's own proof (widening above): a machine credential's is refused.
|
|
521
|
+
const made = addFlowTriggerTo(store, flow, input.value, who, now, context.configDir, ingress ? { ingress: 'proved' } : widening(flow.repo));
|
|
512
522
|
if (!made.ok)
|
|
513
523
|
return refuse(flow.repo, 'invalid-trigger', made.message);
|
|
514
524
|
await prepareUnattendedCandidates(store, flow.id);
|
package/dist/guides.js
CHANGED
|
@@ -445,7 +445,13 @@ with a time limit.
|
|
|
445
445
|
|
|
446
446
|
## What it does, in order
|
|
447
447
|
|
|
448
|
-
1. **gate**:
|
|
448
|
+
1. **gate**: before filing, looks at the pull request's checks on the
|
|
449
|
+
branch's exact commit: a required check that failed stops here, before
|
|
450
|
+
anything is filed or sent for approval. Checks not started or still
|
|
451
|
+
running are named and waited for (GitHub not answering is waited out
|
|
452
|
+
too); if they don't finish within the gate's limit (60 min, \`--limit
|
|
453
|
+
gate=<min>\`), it stops here as well. A task already filed for the commit is kept and
|
|
454
|
+
not looked at again. Then it files task \`release-<version>-<sha7>\` pinned to the
|
|
449
455
|
branch's exact commit with \`--checks full\`, regardless of the project's default.
|
|
450
456
|
2. **approval**: waits for the owner to approve that exact scope
|
|
451
457
|
(\`toolroll task approve <id> --digest <d>\`, or the console). A
|
|
@@ -499,6 +505,9 @@ instead of making them twice.
|
|
|
499
505
|
checks and obtain a passing result; Quick and Off cannot authorize a release.
|
|
500
506
|
- \`check-failed\`: fix the branch and release the new commit with \`--new\`.
|
|
501
507
|
- \`ci-failed\`: fix the failed GitHub check, then rerun. Nothing was merged.
|
|
508
|
+
A stop at the gate comes before the task is filed, so nothing was filed
|
|
509
|
+
either: a fix pushed as a new commit is released with \`--new\`; after
|
|
510
|
+
rerunning the failed job on GitHub, run the same command again.
|
|
502
511
|
- \`publish-failed\` / \`homebrew-failed\`: fix the failed workflow before
|
|
503
512
|
resuming. A failed tag check cannot finish publication or update Homebrew.
|
|
504
513
|
- \`base-moved\`: main gained a commit the branch doesn't have, so the
|
package/dist/kits-ui.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { kitChecklist, kitInstalled, KITS } from "./kits.js";
|
|
2
2
|
import { SUBAGENT_TEMPLATES } from "./subagents.js";
|
|
3
|
-
import { html, postForm } from "./html.js";
|
|
3
|
+
import { html, postForm, reauth } from "./html.js";
|
|
4
4
|
const CHECK = html `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>`;
|
|
5
5
|
const RING = html `<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/></svg>`;
|
|
6
6
|
export const KITS_CSS = `.kits{max-width:960px;min-width:0}.kits-lede{color:var(--so-muted);margin:.25rem 0 1.5rem;max-width:40rem}.kit-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}` +
|
|
@@ -35,7 +35,9 @@ export function kitPageHtml(store, kit, repo, dir, canSetUp, notice) {
|
|
|
35
35
|
const rows = steps.map(step => {
|
|
36
36
|
const go = step.done ? (step.href === null ? "" : html `<a class="kit-step-go" href="${step.href}">Open</a>`)
|
|
37
37
|
: step.action === "sample" || step.action === "github"
|
|
38
|
-
? (canSetUp ? postForm(`/kits/${kit.id}/${step.action}`,
|
|
38
|
+
? (canSetUp ? postForm(`/kits/${kit.id}/${step.action}`, step.action === "sample" ? html `<button>Try it</button>`
|
|
39
|
+
// Issues from anyone come in: it takes your password (an ordinary step-up; a session does under Passwordless).
|
|
40
|
+
: html `${reauth(html `<input type="password" name="password" autocomplete="current-password" aria-label="Your password" placeholder="Your password">`)}<button class="secondary">Bring them in</button>`, { hidden: { repo } }) : "")
|
|
39
41
|
: step.href === null ? "" : html `<a class="kit-step-go" href="${step.href}">${step.action === "connect" ? "Connect" : "Set up"}</a>`;
|
|
40
42
|
return html `<li data-step="${step.id}" data-done="${String(step.done)}">${step.done ? CHECK : RING}<span class="kit-step-said">${step.said}</span>${go}</li>`;
|
|
41
43
|
});
|
package/dist/kits.d.ts
CHANGED
|
@@ -53,9 +53,11 @@ export declare function kitInstalled(store: Store, kit: Kit, repo: string): {
|
|
|
53
53
|
* flow, and its button triggers; then its subagent may use whichever of the
|
|
54
54
|
* kit's tools the project already has (reading free, writing asking first).
|
|
55
55
|
*/
|
|
56
|
-
export declare function setUpKit(store: Store, kit: Kit, repo: string, by: string, now: Date, dir: string | null, io?: ToolIo
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
export declare function setUpKit(store: Store, kit: Kit, repo: string, by: string, now: Date, dir: string | null, io?: ToolIo, options?: {
|
|
57
|
+
widening?: "proved";
|
|
58
|
+
}): Promise<Done>;
|
|
59
|
+
/** The same, all at once and without its tools (a chat confirmation is never a grant's proof): its subagent, its flow and its buttons. */
|
|
60
|
+
export declare function setUpKitNow(store: Store, kit: Kit, repo: string, by: string, now: Date, dir: string | null): Done;
|
|
59
61
|
/** One line of a kit's checklist: done, or the next step with where to take it. */
|
|
60
62
|
export type KitStep = {
|
|
61
63
|
id: string;
|
|
@@ -76,7 +78,7 @@ export declare function addKitSample(store: Store, kit: Kit, repo: string, by: s
|
|
|
76
78
|
said: string;
|
|
77
79
|
};
|
|
78
80
|
/** Bring new issues from the project's GitHub repository into the kit's flow (issues from anyone, polled). */
|
|
79
|
-
export declare function addKitGithubTrigger(store: Store, kit: Kit, repo: string, by: string, now: Date, dir: string | null): {
|
|
81
|
+
export declare function addKitGithubTrigger(store: Store, kit: Kit, repo: string, by: string, now: Date, dir: string | null, proved?: "proved" | null): {
|
|
80
82
|
ok: true;
|
|
81
83
|
said: string;
|
|
82
84
|
} | {
|
package/dist/kits.js
CHANGED
|
@@ -17,7 +17,7 @@ import { readEmailSettings } from "./email-settings.js";
|
|
|
17
17
|
import { mailboxReady } from "./mailbox.js";
|
|
18
18
|
import { projectToolsOf } from "./project-tools.js";
|
|
19
19
|
import { createSubagentFrom, nameOf } from "./subagent-admin.js";
|
|
20
|
-
import {
|
|
20
|
+
import { grantTool } from "./subagent-tools.js";
|
|
21
21
|
import { SUBAGENT_TEMPLATES } from "./subagents.js";
|
|
22
22
|
export const KITS = [
|
|
23
23
|
{
|
|
@@ -115,22 +115,23 @@ export function kitInstalled(store, kit, repo) {
|
|
|
115
115
|
* flow, and its button triggers; then its subagent may use whichever of the
|
|
116
116
|
* kit's tools the project already has (reading free, writing asking first).
|
|
117
117
|
*/
|
|
118
|
-
export async function setUpKit(store, kit, repo, by, now, dir, io = {}) {
|
|
118
|
+
export async function setUpKit(store, kit, repo, by, now, dir, io = {}, options = {}) {
|
|
119
119
|
const made = setUpKitNow(store, kit, repo, by, now, dir);
|
|
120
|
-
|
|
120
|
+
// Its tools only with the person's proof (chat-management.md §0a.3); without it, the checklist says to let it use each.
|
|
121
|
+
if (!made.ok || made.said.endsWith("already set up here.") || options.widening !== "proved")
|
|
121
122
|
return made;
|
|
122
123
|
const mate = store.getSubagent(made.subagent);
|
|
123
124
|
const present = new Set(projectToolsOf(store, repo).map(one => one.name));
|
|
124
125
|
for (const tool of kit.tools.filter(one => present.has(one.tool))) {
|
|
125
126
|
try {
|
|
126
|
-
await grantTool(store, mate, tool.tool, by, now, io);
|
|
127
|
+
await grantTool(store, mate, tool.tool, by, now, io, options);
|
|
127
128
|
}
|
|
128
129
|
catch { /* it can be given the tool on its page */ }
|
|
129
130
|
}
|
|
130
131
|
return made;
|
|
131
132
|
}
|
|
132
|
-
/** The same, all at once (a chat confirmation
|
|
133
|
-
export function setUpKitNow(store, kit, repo, by, now, dir
|
|
133
|
+
/** The same, all at once and without its tools (a chat confirmation is never a grant's proof): its subagent, its flow and its buttons. */
|
|
134
|
+
export function setUpKitNow(store, kit, repo, by, now, dir) {
|
|
134
135
|
const had = kitInstalled(store, kit, repo);
|
|
135
136
|
if (had !== null)
|
|
136
137
|
return { ok: true, said: `${kit.name} is already set up here.`, flow: had.flow.id, subagent: had.mate.id };
|
|
@@ -155,9 +156,6 @@ export function setUpKitNow(store, kit, repo, by, now, dir, grantByName = false)
|
|
|
155
156
|
const flow = store.getFlow(flowId);
|
|
156
157
|
for (const button of kit.buttons)
|
|
157
158
|
addFlowTriggerTo(store, flow, { kind: "button", label: button.label, questions: button.questions, zone: definition.start }, by, now, dir);
|
|
158
|
-
if (grantByName)
|
|
159
|
-
for (const tool of projectToolsOf(store, repo).filter(one => kit.tools.some(each => each.tool === one.name)))
|
|
160
|
-
grantListed(store, mate, tool, null, by, now);
|
|
161
159
|
return { ok: true, said: `${kit.name} is ready: ${nameOf(mate)} works its ${kit.flowName} flow.`, flow: flowId, subagent: mate.id };
|
|
162
160
|
}
|
|
163
161
|
/** What's set up and what's left, read fresh each time. */
|
|
@@ -205,11 +203,11 @@ export function addKitSample(store, kit, repo, by, now) {
|
|
|
205
203
|
return added.ok ? { ok: true, href: flowCardHref(set.flow.id, added.card) } : { ok: false, said: added.message };
|
|
206
204
|
}
|
|
207
205
|
/** Bring new issues from the project's GitHub repository into the kit's flow (issues from anyone, polled). */
|
|
208
|
-
export function addKitGithubTrigger(store, kit, repo, by, now, dir) {
|
|
206
|
+
export function addKitGithubTrigger(store, kit, repo, by, now, dir, proved = null) {
|
|
209
207
|
const set = kitInstalled(store, kit, repo);
|
|
210
208
|
const github = githubRepoOf(repo);
|
|
211
209
|
if (set === null || github === null)
|
|
212
210
|
return { ok: false, said: "This project isn't on GitHub." };
|
|
213
|
-
const made = addFlowTriggerTo(store, set.flow, { kind: "github", repo: github, watch: "issues", from: "anyone", delivery: "poll", zone: flowDefinitionOf(set.flow)?.start }, by, now, dir);
|
|
211
|
+
const made = addFlowTriggerTo(store, set.flow, { kind: "github", repo: github, watch: "issues", from: "anyone", delivery: "poll", zone: flowDefinitionOf(set.flow)?.start }, by, now, dir, proved === null ? {} : { widening: proved });
|
|
214
212
|
return made.ok ? { ok: true, said: `New issues on ${github} now come into ${kit.flowName}.` } : { ok: false, said: made.message };
|
|
215
213
|
}
|
package/dist/mcp-connect.d.ts
CHANGED
|
@@ -117,6 +117,14 @@ export type ConnectVisit = {
|
|
|
117
117
|
scope?: string | null;
|
|
118
118
|
/** Where the service revokes a token, when its metadata said: a run's own token is revoked there when the run ends. */
|
|
119
119
|
revoke?: string | null;
|
|
120
|
+
/** The signer's account generation when the password was typed at Connect: a rotation or revocation since ends the visit. */
|
|
121
|
+
generation?: number;
|
|
122
|
+
/** From a kit: the one grant the password at Connect proved (its subagent, this tool, this project); nothing else. */
|
|
123
|
+
grant?: {
|
|
124
|
+
subagent: number;
|
|
125
|
+
tool: string;
|
|
126
|
+
repo: string;
|
|
127
|
+
} | null;
|
|
120
128
|
};
|
|
121
129
|
/** A server's sign-in: the resource metadata its 401 names (or the well-known one), then its authorization server's metadata. */
|
|
122
130
|
export declare function discoverSignIn(mcpUrl: string, fetcher?: Fetch): Promise<Server | null>;
|
|
@@ -154,6 +162,8 @@ export declare function finishConnect(store: Store, visit: ConnectVisit, code: s
|
|
|
154
162
|
home?: string | undefined;
|
|
155
163
|
test?: boolean;
|
|
156
164
|
omitEnv?: readonly string[];
|
|
165
|
+
/** The signer's standing, checked again after the token answer and before anything changes. */
|
|
166
|
+
stillStanding?: () => boolean;
|
|
157
167
|
}): Promise<{
|
|
158
168
|
ok: true;
|
|
159
169
|
said: string;
|
package/dist/mcp-connect.js
CHANGED
|
@@ -351,6 +351,8 @@ export async function finishConnect(store, visit, code, now, options = {}) {
|
|
|
351
351
|
const tokens = tokensFrom(body, now.getTime());
|
|
352
352
|
if (tokens.access === null)
|
|
353
353
|
return { ok: false, said: `${service.label} didn't send a sign-in token.` };
|
|
354
|
+
if (options.stillStanding !== undefined && !options.stillStanding())
|
|
355
|
+
return { ok: false, said: "Your access changed during the sign-in, so nothing was connected. Connect again." };
|
|
354
356
|
if (had === undefined) {
|
|
355
357
|
const added = addToolTo(store, visit.repo, spec, `${service.label}, connected by signing in`, visit.by, now, { home });
|
|
356
358
|
if (!added.ok)
|
package/dist/project-tools.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { toolRefusal } from "./policy.js";
|
|
23
23
|
import { createHash } from "node:crypto";
|
|
24
24
|
import { spawn } from "node:child_process";
|
|
25
|
+
import { isProtectedEnvName } from "./protected-storage.js";
|
|
25
26
|
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
26
27
|
import { homedir, tmpdir } from "node:os";
|
|
27
28
|
import { realpathSync } from "node:fs";
|
|
@@ -572,7 +573,8 @@ function exchangeStdio(spec, values, timeoutMs, base, omit, request, fence) {
|
|
|
572
573
|
let child;
|
|
573
574
|
try {
|
|
574
575
|
const launch = confined(spec.command, spec.args, { ...(fence ?? {}), temp: env["TMPDIR"] });
|
|
575
|
-
|
|
576
|
+
// A fenced server gets no chat or vault variable (protected-storage.ts) but the tool's own secrets, set by name.
|
|
577
|
+
child = spawn(launch.file, launch.args, { env: fence === null ? { ...env, ...launch.env } : { ...Object.fromEntries(Object.entries(env).filter(([name]) => !isProtectedEnvName(name) || Object.hasOwn(values, name))), ...launch.env }, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
576
578
|
}
|
|
577
579
|
catch (error) {
|
|
578
580
|
if (error instanceof ContainmentRefusal) {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** One app's storage, relative to the home folder, per platform. */
|
|
2
|
+
export type ProtectedApp = {
|
|
3
|
+
app: string;
|
|
4
|
+
darwin: readonly string[];
|
|
5
|
+
linux: readonly string[];
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Every listed root. Checked against a real Mac (2026-10-25): existence only, never a file's contents. The 1Password
|
|
9
|
+
* containers and the extra Chrome, Edge and other browsers' folders below were found there and added.
|
|
10
|
+
*/
|
|
11
|
+
export declare const PROTECTED_STORAGE: readonly ProtectedApp[];
|
|
12
|
+
/** The chat and vault variables no launch gets. */
|
|
13
|
+
export declare const PROTECTED_ENV_PREFIXES: readonly string[];
|
|
14
|
+
export declare function isProtectedEnvName(name: string): boolean;
|
|
15
|
+
/** `env` without a chat or vault variable. */
|
|
16
|
+
export declare function withoutProtectedEnv<T extends Record<string, string | undefined>>(env: T): T;
|
|
17
|
+
/** How far the link walk goes in each present root. */
|
|
18
|
+
export declare const PROTECTED_WALK: {
|
|
19
|
+
readonly depth: 4;
|
|
20
|
+
readonly entries: 5000;
|
|
21
|
+
};
|
|
22
|
+
/** The roots by name, under the (real) home folder and the overrides the plane's environment names. */
|
|
23
|
+
export declare function protectedStorageNames(home?: string, env?: Readonly<Record<string, string | undefined>>, platform?: NodeJS.Platform): string[];
|
|
24
|
+
/**
|
|
25
|
+
* Every protected path one launch denies: each root by name and by real path, every link inside a present root that
|
|
26
|
+
* leads outward (where it leads, by real path and as spelled), and the overrides. Resolved at each launch, never kept.
|
|
27
|
+
* A link macOS makes in a sandboxed app's container to the same place under the home folder (its Desktop, Downloads,
|
|
28
|
+
* Fonts…) is the system's own mirror of the person's folders, not the app's data, and isn't followed (systemMirror).
|
|
29
|
+
*
|
|
30
|
+
* On a Mac, listing inside another app's container waits for the person to answer macOS's "access data from other apps"
|
|
31
|
+
* prompt when the process asking has no answer yet (a plane run by launchd: every launch, and the console with it, hung
|
|
32
|
+
* there in release check 3249). So the containers are walked only after a time-limited look in a child process
|
|
33
|
+
* (`containers`, containersListable) shows they can be listed without waiting. A look that fails any other way refuses
|
|
34
|
+
* the launch (it throws). When macOS is waiting on the person, the containers stay denied by name and real path, and
|
|
35
|
+
* the outward links the last complete walk found (kept in the plane's own state folder, linkCacheFile) stay denied too;
|
|
36
|
+
* with none kept, only the roots are denied, and that is said once.
|
|
37
|
+
*/
|
|
38
|
+
export declare function protectedStorageDenies(home?: string, env?: Readonly<Record<string, string | undefined>>, platform?: NodeJS.Platform, say?: (line: string) => void, containers?: (roots: readonly string[]) => boolean, list?: (path: string) => import("node:fs").Dirent[]): string[];
|
|
39
|
+
/** How long the look at other apps' containers may take before Toolroll takes it that macOS is waiting on the person. */
|
|
40
|
+
export declare const CONTAINER_LOOK_MS = 3000;
|
|
41
|
+
/** What a look at the containers found: listable without waiting, macOS waiting on the person (the look timed out), or
|
|
42
|
+
* the look itself failing (it couldn't start, crashed or was killed by anything but its own limit). */
|
|
43
|
+
export type ContainerLook = "listable" | "waiting" | "failed";
|
|
44
|
+
/**
|
|
45
|
+
* Whether this process can list inside these app containers without macOS stopping to ask the person: the walk run
|
|
46
|
+
* first in a child process that is killed at CONTAINER_LOOK_MS. Finishing in time (listed, or refused outright) means
|
|
47
|
+
* the walk can run here too; only running into that limit means macOS is waiting on the person (only the child waited).
|
|
48
|
+
* A look that fails any other way is tried once more, then throws: the launch is refused rather than fenced with less.
|
|
49
|
+
* A yes is kept (macOS keeps the person's answer); a no is looked at again after CONTAINER_RELOOK_MS.
|
|
50
|
+
*/
|
|
51
|
+
export declare function containersListable(roots: readonly string[], look?: (roots: readonly string[]) => ContainerLook, now?: () => number): boolean;
|
|
52
|
+
/** A look's outcome from its child's result: only its own limit (ETIMEDOUT) is macOS waiting; any other error, signal
|
|
53
|
+
* or exit code is a failed look. */
|
|
54
|
+
export declare function lookOutcome(result: {
|
|
55
|
+
error?: Error;
|
|
56
|
+
signal: NodeJS.Signals | null;
|
|
57
|
+
status: number | null;
|
|
58
|
+
}): ContainerLook;
|
|
59
|
+
/** Where the outward links the last complete container walk found are kept: the plane's own state folder (fenced from
|
|
60
|
+
* every build), one file per user. */
|
|
61
|
+
export declare function linkCacheFile(home: string, env?: Readonly<Record<string, string | undefined>>): string;
|
|
62
|
+
/** The kept links for this home: only a plain file of this user's (no link), at most LINK_CACHE_MAX, of exactly the
|
|
63
|
+
* kept shape; anything else is no cache at all. */
|
|
64
|
+
export declare function readLinkCache(file: string, home: string): string[] | null;
|
|
65
|
+
/** Keep a complete walk's links: written aside (owner-only) and renamed into place, only into a state folder that is
|
|
66
|
+
* already there. A failed write keeps whatever was kept before. */
|
|
67
|
+
export declare function writeLinkCache(file: string, home: string, targets: readonly string[]): void;
|