toolroll 0.9.20 → 0.9.21
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 +14 -0
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +22 -21
- package/dist/browser-workspace.d.ts +41 -1
- package/dist/chat-actions.d.ts +5 -0
- package/dist/chat-actions.js +57 -6
- package/dist/chat-delivery-state.d.ts +15 -0
- package/dist/chat-delivery-state.js +27 -2
- package/dist/chat-delivery.js +24 -4
- package/dist/chat-flow.d.ts +26 -3
- package/dist/chat-flow.js +217 -5
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-chat.js +2 -2
- package/dist/evidence.d.ts +13 -1
- package/dist/evidence.js +10 -1
- package/dist/flow-engine.d.ts +7 -0
- package/dist/flow-engine.js +71 -27
- package/dist/flow-gallery-ui.d.ts +1 -1
- package/dist/flow-gallery-ui.js +8 -7
- package/dist/flow-gallery.d.ts +8 -0
- package/dist/flow-gallery.js +37 -3
- package/dist/flow-send.d.ts +80 -0
- package/dist/flow-send.js +249 -0
- package/dist/flow-share.js +7 -3
- package/dist/flow-steps.js +1 -1
- package/dist/flows-cli.js +11 -3
- package/dist/flows-ui.js +20 -4
- package/dist/flows.d.ts +27 -1
- package/dist/flows.js +104 -18
- package/dist/mate-tools.js +33 -10
- package/dist/operate.js +41 -0
- package/dist/project-delete.js +4 -0
- package/dist/project-demo.d.ts +12 -0
- package/dist/project-demo.js +71 -0
- package/dist/provider.d.ts +6 -0
- package/dist/provider.js +4 -1
- package/dist/report-summary.d.ts +9 -0
- package/dist/report-summary.js +9 -0
- package/dist/result-shots.d.ts +2 -0
- package/dist/result-shots.js +24 -5
- package/dist/scout-net.d.ts +22 -0
- package/dist/scout-net.js +214 -0
- package/dist/scout-report.d.ts +67 -0
- package/dist/scout-report.js +108 -1
- package/dist/scout.d.ts +42 -0
- package/dist/scout.js +225 -16
- package/dist/serve.js +43 -5
- package/dist/slack-chat.js +3 -3
- package/dist/store.d.ts +53 -0
- package/dist/store.js +95 -4
- package/dist/teams-chat.js +1 -1
- package/dist/telegram-flow.d.ts +16 -2
- package/dist/telegram-flow.js +58 -0
- package/dist/telegram.js +46 -10
- package/package.json +1 -1
package/dist/operate.js
CHANGED
|
@@ -180,6 +180,7 @@ import { isDirectChatProvider, isSubscriptionChatProvider, priceOf, PRICED_MODEL
|
|
|
180
180
|
import { resolvePhaseAgent, resolveScopeProfile, resolveScopeChain, resolveRouteCandidates, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.js";
|
|
181
181
|
import { isRiskLevel, legOf, projectRoute, riskConsequence, routeDigestOf, routeWords, RISK_LEVELS, PHASES as ROUTE_PHASES } from "./phase-routing.js";
|
|
182
182
|
import { observeProviderReadiness, reportProviderReadinessAuthed } from "./runner.js";
|
|
183
|
+
import { parseDemoUrl, projectDemoUrl, saveProjectDemo } from "./project-demo.js";
|
|
183
184
|
import { effectiveConcurrency, maySlotTake, parseProjectConcurrency, PROJECT_CONCURRENCY_DEFAULT, ProjectPasses, projectConcurrency, saveProjectConcurrency, savedProjectConcurrency } from "./project-concurrency.js";
|
|
184
185
|
import { clearWebhook, effectivePrimary, isMessagingChannel, loadConsoleUrl, loadPrimary, loadWebhookTargets, phoneOrigin, saveConsoleUrl, savePrimary, saveWebhook, webhookPass, SLACK_ENV, DISCORD_ENV } from "./webhooks.js";
|
|
185
186
|
import { auditOf, inspectionOf, isProviderId, MONEY_CAPABILITIES, PROVIDER_IDS, validModelId, validateSpec, ALL_CREDENTIAL_ENV } from "./provider.js";
|
|
@@ -233,6 +234,9 @@ export const OPERATE_HELP = `toolroll — operating the queue
|
|
|
233
234
|
toolroll project concurrency [<n>] --repo <p>
|
|
234
235
|
how many of a project's tasks build at once (default 2, never past the
|
|
235
236
|
worker's capacity); an approver changes it with <n>
|
|
237
|
+
toolroll project demo [<url>|off] --repo <p>
|
|
238
|
+
the project's own demo or dev server, the one non-public address a
|
|
239
|
+
report's scout may screenshot
|
|
236
240
|
toolroll task evidence <id> the task's evidence pack as JSON (--html for a printable page; --out <file>)
|
|
237
241
|
toolroll storage where the disk goes: database, build checkouts, releases, evidence, leftover test temp folders
|
|
238
242
|
toolroll storage clean [--yes] preview removing finished tasks' clean checkouts (their branches stay) and test temp folders older than a day; --yes removes them
|
|
@@ -971,6 +975,8 @@ async function dispatch(command, positional, flags, context) {
|
|
|
971
975
|
return projectDeleteCommand(positional, flags, context);
|
|
972
976
|
if (positional[0] === "concurrency")
|
|
973
977
|
return projectConcurrencyCommand(positional, flags, context);
|
|
978
|
+
if (positional[0] === "demo")
|
|
979
|
+
return projectDemoCommand(positional, flags, context);
|
|
974
980
|
return runProjectCommand(positional, flags, context);
|
|
975
981
|
case "assignment":
|
|
976
982
|
// The person's own lead (its lead token) claims as the lead; coordinator credentials keep their own path.
|
|
@@ -3465,6 +3471,7 @@ async function tickCommand(flags, context) {
|
|
|
3465
3471
|
onProviderSpawn: pid => { worktrees.recordProviderOccupancy(scoutLeased.worktree.path, runner, pid, scoutLeased.worktree.leaseEpoch); },
|
|
3466
3472
|
evidenceRoot: context.evidenceRoot,
|
|
3467
3473
|
answers: scoutAnswers,
|
|
3474
|
+
demoUrl: projectDemoUrl(context.databaseFile, repo),
|
|
3468
3475
|
provider: spec.provider,
|
|
3469
3476
|
model: proof.effective.model,
|
|
3470
3477
|
...(proof.effective.maxTurns === undefined ? {} : { maxTurns: proof.effective.maxTurns }),
|
|
@@ -12024,6 +12031,40 @@ async function projectConcurrencyCommand(positional, flags, context) {
|
|
|
12024
12031
|
...(changed.before > changed.after ? ["Builds already running carry on; the new number applies to the next one."] : []),
|
|
12025
12032
|
]);
|
|
12026
12033
|
}
|
|
12034
|
+
/** `project demo [<url>|off] --repo <p>` (review 827): the project's own demo or dev server, which its scouts may
|
|
12035
|
+
* screenshot. Changing it is an approver's act. */
|
|
12036
|
+
async function projectDemoCommand(positional, flags, context) {
|
|
12037
|
+
const command = "project demo";
|
|
12038
|
+
const allowed = new Set(["repo", "as", "token", "token-file", "token-env", "db", "json"]);
|
|
12039
|
+
for (const name of flags.keys())
|
|
12040
|
+
if (!allowed.has(name))
|
|
12041
|
+
return fail(context.write, context.json, command, "usage", `--${name} is not a project demo option.`, EXIT.usage);
|
|
12042
|
+
const repoFlag = text(flags, "repo");
|
|
12043
|
+
if (positional.length > 2 || repoFlag === undefined)
|
|
12044
|
+
return fail(context.write, context.json, command, "usage", "Use project demo [<url>|off] --repo <project path>.", EXIT.usage);
|
|
12045
|
+
const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
|
|
12046
|
+
const known = [...new Set([...context.store.knownRepos(), ...("error" in registered ? [] : registered.repos)])];
|
|
12047
|
+
const repo = known.find(one => one === repoFlag || one === resolve(repoFlag) || one === canonicalProject(repoFlag));
|
|
12048
|
+
if (repo === undefined)
|
|
12049
|
+
return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
|
|
12050
|
+
const words = (url) => url === null ? `${repo}: no demo; scouts screenshot public pages only.` : `${repo}: scouts may also screenshot ${url}.`;
|
|
12051
|
+
const given = positional[1];
|
|
12052
|
+
if (given === undefined) {
|
|
12053
|
+
const current = projectDemoUrl(context.databaseFile, repo);
|
|
12054
|
+
return succeed(context.write, context.json, command, { repo, demoUrl: current }, () => [words(current)]);
|
|
12055
|
+
}
|
|
12056
|
+
const url = given.trim().toLowerCase() === "off" ? null : parseDemoUrl(given);
|
|
12057
|
+
if (url === null && given.trim().toLowerCase() !== "off")
|
|
12058
|
+
return fail(context.write, context.json, command, "usage", "The demo is an http or https address without a sign-in, or off.", EXIT.usage);
|
|
12059
|
+
const acting = await askCredentials(flags, context);
|
|
12060
|
+
const verified = acting === null ? null : authenticateApprover(context.store, acting.name, acting.token);
|
|
12061
|
+
if (acting === null || verified === null || !verified.ok || !context.store.accountCanAccess(acting.name, repo)) {
|
|
12062
|
+
return fail(context.write, context.json, command, "refused", "An approver for this project sets its demo: pass --as and --token (or use the remembered login).", EXIT.refused);
|
|
12063
|
+
}
|
|
12064
|
+
const changed = saveProjectDemo(context.databaseFile, repo, url);
|
|
12065
|
+
context.store.recordProjectDemo(acting.name, repo, changed.before, changed.after, context.clock());
|
|
12066
|
+
return succeed(context.write, context.json, command, { repo, before: changed.before, demoUrl: changed.after }, () => [words(changed.after)]);
|
|
12067
|
+
}
|
|
12027
12068
|
/** Write a pack or export where asked (never over an existing file), or to the terminal. */
|
|
12028
12069
|
function writeOut(context, command, out, content, summary, line, data) {
|
|
12029
12070
|
if (out === undefined) {
|
package/dist/project-delete.js
CHANGED
|
@@ -216,6 +216,8 @@ function deleteRows(store, repo, d, now) {
|
|
|
216
216
|
del(`${surface}_action`, IN("proposal"), proposals);
|
|
217
217
|
del(`${surface}_flow_action`, IN("card"), C);
|
|
218
218
|
del(`${surface}_flow_prompt`, IN("card"), C);
|
|
219
|
+
del(`${surface}_flow_choice`, IN("card"), C);
|
|
220
|
+
del(`${surface}_flow_note`, IN("card"), C);
|
|
219
221
|
del(`${surface}_question_action`, IN("question"), questions);
|
|
220
222
|
del(`${surface}_question_prompt`, IN("question"), questions);
|
|
221
223
|
del(`${surface}_ask_action`, IN("turn"), U);
|
|
@@ -226,6 +228,8 @@ function deleteRows(store, repo, d, now) {
|
|
|
226
228
|
del("telegram_proposal_action", IN("proposal"), proposals);
|
|
227
229
|
del("telegram_flow_action", IN("card"), C);
|
|
228
230
|
del("telegram_flow_prompt", IN("card"), C);
|
|
231
|
+
del("telegram_flow_choice", IN("card"), C);
|
|
232
|
+
del("flow_send", IN("card"), C);
|
|
229
233
|
del("telegram_question_action", IN("question"), questions);
|
|
230
234
|
del("telegram_question_prompt", IN("question"), questions);
|
|
231
235
|
del("chat_decide_action", IN("task_id"), K);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const projectDemoPath: (databaseFile: string) => string;
|
|
2
|
+
/** A demo URL as typed: an http(s) address with no sign-in details, at most 500 characters, or null. */
|
|
3
|
+
export declare function parseDemoUrl(value: string): string | null;
|
|
4
|
+
/** Every saved demo URL, by canonical project path. An unreadable file reads as none saved. */
|
|
5
|
+
export declare function savedProjectDemos(databaseFile: string): Map<string, string>;
|
|
6
|
+
/** The project's demo URL, or null when it has none. */
|
|
7
|
+
export declare function projectDemoUrl(databaseFile: string, repo: string): string | null;
|
|
8
|
+
/** Save (or, with null, clear) one project's demo URL atomically; returns it before and after. */
|
|
9
|
+
export declare function saveProjectDemo(databaseFile: string, repo: string, url: string | null): {
|
|
10
|
+
before: string | null;
|
|
11
|
+
after: string | null;
|
|
12
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A project's own demo or dev server (review 827): the one address a scout
|
|
3
|
+
* may reach that is not on the public internet, so it can take screenshots
|
|
4
|
+
* of the project's UI. An approver sets it with
|
|
5
|
+
* `toolroll project demo --repo <p> <url>|off`; it is kept in a small file
|
|
6
|
+
* beside the database, like a project's builds-at-once number, and recorded
|
|
7
|
+
* in the ledger as before → after. A project without one has none.
|
|
8
|
+
*/
|
|
9
|
+
import { lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { randomBytes } from "node:crypto";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
export const projectDemoPath = (databaseFile) => join(dirname(databaseFile), "project-demo.json");
|
|
13
|
+
/** A demo URL as typed: an http(s) address with no sign-in details, at most 500 characters, or null. */
|
|
14
|
+
export function parseDemoUrl(value) {
|
|
15
|
+
const typed = value.trim();
|
|
16
|
+
if (typed === "" || typed.length > 500)
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
const url = new URL(typed);
|
|
20
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
21
|
+
return null;
|
|
22
|
+
if (url.username !== "" || url.password !== "")
|
|
23
|
+
return null;
|
|
24
|
+
return url.href;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Every saved demo URL, by canonical project path. An unreadable file reads as none saved. */
|
|
31
|
+
export function savedProjectDemos(databaseFile) {
|
|
32
|
+
const saved = new Map();
|
|
33
|
+
const file = projectDemoPath(databaseFile);
|
|
34
|
+
try {
|
|
35
|
+
const stat = lstatSync(file, { throwIfNoEntry: false });
|
|
36
|
+
if (stat === undefined || !stat.isFile() || stat.size > 262_144)
|
|
37
|
+
return saved;
|
|
38
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
39
|
+
const projects = value !== null && typeof value === "object" ? value.projects : undefined;
|
|
40
|
+
if (projects === null || typeof projects !== "object")
|
|
41
|
+
return saved;
|
|
42
|
+
for (const [repo, url] of Object.entries(projects)) {
|
|
43
|
+
const parsed = typeof url === "string" ? parseDemoUrl(url) : null;
|
|
44
|
+
if (parsed !== null)
|
|
45
|
+
saved.set(repo, parsed);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// A damaged file never stops a scout: it simply has no demo to open.
|
|
50
|
+
}
|
|
51
|
+
return saved;
|
|
52
|
+
}
|
|
53
|
+
/** The project's demo URL, or null when it has none. */
|
|
54
|
+
export function projectDemoUrl(databaseFile, repo) {
|
|
55
|
+
return savedProjectDemos(databaseFile).get(repo) ?? null;
|
|
56
|
+
}
|
|
57
|
+
/** Save (or, with null, clear) one project's demo URL atomically; returns it before and after. */
|
|
58
|
+
export function saveProjectDemo(databaseFile, repo, url) {
|
|
59
|
+
const saved = savedProjectDemos(databaseFile);
|
|
60
|
+
const before = saved.get(repo) ?? null;
|
|
61
|
+
if (url === null)
|
|
62
|
+
saved.delete(repo);
|
|
63
|
+
else
|
|
64
|
+
saved.set(repo, url);
|
|
65
|
+
const file = projectDemoPath(databaseFile);
|
|
66
|
+
const temp = `${file}.${randomBytes(6).toString("hex")}.tmp`;
|
|
67
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
68
|
+
writeFileSync(temp, `${JSON.stringify({ version: 1, projects: Object.fromEntries([...saved].sort(([a], [b]) => a.localeCompare(b))) }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
69
|
+
renameSync(temp, file);
|
|
70
|
+
return { before, after: url };
|
|
71
|
+
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -72,6 +72,12 @@ export type Invocation = {
|
|
|
72
72
|
* report rides the terminal result event instead of a file plan mode
|
|
73
73
|
* would refuse to write. Ignored by providers without `--json-schema`. */
|
|
74
74
|
jsonSchema?: Readonly<Record<string, unknown>>;
|
|
75
|
+
/** Claude tools allowed without asking, for a `dontAsk` launch: the scout's
|
|
76
|
+
* research and screenshot tools, and nothing that edits or runs commands. */
|
|
77
|
+
allowedTools?: readonly string[];
|
|
78
|
+
/** More MCP servers for this launch only, beside the project's own (the
|
|
79
|
+
* scout's headless browser). Claude-only; ignored for a review. */
|
|
80
|
+
extraMcpServers?: Readonly<Record<string, unknown>>;
|
|
75
81
|
};
|
|
76
82
|
export type ProviderRunner = (file: string, args: readonly string[], options?: RunOptions) => Promise<ExecResult>;
|
|
77
83
|
/** The harness's own account of how a turn ended. */
|
package/dist/provider.js
CHANGED
|
@@ -229,7 +229,10 @@ const claudeArgv = (invocation) => [
|
|
|
229
229
|
// Headless (run 2085): a wakeup, cron job or monitor needs a later
|
|
230
230
|
// turn that a -p process never gets. Denied at the harness too.
|
|
231
231
|
"--disallowedTools", HEADLESS_DISALLOWED_TOOLS.join(","),
|
|
232
|
-
...(invocation.
|
|
232
|
+
...(invocation.allowedTools === undefined || invocation.allowedTools.length === 0 ? [] : ["--allowedTools", invocation.allowedTools.join(",")]),
|
|
233
|
+
...(invocation.toolArgv ?? []),
|
|
234
|
+
...(invocation.extraMcpServers === undefined ? [] : ["--mcp-config", JSON.stringify({ mcpServers: invocation.extraMcpServers })]),
|
|
235
|
+
...(invocation.fence !== undefined && invocation.fence.length > 0 ? ["--settings", claudeFenceSettings(invocation.fence)] : []),
|
|
233
236
|
...(invocation.jsonSchema === undefined ? [] : ["--json-schema", JSON.stringify(invocation.jsonSchema)])
|
|
234
237
|
]),
|
|
235
238
|
];
|
package/dist/report-summary.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Store } from "./store.js";
|
|
2
|
+
import type { ParsedReport } from "./scout-report.js";
|
|
2
3
|
/** The report a scout delivered, for get_task: title, summary, follow-ups —
|
|
3
4
|
* verified before it is read; the document itself stays on the task page. */
|
|
4
5
|
export declare function reportSummaryFor(store: Store, evidenceRoot: string | undefined, taskRef: number): {
|
|
@@ -11,3 +12,11 @@ export declare function reportSummaryFor(store: Store, evidenceRoot: string | un
|
|
|
11
12
|
} | {
|
|
12
13
|
problem: string;
|
|
13
14
|
} | null;
|
|
15
|
+
/** The longest full report a later flow step is given through {{stage.<id>.report}}. */
|
|
16
|
+
export declare const REPORT_FILL_LIMIT = 20000;
|
|
17
|
+
/** What a report zone gives the zones after it besides its summary: its items as a numbered list (title, why, URL)
|
|
18
|
+
* and the report's markdown, capped. Empty strings when it has neither. */
|
|
19
|
+
export declare function reportFillIns(report: Pick<ParsedReport, "items" | "report">): {
|
|
20
|
+
items: string;
|
|
21
|
+
report: string;
|
|
22
|
+
};
|
package/dist/report-summary.js
CHANGED
|
@@ -13,3 +13,12 @@ export function reportSummaryFor(store, evidenceRoot, taskRef) {
|
|
|
13
13
|
return { problem: view.problem };
|
|
14
14
|
return { title: view.report.title, summary: view.report.summary, followUps: view.report.followUps };
|
|
15
15
|
}
|
|
16
|
+
/** The longest full report a later flow step is given through {{stage.<id>.report}}. */
|
|
17
|
+
export const REPORT_FILL_LIMIT = 20_000;
|
|
18
|
+
/** What a report zone gives the zones after it besides its summary: its items as a numbered list (title, why, URL)
|
|
19
|
+
* and the report's markdown, capped. Empty strings when it has neither. */
|
|
20
|
+
export function reportFillIns(report) {
|
|
21
|
+
const items = report.items.map((one, index) => `${index + 1}. ${one.title}\n ${one.why.replace(/\s*\n\s*/g, " ")}\n ${one.url}`).join("\n");
|
|
22
|
+
const document = report.report.length <= REPORT_FILL_LIMIT ? report.report : `${report.report.slice(0, REPORT_FILL_LIMIT)}\n… (the rest of the report is on its task)`;
|
|
23
|
+
return { items, report: document };
|
|
24
|
+
}
|
package/dist/result-shots.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export type ResultShotsPlan =
|
|
|
39
39
|
* no wait in between, or asks again.
|
|
40
40
|
*/
|
|
41
41
|
export declare function resultShotsFor(store: Store, evidenceRoot: string | undefined, repos: readonly string[], row: Pick<Notification, "kind" | "recipient" | "taskId" | "taskRef" | "run">, sent?: ReadonlySet<number>): ResultShotsPlan;
|
|
42
|
+
/** A row that carries a result's screenshots: one a person asked for with results, or one a flow sends them. */
|
|
43
|
+
export declare function isShotsKind(kind: string): boolean;
|
|
42
44
|
/** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
|
|
43
45
|
export declare function shotWaitsUntil(resultNextAt: unknown, now: Date): string;
|
|
44
46
|
/** Retention removed this result's files: its screenshots go quietly, never as a complaint. */
|
package/dist/result-shots.js
CHANGED
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { existsSync } from "node:fs";
|
|
17
17
|
import { join } from "node:path";
|
|
18
|
-
import { imageDimensions, readVerifiedProofForRun, RETENTION_NOTE } from "./evidence.js";
|
|
18
|
+
import { imageDimensions, readVerifiedProofForRun, readVerifiedReport, RETENTION_NOTE } from "./evidence.js";
|
|
19
19
|
import { resultImageFileName, verifyResultImage } from "./chat-evidence.js";
|
|
20
20
|
import { publicChatText } from "./chat-display.js";
|
|
21
21
|
import { chatTitle } from "./chat-voice.js";
|
|
22
|
-
import { RESULT_SHOTS_KIND } from "./store.js";
|
|
22
|
+
import { FLOW_SHOTS_KIND, RESULT_SHOTS_KIND } from "./store.js";
|
|
23
23
|
/** "Up to 4": the most screenshots one result ever sends. */
|
|
24
24
|
export const RESULT_SHOTS_MAX = 4;
|
|
25
25
|
/** The choices in the order Settings and the CLI show them. */
|
|
@@ -45,6 +45,19 @@ function ownCaptions(store, root, run) {
|
|
|
45
45
|
}
|
|
46
46
|
return captions;
|
|
47
47
|
}
|
|
48
|
+
/** A scout's screenshots keep the captions its report gave them, by evidence id. */
|
|
49
|
+
function reportCaptions(store, root, taskRef, run) {
|
|
50
|
+
const captions = new Map();
|
|
51
|
+
const view = readVerifiedReport(store, root, taskRef);
|
|
52
|
+
if (view === null || !view.ok || view.run !== run)
|
|
53
|
+
return captions;
|
|
54
|
+
for (const image of view.report.images) {
|
|
55
|
+
const words = publicChatText(image.caption, 200).trim();
|
|
56
|
+
if (image.artifact !== undefined && words !== "" && !words.includes("[sensitive text hidden]"))
|
|
57
|
+
captions.set(image.artifact, words);
|
|
58
|
+
}
|
|
59
|
+
return captions;
|
|
60
|
+
}
|
|
48
61
|
function isPhoto(bytes, format) {
|
|
49
62
|
const size = imageDimensions(bytes, format);
|
|
50
63
|
if (size === null || size.width === 0 || size.height === 0)
|
|
@@ -59,9 +72,10 @@ function isPhoto(bytes, format) {
|
|
|
59
72
|
* no wait in between, or asks again.
|
|
60
73
|
*/
|
|
61
74
|
export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()) {
|
|
62
|
-
if (row.kind
|
|
75
|
+
if (!isShotsKind(row.kind) || row.recipient === null || row.taskId === null || row.taskRef === null || row.run === null)
|
|
63
76
|
return { kind: "none", why: "none" };
|
|
64
|
-
|
|
77
|
+
// A flow's "Send to me" or "Person chooses" asked for them: up to four, whatever the person chose for results.
|
|
78
|
+
const limit = row.kind === FLOW_SHOTS_KIND ? RESULT_SHOTS_MAX : resultShotLimit(store.notificationPreference(row.recipient).screenshots);
|
|
65
79
|
if (limit === 0)
|
|
66
80
|
return { kind: "none", why: "off" };
|
|
67
81
|
const title = chatTitle(store, row.taskId);
|
|
@@ -82,6 +96,7 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
|
|
|
82
96
|
if (chosen.length === 0)
|
|
83
97
|
return { kind: "none", why: "none" };
|
|
84
98
|
const captions = ownCaptions(store, evidenceRoot, row.run);
|
|
99
|
+
const reported = latest.role === "scout" ? reportCaptions(store, evidenceRoot, row.taskRef, row.run) : new Map();
|
|
85
100
|
const shots = [];
|
|
86
101
|
for (const [index, artifact] of chosen.entries()) {
|
|
87
102
|
const verified = verifyResultImage(store, evidenceRoot, repos, { taskId: row.taskId, run: row.run, artifact: artifact.id, sha256: artifact.sha256 });
|
|
@@ -91,7 +106,7 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
|
|
|
91
106
|
return { kind: "none", why: "pruned" };
|
|
92
107
|
return line(verified.problem);
|
|
93
108
|
}
|
|
94
|
-
const own = captions.get(SCREENSHOT_CAPTURE.exec(artifact.capture)?.[1] ?? "") ?? null;
|
|
109
|
+
const own = captions.get(SCREENSHOT_CAPTURE.exec(artifact.capture)?.[1] ?? "") ?? reported.get(artifact.id) ?? null;
|
|
95
110
|
shots.push({
|
|
96
111
|
artifact: artifact.id, sha256: artifact.sha256, bytes: verified.bytes, format: verified.format,
|
|
97
112
|
fileName: resultImageFileName(row.taskId, row.run, artifact.id, verified.format),
|
|
@@ -102,6 +117,10 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
|
|
|
102
117
|
const remaining = shots.filter(one => !sent.has(one.artifact));
|
|
103
118
|
return remaining.length === 0 ? { kind: "none", why: "sent" } : { kind: "send", taskId: row.taskId, run: row.run, shots: remaining };
|
|
104
119
|
}
|
|
120
|
+
/** A row that carries a result's screenshots: one a person asked for with results, or one a flow sends them. */
|
|
121
|
+
export function isShotsKind(kind) {
|
|
122
|
+
return kind === RESULT_SHOTS_KIND || kind === FLOW_SHOTS_KIND;
|
|
123
|
+
}
|
|
105
124
|
/** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
|
|
106
125
|
export function shotWaitsUntil(resultNextAt, now) {
|
|
107
126
|
const soonest = new Date(now.getTime() + 1_000).toISOString();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Socket } from "node:net";
|
|
2
|
+
/** Whether an IP literal is a public internet address. IPv4 inside IPv6 (mapped, compatible or NAT64) is judged as IPv4. */
|
|
3
|
+
export declare function isPublicAddress(address: string): boolean;
|
|
4
|
+
export type Lookup = (host: string) => Promise<readonly string[]>;
|
|
5
|
+
/**
|
|
6
|
+
* A connection to the first of the checked addresses that answers, tried in turn (review 827): a host whose IPv6
|
|
7
|
+
* address is unreachable from here is still reached over IPv4. Null when none answers.
|
|
8
|
+
*/
|
|
9
|
+
export declare function connectFirst(addresses: readonly string[], port: number, timeoutMs?: number): Promise<Socket | null>;
|
|
10
|
+
export type ScoutProxy = {
|
|
11
|
+
url: string;
|
|
12
|
+
close: () => Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Start the scout browser's proxy on loopback. `refused` hears every request it turned away (for the live log and
|
|
16
|
+
* tests). `lookup` resolves a host name to its addresses (the system resolver unless a test supplies one).
|
|
17
|
+
*/
|
|
18
|
+
export declare function startScoutProxy(options?: {
|
|
19
|
+
demoUrl?: string | null;
|
|
20
|
+
lookup?: Lookup;
|
|
21
|
+
refused?: (target: string, why: string) => void;
|
|
22
|
+
}): Promise<ScoutProxy>;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scout reaches only the public web (reviews 826, 827): every request
|
|
3
|
+
* its browser and its web fetch make goes through this local proxy, which resolves the host
|
|
4
|
+
* itself and refuses anything that is not a public internet address —
|
|
5
|
+
* loopback, private, link-local, carrier-grade NAT, multicast and reserved
|
|
6
|
+
* ranges, in IPv4 and IPv6 — then connects to the very addresses it
|
|
7
|
+
* checked, each in turn until one answers, so a name that re-resolves
|
|
8
|
+
* somewhere private gets nowhere. The one
|
|
9
|
+
* exception is the project's own demo URL, when one is set, matched by its
|
|
10
|
+
* exact scheme, host and port. Redirects and a page's own requests pass
|
|
11
|
+
* through the same check; file: never reaches a proxy, and the pinned
|
|
12
|
+
* Playwright refuses to open it.
|
|
13
|
+
*/
|
|
14
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
15
|
+
import { BlockList, connect, isIP } from "node:net";
|
|
16
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
17
|
+
/** Address ranges a scout's browser may never reach. */
|
|
18
|
+
const NOT_PUBLIC = (() => {
|
|
19
|
+
const list = new BlockList();
|
|
20
|
+
for (const [net, prefix] of [
|
|
21
|
+
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8], ["169.254.0.0", 16], ["172.16.0.0", 12],
|
|
22
|
+
["192.0.0.0", 24], ["192.0.2.0", 24], ["192.88.99.0", 24], ["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24],
|
|
23
|
+
["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
24
|
+
])
|
|
25
|
+
list.addSubnet(net, prefix, "ipv4");
|
|
26
|
+
for (const [net, prefix] of [
|
|
27
|
+
["::", 128], ["::1", 128], ["64:ff9b:1::", 48], ["100::", 64], ["2001::", 23], ["2001:db8::", 32], ["fc00::", 7], ["fe80::", 10], ["fec0::", 10], ["ff00::", 8],
|
|
28
|
+
])
|
|
29
|
+
list.addSubnet(net, prefix, "ipv6");
|
|
30
|
+
return list;
|
|
31
|
+
})();
|
|
32
|
+
/** Whether an IP literal is a public internet address. IPv4 inside IPv6 (mapped, compatible or NAT64) is judged as IPv4. */
|
|
33
|
+
export function isPublicAddress(address) {
|
|
34
|
+
const bare = address.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
|
|
35
|
+
const family = isIP(bare);
|
|
36
|
+
if (family === 4)
|
|
37
|
+
return !NOT_PUBLIC.check(bare, "ipv4");
|
|
38
|
+
if (family !== 6)
|
|
39
|
+
return false;
|
|
40
|
+
const embedded = /^(?:::ffff:(?:0:)?|::|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/i.exec(bare)?.[1];
|
|
41
|
+
if (embedded !== undefined)
|
|
42
|
+
return isPublicAddress(embedded);
|
|
43
|
+
const mappedHex = /^(?:::ffff:|64:ff9b::)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(bare);
|
|
44
|
+
if (mappedHex !== null) {
|
|
45
|
+
const high = parseInt(mappedHex[1], 16);
|
|
46
|
+
const low = parseInt(mappedHex[2], 16);
|
|
47
|
+
return isPublicAddress(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
48
|
+
}
|
|
49
|
+
if (/^::ffff:/i.test(bare))
|
|
50
|
+
return false;
|
|
51
|
+
return !NOT_PUBLIC.check(bare, "ipv6");
|
|
52
|
+
}
|
|
53
|
+
const systemLookup = async (host) => (await dnsLookup(host, { all: true, verbatim: true })).map(one => one.address);
|
|
54
|
+
/** The demo URL's own origin, or null when none is set or it is not an http(s) address. */
|
|
55
|
+
function demoTarget(demoUrl) {
|
|
56
|
+
if (demoUrl === undefined || demoUrl === null || demoUrl === "")
|
|
57
|
+
return null;
|
|
58
|
+
try {
|
|
59
|
+
const url = new URL(demoUrl);
|
|
60
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
61
|
+
return null;
|
|
62
|
+
const scheme = url.protocol === "http:" ? "http" : "https";
|
|
63
|
+
return { scheme, host: url.hostname.toLowerCase(), port: Number(url.port || (scheme === "http" ? 80 : 443)) };
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** How long one address may take to answer before the next is tried. */
|
|
70
|
+
const CONNECT_TIMEOUT_MS = 10_000;
|
|
71
|
+
/**
|
|
72
|
+
* A connection to the first of the checked addresses that answers, tried in turn (review 827): a host whose IPv6
|
|
73
|
+
* address is unreachable from here is still reached over IPv4. Null when none answers.
|
|
74
|
+
*/
|
|
75
|
+
export async function connectFirst(addresses, port, timeoutMs = CONNECT_TIMEOUT_MS) {
|
|
76
|
+
for (const address of addresses) {
|
|
77
|
+
const socket = await new Promise(done => {
|
|
78
|
+
const attempt = connect({ host: address, port });
|
|
79
|
+
const give = (value) => {
|
|
80
|
+
attempt.removeAllListeners("connect").removeAllListeners("error").removeAllListeners("timeout");
|
|
81
|
+
attempt.setTimeout(0);
|
|
82
|
+
if (value === null)
|
|
83
|
+
attempt.destroy();
|
|
84
|
+
done(value);
|
|
85
|
+
};
|
|
86
|
+
attempt.setTimeout(timeoutMs);
|
|
87
|
+
attempt.once("connect", () => give(attempt));
|
|
88
|
+
attempt.once("error", () => give(null));
|
|
89
|
+
attempt.once("timeout", () => give(null));
|
|
90
|
+
});
|
|
91
|
+
if (socket !== null)
|
|
92
|
+
return socket;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Start the scout browser's proxy on loopback. `refused` hears every request it turned away (for the live log and
|
|
98
|
+
* tests). `lookup` resolves a host name to its addresses (the system resolver unless a test supplies one).
|
|
99
|
+
*/
|
|
100
|
+
export async function startScoutProxy(options = {}) {
|
|
101
|
+
const lookup = options.lookup ?? systemLookup;
|
|
102
|
+
const demo = demoTarget(options.demoUrl);
|
|
103
|
+
const sockets = new Set();
|
|
104
|
+
/** The addresses this request may reach, in the resolver's order, or why it may not. */
|
|
105
|
+
const resolve = async (target) => {
|
|
106
|
+
const host = target.host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
107
|
+
const isDemo = demo !== null && demo.scheme === target.scheme && demo.host.replace(/^\[|\]$/g, "") === host && demo.port === target.port;
|
|
108
|
+
let addresses;
|
|
109
|
+
try {
|
|
110
|
+
addresses = isIP(host) !== 0 ? [host] : await lookup(host);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return { ok: false, why: "the host does not resolve" };
|
|
114
|
+
}
|
|
115
|
+
if (addresses.length === 0)
|
|
116
|
+
return { ok: false, why: "the host does not resolve" };
|
|
117
|
+
if (isDemo)
|
|
118
|
+
return { ok: true, addresses };
|
|
119
|
+
if (!addresses.every(isPublicAddress))
|
|
120
|
+
return { ok: false, why: "not a public internet address" };
|
|
121
|
+
return { ok: true, addresses };
|
|
122
|
+
};
|
|
123
|
+
const refuse = (target, why) => {
|
|
124
|
+
try {
|
|
125
|
+
options.refused?.(target, why);
|
|
126
|
+
}
|
|
127
|
+
catch { /* a listener's trouble is not the browser's */ }
|
|
128
|
+
};
|
|
129
|
+
const server = createServer((incoming, outgoing) => {
|
|
130
|
+
void (async () => {
|
|
131
|
+
let url;
|
|
132
|
+
try {
|
|
133
|
+
url = new URL(incoming.url ?? "");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
outgoing.writeHead(400).end();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (url.protocol !== "http:") {
|
|
140
|
+
refuse(url.protocol, "only web pages");
|
|
141
|
+
outgoing.writeHead(403, { "content-type": "text/plain" }).end("Toolroll's scout browser opens only public web pages.");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const target = { scheme: "http", host: url.hostname, port: Number(url.port || 80) };
|
|
145
|
+
const reached = await resolve(target);
|
|
146
|
+
if (!reached.ok) {
|
|
147
|
+
refuse(url.origin, reached.why);
|
|
148
|
+
outgoing.writeHead(403, { "content-type": "text/plain" }).end("Toolroll's scout browser opens only public web pages.");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const upstream = await connectFirst(reached.addresses, target.port);
|
|
152
|
+
if (upstream === null) {
|
|
153
|
+
outgoing.writeHead(502).end();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
sockets.add(upstream);
|
|
157
|
+
upstream.on("close", () => sockets.delete(upstream));
|
|
158
|
+
const headers = { ...incoming.headers };
|
|
159
|
+
delete headers["proxy-connection"];
|
|
160
|
+
delete headers["proxy-authorization"];
|
|
161
|
+
const forward = httpRequest({ createConnection: () => upstream, method: incoming.method, path: `${url.pathname}${url.search}`, headers, setHost: false }, answer => {
|
|
162
|
+
outgoing.writeHead(answer.statusCode ?? 502, answer.headers);
|
|
163
|
+
answer.pipe(outgoing);
|
|
164
|
+
});
|
|
165
|
+
forward.on("error", () => { if (!outgoing.headersSent)
|
|
166
|
+
outgoing.writeHead(502); outgoing.end(); });
|
|
167
|
+
incoming.pipe(forward);
|
|
168
|
+
})();
|
|
169
|
+
});
|
|
170
|
+
server.on("connect", (incoming, client, head) => {
|
|
171
|
+
sockets.add(client);
|
|
172
|
+
client.on("close", () => sockets.delete(client));
|
|
173
|
+
client.on("error", () => client.destroy());
|
|
174
|
+
void (async () => {
|
|
175
|
+
const match = /^(\[[0-9a-fA-F:.]+\]|[^:[\]]+):(\d{1,5})$/.exec(incoming.url ?? "");
|
|
176
|
+
const target = match === null ? null : { scheme: "https", host: match[1], port: Number(match[2]) };
|
|
177
|
+
const reached = target === null ? { ok: false, why: "not a host and port" } : await resolve(target);
|
|
178
|
+
if (!reached.ok) {
|
|
179
|
+
refuse(incoming.url ?? "", reached.why);
|
|
180
|
+
client.end("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const upstream = await connectFirst(reached.addresses, target.port);
|
|
184
|
+
if (upstream === null) {
|
|
185
|
+
if (!client.destroyed)
|
|
186
|
+
client.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
sockets.add(upstream);
|
|
190
|
+
upstream.on("close", () => { sockets.delete(upstream); client.destroy(); });
|
|
191
|
+
upstream.on("error", () => client.destroy());
|
|
192
|
+
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
193
|
+
if (head.length > 0)
|
|
194
|
+
upstream.write(head);
|
|
195
|
+
upstream.pipe(client);
|
|
196
|
+
client.pipe(upstream);
|
|
197
|
+
})();
|
|
198
|
+
});
|
|
199
|
+
server.on("connection", socket => { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); });
|
|
200
|
+
await new Promise((done, fail) => {
|
|
201
|
+
server.once("error", fail);
|
|
202
|
+
server.listen(0, "127.0.0.1", () => { server.off("error", fail); done(); });
|
|
203
|
+
});
|
|
204
|
+
const address = server.address();
|
|
205
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
206
|
+
return {
|
|
207
|
+
url: `http://127.0.0.1:${port}`,
|
|
208
|
+
close: () => new Promise(done => {
|
|
209
|
+
for (const socket of sockets)
|
|
210
|
+
socket.destroy();
|
|
211
|
+
server.close(() => done());
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
}
|
package/dist/scout-report.d.ts
CHANGED
|
@@ -20,6 +20,24 @@ export type ParsedReport = {
|
|
|
20
20
|
title: string;
|
|
21
21
|
goal: string;
|
|
22
22
|
}[];
|
|
23
|
+
/** What the scout found, as a short list later steps can read: each cites its URL, and may show one of `images`. */
|
|
24
|
+
items: ReportItem[];
|
|
25
|
+
/** Screenshots the scout saved during the run. From the scout, `file` names a file in its output folder; once
|
|
26
|
+
* stored, the runner adds the bytes' sha256 and the evidence row that holds them. */
|
|
27
|
+
images: ReportImage[];
|
|
28
|
+
};
|
|
29
|
+
export type ReportItem = {
|
|
30
|
+
title: string;
|
|
31
|
+
why: string;
|
|
32
|
+
url: string;
|
|
33
|
+
image: string | null;
|
|
34
|
+
};
|
|
35
|
+
export type ReportImage = {
|
|
36
|
+
file: string;
|
|
37
|
+
caption: string;
|
|
38
|
+
url: string;
|
|
39
|
+
sha256?: string;
|
|
40
|
+
artifact?: number;
|
|
23
41
|
};
|
|
24
42
|
export type ReportParseResult = {
|
|
25
43
|
ok: true;
|
|
@@ -38,7 +56,15 @@ export declare const REPORT_LIMITS: {
|
|
|
38
56
|
readonly followUps: 5;
|
|
39
57
|
readonly followUpTitle: 200;
|
|
40
58
|
readonly followUpGoal: 2000;
|
|
59
|
+
readonly items: 6;
|
|
60
|
+
readonly itemTitle: 200;
|
|
61
|
+
readonly itemWhy: 1000;
|
|
62
|
+
readonly url: 2000;
|
|
63
|
+
readonly images: 8;
|
|
64
|
+
readonly caption: 300;
|
|
41
65
|
};
|
|
66
|
+
/** A screenshot's name in the scout's output folder: one plain file name, PNG or JPEG, never a path. */
|
|
67
|
+
export declare const REPORT_IMAGE_FILE: RegExp;
|
|
42
68
|
/**
|
|
43
69
|
* The scout's handback for Claude's `--json-schema` (run 2334's fix): plan
|
|
44
70
|
* mode only lets a session write its own plan file, so a Claude scout
|
|
@@ -82,6 +108,47 @@ export declare const SCOUT_OUTPUT_JSON_SCHEMA: {
|
|
|
82
108
|
readonly additionalProperties: false;
|
|
83
109
|
};
|
|
84
110
|
};
|
|
111
|
+
readonly items: {
|
|
112
|
+
readonly type: "array";
|
|
113
|
+
readonly items: {
|
|
114
|
+
readonly type: "object";
|
|
115
|
+
readonly properties: {
|
|
116
|
+
readonly title: {
|
|
117
|
+
readonly type: "string";
|
|
118
|
+
};
|
|
119
|
+
readonly why: {
|
|
120
|
+
readonly type: "string";
|
|
121
|
+
};
|
|
122
|
+
readonly url: {
|
|
123
|
+
readonly type: "string";
|
|
124
|
+
};
|
|
125
|
+
readonly image: {
|
|
126
|
+
readonly type: "string";
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
readonly required: readonly ["title", "why", "url"];
|
|
130
|
+
readonly additionalProperties: false;
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
readonly images: {
|
|
134
|
+
readonly type: "array";
|
|
135
|
+
readonly items: {
|
|
136
|
+
readonly type: "object";
|
|
137
|
+
readonly properties: {
|
|
138
|
+
readonly file: {
|
|
139
|
+
readonly type: "string";
|
|
140
|
+
};
|
|
141
|
+
readonly caption: {
|
|
142
|
+
readonly type: "string";
|
|
143
|
+
};
|
|
144
|
+
readonly url: {
|
|
145
|
+
readonly type: "string";
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
readonly required: readonly ["file", "caption", "url"];
|
|
149
|
+
readonly additionalProperties: false;
|
|
150
|
+
};
|
|
151
|
+
};
|
|
85
152
|
};
|
|
86
153
|
readonly required: readonly ["title", "summary", "report"];
|
|
87
154
|
readonly additionalProperties: false;
|