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
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type ReleaseTerms } from "./contracts/release-terms.js";
|
|
2
|
+
import type { Scope } from "./scope.js";
|
|
3
|
+
import type { Store } from "./store.js";
|
|
4
|
+
/** One signed term as the card says it. `key` names the scope field or the release term's path. */
|
|
5
|
+
export type TermLine = {
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
value: string;
|
|
9
|
+
};
|
|
10
|
+
/** The scope's own terms the card names, in order. A contract test holds `goal`, `outOfScope`, `touches` and
|
|
11
|
+
* `acceptance` (contracts/scope.ts) and the scope's other signed fields to this list; the agent profile and the phase
|
|
12
|
+
* route follow as one line per signed setting (`profile.…`, `route.…`). */
|
|
13
|
+
export declare const SCOPE_TERM_LABELS: {
|
|
14
|
+
readonly title: "Title";
|
|
15
|
+
readonly project: "Project";
|
|
16
|
+
readonly filedBy: "Filed by";
|
|
17
|
+
readonly goal: "Goal";
|
|
18
|
+
readonly outOfScope: "Not in scope";
|
|
19
|
+
readonly touches: "Paths it may touch";
|
|
20
|
+
readonly acceptance: "Done when";
|
|
21
|
+
readonly qualityMode: "Evidence standard";
|
|
22
|
+
readonly agents: "Agents and model";
|
|
23
|
+
readonly permission: "Agents may";
|
|
24
|
+
readonly checks: "Checks";
|
|
25
|
+
readonly checkAccess: "Check access";
|
|
26
|
+
readonly budgetMicrousd: "Budget per attempt";
|
|
27
|
+
readonly candidate: "Candidate commit";
|
|
28
|
+
};
|
|
29
|
+
/** Every field of the release terms, by its path in the terms (contracts/release-terms.ts), against the schema's
|
|
30
|
+
* own leaves: the contract test checks every one has a label. */
|
|
31
|
+
export declare const RELEASE_TERM_LABELS: Readonly<Record<string, string>>;
|
|
32
|
+
/** The leaf paths of a zod object schema (nested objects joined with dots). */
|
|
33
|
+
export declare function schemaLeaves(schema: unknown, prefix?: string): string[];
|
|
34
|
+
/** Every signed term of `scope`, its release terms included, one line each. Reads only. */
|
|
35
|
+
export declare function approvalTermsOf(store: Store, scope: Scope, words: {
|
|
36
|
+
profile: (profile: Scope["profile"]) => string | null;
|
|
37
|
+
}): TermLine[];
|
|
38
|
+
export declare const termsText: (lines: readonly TermLine[]) => string;
|
|
39
|
+
export type ChangeList = {
|
|
40
|
+
/** The merge commit of the last verified release, or null when none is recorded. */
|
|
41
|
+
baseline: string | null;
|
|
42
|
+
/** Newest first; null when it couldn't be read. */
|
|
43
|
+
commits: {
|
|
44
|
+
sha: string;
|
|
45
|
+
subject: string;
|
|
46
|
+
}[] | null;
|
|
47
|
+
source: "recorded" | "pull-request" | "unread";
|
|
48
|
+
digest: string;
|
|
49
|
+
};
|
|
50
|
+
/** The plane's own record of the last verified release of `repo`: the merge commit GitHub answered with, recorded once
|
|
51
|
+
* the release verified the merged tree (the newest merged authority whose merge operation succeeded). Never a tag. */
|
|
52
|
+
export declare function releaseBaseline(store: Store, repo: string): string | null;
|
|
53
|
+
/** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
|
|
54
|
+
* release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
|
|
55
|
+
* a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
|
|
56
|
+
* history). Null when they can't be read, so a card never shows a shortened list. */
|
|
57
|
+
export declare function commitsBetween(repo: string, from: string, to: string): {
|
|
58
|
+
sha: string;
|
|
59
|
+
subject: string;
|
|
60
|
+
}[] | null;
|
|
61
|
+
/** What changed since the last verified release, for a release's terms, from Toolroll's records only. */
|
|
62
|
+
export declare function releaseChangeList(store: Store, terms: ReleaseTerms): ChangeList;
|
|
63
|
+
export type ReleaseCard = {
|
|
64
|
+
/** Binds the scope digest, the plan form's digest, the baseline and the change list. */
|
|
65
|
+
digest: string;
|
|
66
|
+
short: string;
|
|
67
|
+
full: string;
|
|
68
|
+
fileName: string;
|
|
69
|
+
changes: ChangeList;
|
|
70
|
+
};
|
|
71
|
+
/** The card digest: everything the person is shown that can change under them. */
|
|
72
|
+
export declare function cardDigestOf(scopeDigest: string, formDigest: string, changes: Pick<ChangeList, "baseline" | "digest">): string;
|
|
73
|
+
/** The release card for a deploy gate, against the live records now. */
|
|
74
|
+
export declare function releaseCardOf(store: Store, scope: Scope, formDigest: string, words: {
|
|
75
|
+
profile: (profile: Scope["profile"]) => string | null;
|
|
76
|
+
}): ReleaseCard;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release card (docs/plans/chat-management.md §0a.5): how a deploy gate is approved from chat until the
|
|
3
|
+
* authenticator ships. Alex's decision (2026-10-25): chat keeps approving releases by tap, so the tap is made to mean
|
|
4
|
+
* exactly what was shown.
|
|
5
|
+
*
|
|
6
|
+
* - `approvalTermsOf`: every signed term of the scope (its agent profile and phase route setting by setting) and every
|
|
7
|
+
* field of its release terms, one line each, "none" for an empty one and "any file in the project" for unnamed paths
|
|
8
|
+
* (a contract test changes each input of the approval digest in turn and holds the text to change with it).
|
|
9
|
+
* - `releaseChangeList`: what changed since the last verified release, from the plane's own record of it (the merge
|
|
10
|
+
* commit of the newest merged release whose merge operation succeeded), read as verified Git objects from the
|
|
11
|
+
* project's recorded Git directory: never a tag or a ref, so moving one changes nothing, and history rewritten around
|
|
12
|
+
* the objects (a shallow boundary, grafts, replacement refs) refuses the list rather than shortening it.
|
|
13
|
+
* With no earlier release on record, the pull request's own commits from GitHub, for the recorded owner/name.
|
|
14
|
+
* - `releaseCardOf`: the short card, the full terms (sent as a file where the chat app limits a message), and
|
|
15
|
+
* `cardDigest`, which binds the scope digest, the plan form's digest, the baseline and the change list: Continue and
|
|
16
|
+
* Approve carry it, and anything different at the tap refuses.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { checkAccessWords } from "./check-access.js";
|
|
23
|
+
import { CHECK_LEVEL_WORDS, effectiveCheckLevel } from "./check-levels.js";
|
|
24
|
+
import { verifiedRange } from "./commit-extract.js";
|
|
25
|
+
import { releaseTermsSchema } from "./contracts/release-terms.js";
|
|
26
|
+
import { routeFromJson } from "./phase-routing.js";
|
|
27
|
+
import { projectName } from "./project.js";
|
|
28
|
+
import { RELEASE_PATH, releaseConfigProblem, releaseGitEnv, releaseRemote, releaseTools } from "./release-git.js";
|
|
29
|
+
const NONE = "none";
|
|
30
|
+
/** A budget in dollars from integer micro-dollars, in integer arithmetic: cents when it is whole cents, else every digit. */
|
|
31
|
+
const money = (micro) => {
|
|
32
|
+
const exact = BigInt(micro), whole = exact / 1000000n, rest = (exact < 0n ? -exact : exact) % 1000000n;
|
|
33
|
+
const fraction = rest.toString().padStart(6, "0");
|
|
34
|
+
return `${exact < 0n && whole === 0n ? "-" : ""}$${whole}.${rest % 10000n === 0n ? fraction.slice(0, 2) : fraction}`;
|
|
35
|
+
};
|
|
36
|
+
/** Words the card itself says in place of a value: a signed string spelled the same is quoted, never mistaken for one. */
|
|
37
|
+
const RESERVED = new Set([NONE, "yes", "no", "null", "empty", "not set", "any file in the project", "the whole project", "unreadable"]);
|
|
38
|
+
/** Text that reads the same plain as quoted: ASCII letters, digits and ordinary punctuation only (no lookalike letters
|
|
39
|
+
* from other scripts, no direction marks), never a comma, semicolon, parenthesis, quote, backslash, line break or
|
|
40
|
+
* control character, nor a trailing space. */
|
|
41
|
+
const PLAIN = /^[A-Za-z0-9~/.#$@][A-Za-z0-9 ._/@~+#=!?&%$*:<>|^-]*$/;
|
|
42
|
+
/** JSON-quoted with everything outside printable ASCII \u-escaped (JSON.stringify leaves non-ASCII as it is). */
|
|
43
|
+
const quoted = (value) => JSON.stringify(value).replace(/[^\x20-\x7e]/g, one => `\\u${one.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
44
|
+
/** One signed string, unambiguous: as it is when plain, else quoted; an empty, number-like or colon-bearing string, or
|
|
45
|
+
* one of the card's own words, is quoted too. */
|
|
46
|
+
const text = (value) => PLAIN.test(value) && !value.endsWith(" ") && !RESERVED.has(value.toLowerCase()) && !/^-?\d+(\.\d+)?$/.test(value) ? value : quoted(value);
|
|
47
|
+
/** One signed value as the card says it, every shape distinct: "none" for null, "not set" for absent, "empty" for an
|
|
48
|
+
* empty list, `""` for an empty string; a list's entries each said the same way, so a comma never joins two into one. */
|
|
49
|
+
const said = (value) => {
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
return "not set";
|
|
52
|
+
if (value === null)
|
|
53
|
+
return NONE;
|
|
54
|
+
if (typeof value === "boolean")
|
|
55
|
+
return value ? "yes" : "no";
|
|
56
|
+
if (typeof value === "number")
|
|
57
|
+
return String(value);
|
|
58
|
+
if (Array.isArray(value))
|
|
59
|
+
return value.length === 0 ? "empty" : value.map(one => typeof one === "string" ? text(one) : one === null ? "null" : said(one)).join(", ");
|
|
60
|
+
return text(String(value));
|
|
61
|
+
};
|
|
62
|
+
/** The scope's own terms the card names, in order. A contract test holds `goal`, `outOfScope`, `touches` and
|
|
63
|
+
* `acceptance` (contracts/scope.ts) and the scope's other signed fields to this list; the agent profile and the phase
|
|
64
|
+
* route follow as one line per signed setting (`profile.…`, `route.…`). */
|
|
65
|
+
export const SCOPE_TERM_LABELS = {
|
|
66
|
+
title: "Title", project: "Project", filedBy: "Filed by", goal: "Goal", outOfScope: "Not in scope", touches: "Paths it may touch",
|
|
67
|
+
acceptance: "Done when", qualityMode: "Evidence standard", agents: "Agents and model", permission: "Agents may", checks: "Checks", checkAccess: "Check access",
|
|
68
|
+
budgetMicrousd: "Budget per attempt", candidate: "Candidate commit",
|
|
69
|
+
};
|
|
70
|
+
/** Every leaf of a signed value, at its path (`a.b`, `legs.0.model`), as the card says it. */
|
|
71
|
+
function leavesOf(value, path) {
|
|
72
|
+
if (Array.isArray(value))
|
|
73
|
+
return value.every(one => one === null || typeof one !== "object") ? [{ path, value: said(value) }]
|
|
74
|
+
: value.flatMap((one, index) => leavesOf(one, `${path}.${index}`));
|
|
75
|
+
if (value !== null && typeof value === "object") {
|
|
76
|
+
const keys = Object.keys(value).sort();
|
|
77
|
+
return keys.length === 0 ? [{ path, value: "no settings" }] : keys.flatMap(key => leavesOf(value[key], `${path}.${key}`));
|
|
78
|
+
}
|
|
79
|
+
return [{ path, value: said(value) }];
|
|
80
|
+
}
|
|
81
|
+
/** Every field of the release terms, by its path in the terms (contracts/release-terms.ts), against the schema's
|
|
82
|
+
* own leaves: the contract test checks every one has a label. */
|
|
83
|
+
export const RELEASE_TERM_LABELS = {
|
|
84
|
+
version: "Release terms version", repo: "Repository", packageVersion: "Package version", branch: "Branch", pr: "Pull request", base: "Base",
|
|
85
|
+
approvedSha: "Approved commit", approvedTree: "Approved tree",
|
|
86
|
+
"policy.version": "Carry policy version", "policy.rules": "Carry rules", "policy.carry": "Carry later commits",
|
|
87
|
+
"policy.protectProject": "Protected: the whole project", "policy.protectedPaths": "Protected paths",
|
|
88
|
+
"policy.limits.lines": "Carry limit: lines", "policy.limits.files": "Carry limit: files", "policy.limits.churn": "Carry limit: churn",
|
|
89
|
+
"policy.unsandboxedCarry": "Carry when checks aren't sandboxed",
|
|
90
|
+
"grant.id": "Release check grant", "grant.digest": "Release check grant digest", access: "Release check access", expiresAt: "Expires",
|
|
91
|
+
};
|
|
92
|
+
/** The leaf paths of a zod object schema (nested objects joined with dots). */
|
|
93
|
+
export function schemaLeaves(schema, prefix = "") {
|
|
94
|
+
const shape = schema.shape;
|
|
95
|
+
if (shape === undefined || shape === null)
|
|
96
|
+
return [prefix];
|
|
97
|
+
return Object.entries(shape).flatMap(([key, child]) => schemaLeaves(child, prefix === "" ? key : `${prefix}.${key}`));
|
|
98
|
+
}
|
|
99
|
+
const valueAt = (terms, path) => path.split(".").reduce((at, key) => at !== null && typeof at === "object" ? at[key] : undefined, terms);
|
|
100
|
+
/** Every signed term of `scope`, its release terms included, one line each. Reads only. */
|
|
101
|
+
export function approvalTermsOf(store, scope, words) {
|
|
102
|
+
const ref = store.lookupRef(scope.taskId);
|
|
103
|
+
const repo = ref?.repo ?? null;
|
|
104
|
+
const filer = store.taskFiler(scope.taskId);
|
|
105
|
+
const profile = scope.profile ?? null;
|
|
106
|
+
const scopeValues = {
|
|
107
|
+
title: said(store.getTask(scope.taskId)?.title),
|
|
108
|
+
project: repo === null ? NONE : `${text(projectName(repo))} (${text(repo)})`,
|
|
109
|
+
filedBy: filer === null ? NONE : filer.name === null ? filer.kind : `${text(filer.name)}${filer.kind === "person" ? "" : ` (${filer.kind})`}`,
|
|
110
|
+
goal: said(scope.goal),
|
|
111
|
+
// null (nothing excluded) and an empty exclusion are signed apart, and read apart.
|
|
112
|
+
outOfScope: said(scope.outOfScope),
|
|
113
|
+
// No paths named is no limit: the work may touch any file in the project.
|
|
114
|
+
touches: scope.touches.length === 0 ? "any file in the project" : said(scope.touches),
|
|
115
|
+
// Each criterion's fields quoted, always: a colon or a parenthesis in one never moves the boundary to the next.
|
|
116
|
+
acceptance: scope.acceptance.length === 0 ? NONE : scope.acceptance.map(one => `${quoted(one.id)}: ${quoted(one.statement)} (evidence: ${said(one.evidence)})`).join("; "),
|
|
117
|
+
qualityMode: scope.qualityMode === "strict" ? "strict" : "standard",
|
|
118
|
+
agents: profile === null ? NONE : `${profile.provider} · ${text(profile.model)}`,
|
|
119
|
+
permission: said(words.profile(profile)),
|
|
120
|
+
checks: CHECK_LEVEL_WORDS[effectiveCheckLevel(store, repo, scope.taskId).level],
|
|
121
|
+
checkAccess: checkAccessWords(profile?.checkAccess ?? "sandboxed"),
|
|
122
|
+
budgetMicrousd: scope.budgetMicrousd === null ? NONE : money(scope.budgetMicrousd),
|
|
123
|
+
candidate: said(scope.candidate),
|
|
124
|
+
};
|
|
125
|
+
const lines = Object.keys(SCOPE_TERM_LABELS).map(key => ({ key, label: SCOPE_TERM_LABELS[key], value: scopeValues[key] }));
|
|
126
|
+
// Every signed setting of the agent profile (what it may read, reach and write, its limits) and the phase route the
|
|
127
|
+
// digest binds, one line each: the summary lines above never stand in for them.
|
|
128
|
+
if (profile !== null)
|
|
129
|
+
for (const leaf of leavesOf(profile, "profile"))
|
|
130
|
+
lines.push({ key: leaf.path, label: `Agent setting ${leaf.path.slice("profile.".length)}`, value: leaf.value });
|
|
131
|
+
if (scope.proposedRouteJson != null) {
|
|
132
|
+
const route = routeFromJson(scope.proposedRouteJson);
|
|
133
|
+
// Unreadable (it then never seals): its stored text, exactly, so two different ones never read alike.
|
|
134
|
+
if (route === null)
|
|
135
|
+
lines.push({ key: "route", label: "Phase route (unreadable)", value: quoted(scope.proposedRouteJson) });
|
|
136
|
+
else
|
|
137
|
+
for (const leaf of leavesOf(route, "route"))
|
|
138
|
+
lines.push({ key: leaf.path, label: `Phase route ${leaf.path.slice("route.".length)}`, value: leaf.value });
|
|
139
|
+
}
|
|
140
|
+
const terms = scope.releaseTerms ?? null;
|
|
141
|
+
for (const path of schemaLeaves(releaseTermsSchema)) {
|
|
142
|
+
const label = RELEASE_TERM_LABELS[path] ?? path;
|
|
143
|
+
const value = terms === null ? NONE : path === "pr" ? `#${terms.pr}`
|
|
144
|
+
// The whole project is protected, and the signed list is said too (it is part of what was signed).
|
|
145
|
+
: path === "policy.protectedPaths" && terms.policy.protectProject ? `the whole project (listed: ${said(terms.policy.protectedPaths)})`
|
|
146
|
+
// Absent (sealed before the setting existed) is off, said apart from a signed "no".
|
|
147
|
+
: path === "policy.unsandboxedCarry" && terms.policy.unsandboxedCarry === undefined ? "no (not set when sealed)" : said(valueAt(terms, path));
|
|
148
|
+
lines.push({ key: `release.${path}`, label, value });
|
|
149
|
+
}
|
|
150
|
+
return lines;
|
|
151
|
+
}
|
|
152
|
+
export const termsText = (lines) => lines.map(line => `${line.label}: ${line.value}`).join("\n");
|
|
153
|
+
const SHA = /^[0-9a-f]{40}$/;
|
|
154
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
155
|
+
/** The plane's own record of the last verified release of `repo`: the merge commit GitHub answered with, recorded once
|
|
156
|
+
* the release verified the merged tree (the newest merged authority whose merge operation succeeded). Never a tag. */
|
|
157
|
+
export function releaseBaseline(store, repo) {
|
|
158
|
+
const row = store.handle.prepare(`SELECT o.external_ref FROM release_authority a JOIN release_operation o ON o.authority = a.id
|
|
159
|
+
WHERE a.repo = ? AND a.state = 'merged' AND o.kind = 'merge' AND o.state = 'succeeded' AND o.external_ref IS NOT NULL ORDER BY a.id DESC, o.finished_at DESC LIMIT 1`).get(repo);
|
|
160
|
+
const ref = row === undefined ? null : String(row["external_ref"]);
|
|
161
|
+
return ref !== null && SHA.test(ref) ? ref : null;
|
|
162
|
+
}
|
|
163
|
+
/** The commits after `from` up to `to`, as verified Git objects only, from the project's recorded Git directory: the
|
|
164
|
+
* release coordinator's checkout config allowlist first, then commit-extract's verified range (every commit hashed here,
|
|
165
|
+
* a shallow boundary, grafts or replacement refs refused, and the range proved to stop only in the last release's own
|
|
166
|
+
* history). Null when they can't be read, so a card never shows a shortened list. */
|
|
167
|
+
export function commitsBetween(repo, from, to) {
|
|
168
|
+
if (!SHA.test(from) || !SHA.test(to))
|
|
169
|
+
return null;
|
|
170
|
+
let gitDir;
|
|
171
|
+
try {
|
|
172
|
+
const named = join(realpathSync(repo), ".git");
|
|
173
|
+
if (!lstatSync(named).isDirectory())
|
|
174
|
+
return null;
|
|
175
|
+
gitDir = realpathSync(named);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const tools = releaseTools();
|
|
181
|
+
const env = { ...releaseGitEnv(), PATH: RELEASE_PATH };
|
|
182
|
+
const listing = spawnSync(tools.git, [`--git-dir=${gitDir}`, "--no-optional-locks", "config", "--list", "--no-includes", "--show-scope", "-z"], { cwd: "/", env, encoding: "utf8", timeout: 15_000 });
|
|
183
|
+
if (listing.status !== 0 || releaseConfigProblem(listing.stdout) !== null)
|
|
184
|
+
return null;
|
|
185
|
+
const range = verifiedRange(gitDir, from, to, { git: tools.git, env });
|
|
186
|
+
if (!range.ok)
|
|
187
|
+
return null;
|
|
188
|
+
return range.value.map(one => ({ sha: one.id, subject: one.message.split("\n")[0].trim() }));
|
|
189
|
+
}
|
|
190
|
+
/** A pull request's own commits from GitHub, for the recorded owner/name. Null when GitHub can't be read. */
|
|
191
|
+
function pullRequestCommits(slug, pr) {
|
|
192
|
+
const tools = releaseTools();
|
|
193
|
+
const read = spawnSync(tools.gh, ["api", `repos/${slug}/pulls/${pr}/commits?per_page=100`, "--jq", '.[] | .sha + "\\t" + (.commit.message | split("\\n")[0])'], { cwd: "/", env: releaseGitEnv(), encoding: "utf8", timeout: 15_000 });
|
|
194
|
+
if (read.status !== 0)
|
|
195
|
+
return null;
|
|
196
|
+
return read.stdout.split("\n").filter(line => line !== "").map(line => { const tab = line.indexOf("\t"); return { sha: line.slice(0, tab), subject: line.slice(tab + 1) }; })
|
|
197
|
+
.filter(one => SHA.test(one.sha)).reverse();
|
|
198
|
+
}
|
|
199
|
+
/** What changed since the last verified release, for a release's terms, from Toolroll's records only. */
|
|
200
|
+
export function releaseChangeList(store, terms) {
|
|
201
|
+
const baseline = releaseBaseline(store, terms.repo);
|
|
202
|
+
let commits;
|
|
203
|
+
let source;
|
|
204
|
+
if (baseline !== null) {
|
|
205
|
+
commits = commitsBetween(terms.repo, baseline, terms.approvedSha);
|
|
206
|
+
source = commits === null ? "unread" : "recorded";
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const remote = store.projectRemote(terms.repo);
|
|
210
|
+
const parsed = remote === null ? null : releaseRemote(remote);
|
|
211
|
+
commits = parsed === null ? null : pullRequestCommits(parsed.slug, terms.pr);
|
|
212
|
+
source = commits === null ? "unread" : "pull-request";
|
|
213
|
+
}
|
|
214
|
+
return { baseline, commits, source, digest: sha256(`release-changes:${JSON.stringify({ baseline, source, commits })}`) };
|
|
215
|
+
}
|
|
216
|
+
/** The card digest: everything the person is shown that can change under them. */
|
|
217
|
+
export function cardDigestOf(scopeDigest, formDigest, changes) {
|
|
218
|
+
return sha256(`release-card:v1:${JSON.stringify([scopeDigest, formDigest, changes.baseline, changes.digest])}`);
|
|
219
|
+
}
|
|
220
|
+
function changeWords(changes, terms, cap) {
|
|
221
|
+
const head = changes.baseline === null ? `No earlier verified release is recorded. ${changes.source === "pull-request" ? `Pull request #${terms?.pr ?? "?"}'s commits:` : ""}`.trim()
|
|
222
|
+
: `Changes since the last verified release (${changes.baseline.slice(0, 7)}):`;
|
|
223
|
+
if (changes.commits === null)
|
|
224
|
+
return [head, changes.baseline === null ? `The pull request's commits couldn't be read; see #${terms?.pr ?? "?"} on GitHub.` : "The change list couldn't be read."];
|
|
225
|
+
if (changes.commits.length === 0)
|
|
226
|
+
return [head, "No commits."];
|
|
227
|
+
return [head, ...changes.commits.slice(0, cap).map(one => `• ${one.sha.slice(0, 7)} ${one.subject.slice(0, 100)}`), ...(changes.commits.length > cap ? [`• and ${changes.commits.length - cap} more`] : [])];
|
|
228
|
+
}
|
|
229
|
+
/** The release card for a deploy gate, against the live records now. */
|
|
230
|
+
export function releaseCardOf(store, scope, formDigest, words) {
|
|
231
|
+
const terms = scope.releaseTerms ?? null;
|
|
232
|
+
const lines = approvalTermsOf(store, scope, words);
|
|
233
|
+
const value = (key) => lines.find(line => line.key === key)?.value ?? NONE;
|
|
234
|
+
const changes = terms === null
|
|
235
|
+
? { baseline: null, commits: null, source: "unread", digest: sha256("release-changes:none") }
|
|
236
|
+
: releaseChangeList(store, terms);
|
|
237
|
+
const version = terms?.packageVersion ?? null;
|
|
238
|
+
const fileName = `release-${version ?? scope.taskId.replace(/[^A-Za-z0-9._-]/g, "-")}-terms.txt`;
|
|
239
|
+
const short = [
|
|
240
|
+
version === null ? `Deploy gate: ${value("title")}` : `Release ${version}: ${value("title")}`,
|
|
241
|
+
terms === null ? `Candidate ${said(scope.candidate).slice(0, 12)} · ${value("project")}` : `${projectName(terms.repo)} · ${terms.branch} · pull request #${terms.pr} · commit ${terms.approvedSha.slice(0, 7)}`,
|
|
242
|
+
terms === null ? `Checks: ${value("checks")} · check access: ${value("checkAccess")}` : `Carry later commits: ${terms.policy.carry ? "yes" : "no"} · checks: ${value("checks")} · expires ${terms.expiresAt}`,
|
|
243
|
+
// Option A's disclosure, on the card as on the approval sheet: never approved from chat without it.
|
|
244
|
+
...(terms !== null && terms.policy.carry && terms.access !== "sandboxed" ? ["This project's checks don't run in the sandbox: later test-only commits will run outside the sandbox without being shown to you first."] : []),
|
|
245
|
+
"",
|
|
246
|
+
...changeWords(changes, terms, 5),
|
|
247
|
+
"",
|
|
248
|
+
`Every term is in ${fileName}.`,
|
|
249
|
+
].join("\n");
|
|
250
|
+
const full = [`${version === null ? "Deploy gate" : `Release ${version}`}: every term`, "", termsText(lines), "", ...changeWords(changes, terms, 200)].join("\n");
|
|
251
|
+
return { digest: cardDigestOf(scope.digest, formDigest, changes), short, full, fileName, changes };
|
|
252
|
+
}
|
package/dist/release-carry.d.ts
CHANGED
|
@@ -27,6 +27,9 @@ export declare const CARRY_LIMITS: Readonly<{
|
|
|
27
27
|
/** "Ask for every release commit": a project setting, newest choice wins (a ledger row each); off by default. */
|
|
28
28
|
export declare const ASK_EVERY_COMMIT_ACTION = "release carrying changed";
|
|
29
29
|
export declare function asksEveryReleaseCommit(store: Store, repo: string): boolean;
|
|
30
|
+
/** Set "Carry release approvals when checks aren't sandboxed". `by` is who set it; turning it on is only ever called
|
|
31
|
+
* after a person's own proof (release-cli.ts). */
|
|
32
|
+
export declare function setUnsandboxedCarry(store: Store, repo: string, on: boolean, by: string, now: Date): void;
|
|
30
33
|
export declare function setAskEveryReleaseCommit(store: Store, repo: string, ask: boolean, by: string, now: Date): void;
|
|
31
34
|
/** The effective policy now: the runtime rules, the project's protected paths and the limits. */
|
|
32
35
|
export declare function effectivePolicy(store: Store, repo: string, options?: {
|
package/dist/release-carry.js
CHANGED
|
@@ -32,6 +32,7 @@ import { CARRY_RULES_VERSION, readReleasePolicy, releasePolicyDigest } from "./c
|
|
|
32
32
|
import { run } from "./exec.js";
|
|
33
33
|
import { proxyEnv, startEgressProxy } from "./egress.js";
|
|
34
34
|
import { liveReleaseOf } from "./scope.js";
|
|
35
|
+
import { UNSANDBOXED_CARRY_ACTION, carriesUnsandboxed } from "./release-authority.js";
|
|
35
36
|
import { carryReleaseApproval, releaseAuthority, releaseCarryTo, sealAnchorArtifact, } from "./store.js";
|
|
36
37
|
// ------------------------------------------------------------------ the rules
|
|
37
38
|
/** The journey scripts: tests, by Alex's decision (they carry). */
|
|
@@ -70,6 +71,12 @@ export function asksEveryReleaseCommit(store, repo) {
|
|
|
70
71
|
const row = store.handle.prepare("SELECT outcome FROM action_ledger WHERE repo = ? AND action = ? ORDER BY id DESC LIMIT 1").get(repo, ASK_EVERY_COMMIT_ACTION);
|
|
71
72
|
return row !== undefined && row["outcome"] === "ask";
|
|
72
73
|
}
|
|
74
|
+
/** Set "Carry release approvals when checks aren't sandboxed". `by` is who set it; turning it on is only ever called
|
|
75
|
+
* after a person's own proof (release-cli.ts). */
|
|
76
|
+
export function setUnsandboxedCarry(store, repo, on, by, now) {
|
|
77
|
+
store.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action: UNSANDBOXED_CARRY_ACTION, outcome: on ? "on" : "off", source: "request",
|
|
78
|
+
detail: on ? "Carry release approvals when checks aren't sandboxed: later test-only commits run outside the sandbox without being shown first" : "Release approvals carry only when checks are sandboxed" });
|
|
79
|
+
}
|
|
73
80
|
export function setAskEveryReleaseCommit(store, repo, ask, by, now) {
|
|
74
81
|
store.recordAction({ at: now.toISOString(), actor: by, repo, taskId: null, runId: null, action: ASK_EVERY_COMMIT_ACTION, outcome: ask ? "ask" : "carry", source: "request",
|
|
75
82
|
detail: ask ? "Ask for every release commit" : "Later test-only commits ship under the release approval" });
|
|
@@ -80,6 +87,7 @@ export function effectivePolicy(store, repo, options = {}) {
|
|
|
80
87
|
return {
|
|
81
88
|
version: 1, rules: CARRY_RULES_VERSION, carry: options.noCarry !== true && !asksEveryReleaseCommit(store, repo),
|
|
82
89
|
protectProject: rules.protectProject, protectedPaths: [...new Set(rules.protectedPaths)].sort(), limits: { ...CARRY_LIMITS },
|
|
90
|
+
unsandboxedCarry: carriesUnsandboxed(store, repo),
|
|
83
91
|
};
|
|
84
92
|
}
|
|
85
93
|
/** The stricter of two policies: what either protects asks again, the lower limit wins, either one's "ask" asks. Null
|
|
@@ -91,6 +99,8 @@ export function stricterOf(a, b) {
|
|
|
91
99
|
version: 1, rules: CARRY_RULES_VERSION, carry: a.carry && b.carry, protectProject: a.protectProject || b.protectProject,
|
|
92
100
|
protectedPaths: [...new Set([...a.protectedPaths, ...b.protectedPaths])].sort(),
|
|
93
101
|
limits: { lines: Math.min(a.limits.lines, b.limits.lines), files: Math.min(a.limits.files, b.limits.files), churn: Math.min(a.limits.churn, b.limits.churn) },
|
|
102
|
+
// Carrying when checks aren't sandboxed only while both allow it: turning it on later never widens an approval.
|
|
103
|
+
unsandboxedCarry: a.unsandboxedCarry === true && b.unsandboxedCarry === true,
|
|
94
104
|
};
|
|
95
105
|
}
|
|
96
106
|
/**
|
|
@@ -141,12 +151,17 @@ export function releaseTermsFor(store, input) {
|
|
|
141
151
|
const grant = store.liveVerifyCommand(input.repo);
|
|
142
152
|
if (grant === null)
|
|
143
153
|
return { ok: false, why: "the project has no approved release check" };
|
|
144
|
-
|
|
145
|
-
|
|
154
|
+
const access = store.projectCheckAccess(input.repo);
|
|
155
|
+
const policy = effectivePolicy(store, input.repo, { noCarry: input.noCarry ?? false });
|
|
156
|
+
// Checks outside the sandbox carry only with the project's "Carry release approvals when checks aren't sandboxed" on,
|
|
157
|
+
// sealed here: turning it on after this approval never carries for it.
|
|
158
|
+
if (access !== "sandboxed" && policy.unsandboxedCarry !== true) {
|
|
159
|
+
return { ok: false, why: "the project's checks run outside the sandbox, so later commits always ask again (unless “Carry release approvals when checks aren't sandboxed” is on)" };
|
|
160
|
+
}
|
|
146
161
|
return { ok: true, terms: {
|
|
147
162
|
version: 1, repo: input.repo, packageVersion: input.version, branch: input.branch, pr: input.pr, base: "main",
|
|
148
|
-
approvedSha: input.candidate, approvedTree: commit.value.tree, policy
|
|
149
|
-
grant: { id: grant.id, digest: grant.digest }, access
|
|
163
|
+
approvedSha: input.candidate, approvedTree: commit.value.tree, policy,
|
|
164
|
+
grant: { id: grant.id, digest: grant.digest }, access,
|
|
150
165
|
expiresAt: new Date(input.now.getTime() + 3 * 24 * 60 * 60_000).toISOString(),
|
|
151
166
|
} };
|
|
152
167
|
}
|
|
@@ -170,6 +185,10 @@ export function preflightCarry(store, input) {
|
|
|
170
185
|
return refuse("authority", "This Toolroll doesn't know the release's carry rules; update Toolroll first.");
|
|
171
186
|
if (!policy.carry)
|
|
172
187
|
return refuse("rules", "This project asks for every release commit.");
|
|
188
|
+
// Checks outside the sandbox (at the approval or now): only when the sealed setting and the current one both allow it.
|
|
189
|
+
if ((a.accessDigest !== "sandboxed" || store.projectCheckAccess(a.repo) !== "sandboxed") && policy.unsandboxedCarry !== true) {
|
|
190
|
+
return refuse("rules", "This project's checks run outside the sandbox, and carrying release approvals then wasn't on when this release was approved, or is off now.");
|
|
191
|
+
}
|
|
173
192
|
if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(input.to))
|
|
174
193
|
return refuse("ancestry", `${input.to} isn't a full commit id.`);
|
|
175
194
|
if (input.to === a.tipSha)
|
package/dist/release-cli.js
CHANGED
|
@@ -16,13 +16,16 @@ import { RELEASE_LIMITS, runRelease } from "./release.js";
|
|
|
16
16
|
import { processes, releaseAdapters } from "./release-adapters.js";
|
|
17
17
|
import { releaseGate } from "./release-gate.js";
|
|
18
18
|
import { databasePath, openStore, revokeRelease } from "./store.js";
|
|
19
|
-
import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit } from "./release-carry.js";
|
|
19
|
+
import { carryRefusalWords, proveCarry, setAskEveryReleaseCommit, setUnsandboxedCarry } from "./release-carry.js";
|
|
20
20
|
import { authenticateApprover, machineActing, personProof } from "./scope.js";
|
|
21
21
|
import { createNightlyCheckFlow } from "./nightly-check.js";
|
|
22
22
|
import { parseSchedule } from "./flow-schedule.js";
|
|
23
23
|
export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itself, from a pushed branch with an open pull request
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
First looks at the pull request's checks: a required check that failed stops before anything is filed or sent for
|
|
26
|
+
approval, and checks not started or still running are named and waited for (stopping if they don't finish within
|
|
27
|
+
the gate's limit). Then requires an active approver's scope approval (the only human step) and a passing Full check
|
|
28
|
+
on the exact commit.
|
|
26
29
|
Completes that result, drains, backs up, deploys and relinks both CLI names to that commit. After PR checks pass,
|
|
27
30
|
squash-merges, deletes the matching remote branch, verifies the merged tree and tags. Both macOS legs gate npm
|
|
28
31
|
publication; Homebrew waits for its checks to register and all pass before merging.
|
|
@@ -49,6 +52,11 @@ toolroll release nightly --repo <path> --as <you> --token <password> [--schedule
|
|
|
49
52
|
toolroll release ask-every-commit on|off --repo <path> [--as <you> --token <password>] — this project's releases ask
|
|
50
53
|
for every later commit (on), or carry test-only commits again (off: your password, never the lead's token)
|
|
51
54
|
|
|
55
|
+
toolroll release unsandboxed-carry on|off --repo <path> [--as <you> --token <password>] — carry release approvals
|
|
56
|
+
when this project's checks aren't sandboxed (on: your password, never the lead's token). With it on, later
|
|
57
|
+
test-only commits of a release run outside the sandbox without being shown to you first. Off by default; it counts
|
|
58
|
+
only for releases approved while it is on.
|
|
59
|
+
|
|
52
60
|
Options
|
|
53
61
|
--no-carry every later commit of this release asks for approval again
|
|
54
62
|
--to <commit> with carry: the later commit
|
|
@@ -126,7 +134,7 @@ export async function runReleaseCommand(args, write, deps = {}) {
|
|
|
126
134
|
return refuse("usage", "--yes goes with --release-gate <id>; a release needs no confirmation beyond the owner's approval.", 2);
|
|
127
135
|
if (positional[0] === "nightly" && positional.length === 1)
|
|
128
136
|
return runNightlyCommand({ repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo), as, token, schedule: scheduleFlag, start: args.includes("--start") }, write, json, deps);
|
|
129
|
-
if (["carry", "revoke", "ask-every-commit"].includes(positional[0] ?? "") && positional.length === 2) {
|
|
137
|
+
if (["carry", "revoke", "ask-every-commit", "unsandboxed-carry"].includes(positional[0] ?? "") && positional.length === 2) {
|
|
130
138
|
return runAuthorityCommand(positional[0], positional[1], { to, request, as, token, repo: repo === undefined ? undefined : resolve(deps.cwd ?? process.cwd(), repo) }, write, json, deps);
|
|
131
139
|
}
|
|
132
140
|
if (positional.length !== 1)
|
|
@@ -255,6 +263,31 @@ async function runAuthorityCommand(command, target, flags, write, json, deps) {
|
|
|
255
263
|
const words = target === "on" ? "Every later release commit in this project asks for approval." : "Later test-only release commits ship under the release approval.";
|
|
256
264
|
return answer(true, { repo: flags.repo, ask: target === "on" }, words, 0);
|
|
257
265
|
}
|
|
266
|
+
if (command === "unsandboxed-carry") {
|
|
267
|
+
if (target !== "on" && target !== "off")
|
|
268
|
+
return answer(false, { reason: "usage", message: "unsandboxed-carry takes on or off." }, "unsandboxed-carry takes on or off.", 2);
|
|
269
|
+
if (flags.repo === undefined)
|
|
270
|
+
return answer(false, { reason: "usage", message: "Name the project with --repo <path>." }, "Name the project with --repo <path>.", 2);
|
|
271
|
+
// Turning it on relaxes a release's rules: a person's own ceremony, as an approval needs, never a lead or remote
|
|
272
|
+
// token. Turning it off is only stricter.
|
|
273
|
+
let actor = "cli";
|
|
274
|
+
if (target === "on") {
|
|
275
|
+
const refused = (message) => answer(false, { reason: "person-only", message }, message, 1);
|
|
276
|
+
if (machineActing() || process.env["TOOLROLL_LEAD_TOKEN"] !== undefined || process.env["TOOLROLL_REMOTE_TOKEN"] !== undefined)
|
|
277
|
+
return refused("Only a person turns this on, never the lead or a remote token.");
|
|
278
|
+
if (flags.as === undefined || flags.token === undefined)
|
|
279
|
+
return refused("Turning this on needs your password: add --as <you> --token <password>.");
|
|
280
|
+
const proved = authenticateApprover(store, flags.as, flags.token, flags.repo);
|
|
281
|
+
if (!proved.ok || !personProof(proved.method))
|
|
282
|
+
return refused("Only an approver of this project, proved as a person, turns this on.");
|
|
283
|
+
actor = flags.as;
|
|
284
|
+
}
|
|
285
|
+
setUnsandboxedCarry(store, flags.repo, target === "on", actor, now);
|
|
286
|
+
const words = target === "on"
|
|
287
|
+
? "Release approvals approved from now on carry when this project's checks aren't sandboxed: later test-only commits will run outside the sandbox without being shown to you first."
|
|
288
|
+
: "Release approvals carry only when this project's checks are sandboxed.";
|
|
289
|
+
return answer(true, { repo: flags.repo, unsandboxedCarry: target === "on" }, words, 0);
|
|
290
|
+
}
|
|
258
291
|
const id = Number(target);
|
|
259
292
|
if (!Number.isSafeInteger(id) || id < 1)
|
|
260
293
|
return answer(false, { reason: "usage", message: `${target} isn't a release id.` }, `${target} isn't a release id.`, 2);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The release's repository as Toolroll records it (docs/plans/chat-management.md §0a.2), never as the gate checkout
|
|
3
|
+
* says: the checkout is one of the plane's recorded projects (by real path), its Git directory is that project's own
|
|
4
|
+
* plain `.git` folder, and its GitHub `owner/name` and the URL fetched from come from the recorded remote
|
|
5
|
+
* (`project.remote_url`), not the checkout's `origin`. The checkout is where the release check ran the build's own code,
|
|
6
|
+
* so its `.git/config` is treated as build-written.
|
|
7
|
+
*/
|
|
8
|
+
export type ReleaseRepo = {
|
|
9
|
+
checkout: string;
|
|
10
|
+
gitDir: string;
|
|
11
|
+
remote: string;
|
|
12
|
+
slug: string;
|
|
13
|
+
ssh: boolean;
|
|
14
|
+
};
|
|
15
|
+
/** The programs the coordinator runs, by absolute path, found once when it starts. */
|
|
16
|
+
export type ReleaseTools = {
|
|
17
|
+
git: string;
|
|
18
|
+
gh: string;
|
|
19
|
+
ssh: string;
|
|
20
|
+
};
|
|
21
|
+
/** owner/name and transport of a recorded GitHub remote, read strictly (https or ssh to github.com, nothing else). */
|
|
22
|
+
export declare function releaseRemote(url: string): {
|
|
23
|
+
slug: string;
|
|
24
|
+
ssh: boolean;
|
|
25
|
+
} | null;
|
|
26
|
+
/** Fixed system folders: the only PATH the coordinator's Git and gh run with. */
|
|
27
|
+
export declare const RELEASE_PATH = "/usr/bin:/bin:/usr/sbin:/sbin";
|
|
28
|
+
/** Git, gh and ssh by absolute path: the system's Git (or the first in the fixed system folders), the installed gh as
|
|
29
|
+
* this process's PATH finds it now, and the system ssh. Resolved once; never looked up again. */
|
|
30
|
+
export declare function releaseTools(path?: string): ReleaseTools;
|
|
31
|
+
/** The coordinator's Git and gh environment: an allowlist, a fixed PATH, no system or global Git config, no prompts. */
|
|
32
|
+
export declare function releaseGitEnv(base?: Readonly<NodeJS.ProcessEnv>): NodeJS.ProcessEnv;
|
|
33
|
+
/** Every coordinator Git command's fixed settings, ahead of its own arguments: the recorded Git directory and checkout,
|
|
34
|
+
* no hooks, fsmonitor, replacement objects, pager or submodule recursion, only https (and ssh for an ssh remote), the
|
|
35
|
+
* system ssh, and gh as the only credential helper. */
|
|
36
|
+
export declare function releaseGitArgs(repo: ReleaseRepo, tools: ReleaseTools): string[];
|
|
37
|
+
/** The named remote the coordinator fetches, looks and pushes through: defined only on its own command line (`-c`), so
|
|
38
|
+
* no checkout setting names or rewrites it. */
|
|
39
|
+
export declare const RELEASE_REMOTE = "toolroll-release";
|
|
40
|
+
/** The first local or worktree setting a gate checkout's config holds that a release won't run with, or null: one
|
|
41
|
+
* outside the allowlist (an include, a command, a proxy, TLS or header setting, a credential helper, a URL rewrite, a
|
|
42
|
+
* hooks folder), a remote named like a URL or after the coordinator's own, or a remote over a transport helper. */
|
|
43
|
+
export declare function releaseConfigProblem(listing: string): string | null;
|