humanish 0.114.0 → 0.116.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/CONTEXT.md +1 -1
- package/README.md +7 -6
- package/TELEMETRY.md +6 -0
- package/dist/actors/closing-report-limits.d.ts +11 -0
- package/dist/actors/closing-report-limits.js +13 -0
- package/dist/actors/closing-report.d.ts +68 -0
- package/dist/actors/closing-report.js +55 -0
- package/dist/actors/codex/app-server-ui.js +11 -4
- package/dist/actors/codex/restricted-participant-policy.d.ts +4 -47
- package/dist/actors/codex/restricted-participant-policy.js +12 -40
- package/dist/actors/computer-use/loop/debrief.d.ts +1 -10
- package/dist/actors/computer-use/loop/debrief.js +68 -70
- package/dist/actors/computer-use/loop/impressions.d.ts +2 -2
- package/dist/actors/computer-use/loop/impressions.js +6 -1
- package/dist/actors/computer-use/loop.d.ts +0 -1
- package/dist/actors/computer-use/loop.js +3 -3
- package/dist/actors/computer-use/openai-conversation.d.ts +1 -1
- package/dist/actors/computer-use/openai-conversation.js +3 -29
- package/dist/actors/computer-use/openai-provider.js +11 -5
- package/dist/actors/contract.d.ts +2 -20
- package/dist/actors/contract.js +0 -13
- package/dist/actors/scripted-browser/steps.js +6 -5
- package/dist/analysis/admission.d.ts +14 -4
- package/dist/analysis/admission.js +14 -9
- package/dist/analysis/analysis-limits.d.ts +35 -0
- package/dist/analysis/analysis-limits.js +41 -0
- package/dist/analysis/execute.d.ts +5 -0
- package/dist/analysis/execute.js +3 -0
- package/dist/analysis/finding-lead.d.ts +27 -0
- package/dist/analysis/finding-lead.js +23 -0
- package/dist/analysis/service.js +5 -3
- package/dist/analysis/validation.js +14 -21
- package/dist/cli/commands/analyze.js +2 -1
- package/dist/cli/commands/notes.js +5 -3
- package/dist/cli/commands/run-signals.js +5 -1
- package/dist/cli/commands/runs.js +2 -1
- package/dist/cli/commands/setup.js +3 -3
- package/dist/cli/commands/study-format.d.ts +5 -2
- package/dist/cli/commands/study-format.js +29 -9
- package/dist/cli/commands/study-route-computer-use.js +2 -2
- package/dist/cli/commands/study-route-scripted.js +1 -1
- package/dist/cli/commands/tui.js +59 -17
- package/dist/cli/doctor.js +9 -2
- package/dist/cli/findings-text.d.ts +5 -0
- package/dist/cli/findings-text.js +169 -0
- package/dist/cli/findings.d.ts +0 -4
- package/dist/cli/findings.js +11 -195
- package/dist/cli/io.js +10 -8
- package/dist/cli/telemetry.js +10 -22
- package/dist/cli/update-check.js +10 -19
- package/dist/cli/user-config.d.ts +11 -0
- package/dist/cli/user-config.js +25 -0
- package/dist/comms/connections.js +4 -3
- package/dist/comms/email-catch.js +7 -31
- package/dist/comms/setup.js +18 -12
- package/dist/evidence/encoded-text.d.ts +12 -11
- package/dist/evidence/encoded-text.js +81 -70
- package/dist/evidence/redaction.d.ts +11 -0
- package/dist/evidence/redaction.js +30 -11
- package/dist/evidence/secret-scrub.d.ts +26 -3
- package/dist/evidence/secret-scrub.js +172 -38
- package/dist/feedback/draft.d.ts +1 -1
- package/dist/feedback/draft.js +13 -16
- package/dist/feedback/export-bundle.js +21 -17
- package/dist/feedback/export.js +32 -46
- package/dist/feedback/feedback.js +31 -26
- package/dist/guest/desktop-keys.js +59 -5
- package/dist/keys/key-resolution.d.ts +9 -0
- package/dist/keys/key-resolution.js +62 -55
- package/dist/keys/key-status.d.ts +2 -0
- package/dist/keys/key-status.js +9 -16
- package/dist/observer/artifact.d.ts +1 -1
- package/dist/observer/http.d.ts +7 -0
- package/dist/observer/http.js +19 -1
- package/dist/observer/notes-route.d.ts +0 -1
- package/dist/observer/notes-route.js +24 -44
- package/dist/observer/pinned-files.js +4 -19
- package/dist/observer/render.js +6 -4
- package/dist/observer/run-routes.js +13 -28
- package/dist/observer/serve.js +2 -1
- package/dist/observer-app.html +7 -7
- package/dist/routes/computer-use/bundle-parts.d.ts +4 -4
- package/dist/routes/computer-use/bundle-parts.js +2 -2
- package/dist/routes/computer-use/bundle.d.ts +4 -5
- package/dist/routes/computer-use/bundle.js +1 -3
- package/dist/routes/computer-use/fanout-records.js +10 -11
- package/dist/routes/computer-use/live-phase.d.ts +1 -1
- package/dist/routes/computer-use/live-phase.js +0 -1
- package/dist/routes/computer-use/participant-execution.d.ts +3 -2
- package/dist/routes/computer-use/participant-runs.d.ts +6 -2
- package/dist/routes/computer-use/participant-runs.js +9 -5
- package/dist/routes/computer-use/plan.d.ts +3 -0
- package/dist/routes/computer-use/plan.js +14 -11
- package/dist/routes/computer-use/rerun-selection.js +2 -2
- package/dist/routes/computer-use/result.js +3 -6
- package/dist/routes/computer-use/setup.d.ts +4 -6
- package/dist/routes/computer-use/setup.js +2 -6
- package/dist/routes/computer-use/single-bundle.d.ts +4 -5
- package/dist/routes/computer-use/single-bundle.js +22 -25
- package/dist/routes/computer-use/subject-projection.d.ts +4 -8
- package/dist/routes/computer-use/subject-projection.js +9 -31
- package/dist/routes/computer-use/types.d.ts +4 -41
- package/dist/routes/scripted/scenario.js +28 -12
- package/dist/routes/scripted/session-result.js +6 -3
- package/dist/routes/shared-world/bundle-records.js +2 -8
- package/dist/routes/shared-world/participant-specs.d.ts +0 -2
- package/dist/routes/shared-world/participant-specs.js +1 -20
- package/dist/routes/shared-world/plan.d.ts +3 -0
- package/dist/routes/shared-world/plan.js +23 -3
- package/dist/routes/shared-world/setup.js +2 -2
- package/dist/routes/terminal/ledger.d.ts +9 -9
- package/dist/routes/terminal/ledger.js +63 -40
- package/dist/routes/terminal/live-finish.js +7 -10
- package/dist/routes/terminal/plan.d.ts +3 -0
- package/dist/routes/terminal/plan.js +8 -5
- package/dist/routes/terminal/token-usage.d.ts +1 -2
- package/dist/routes/terminal/token-usage.js +4 -6
- package/dist/routes/terminal/types.d.ts +9 -1
- package/dist/run/bundle-shape.js +6 -1
- package/dist/run/bundle.d.ts +5 -3
- package/dist/run/contained-output.d.ts +59 -10
- package/dist/run/contained-output.js +160 -68
- package/dist/run/detail.d.ts +2 -1
- package/dist/run/detail.js +12 -7
- package/dist/run/escape-sequences.d.ts +11 -0
- package/dist/run/escape-sequences.js +93 -0
- package/dist/run/evidence-files.d.ts +8 -1
- package/dist/run/evidence-files.js +24 -18
- package/dist/run/locate.d.ts +32 -3
- package/dist/run/locate.js +45 -40
- package/dist/run/note-files.d.ts +26 -45
- package/dist/run/note-files.js +86 -58
- package/dist/run/note-shape.d.ts +35 -0
- package/dist/run/note-shape.js +20 -0
- package/dist/run/notes.d.ts +3 -14
- package/dist/run/notes.js +23 -75
- package/dist/run/outcomes.d.ts +24 -0
- package/dist/run/outcomes.js +19 -0
- package/dist/run/participant-records.d.ts +2 -0
- package/dist/run/participant-records.js +17 -2
- package/dist/run/paths.d.ts +2 -0
- package/dist/run/paths.js +5 -1
- package/dist/run/preflight-receipts.js +13 -8
- package/dist/run/project.js +7 -4
- package/dist/run/reclaim-outcomes.d.ts +8 -6
- package/dist/run/reclaim-outcomes.js +10 -6
- package/dist/run/reclaim.d.ts +5 -0
- package/dist/run/reclaim.js +43 -16
- package/dist/run/run-clock.d.ts +50 -0
- package/dist/run/run-clock.js +66 -0
- package/dist/run/run.js +5 -2
- package/dist/run/sandbox-ids.d.ts +7 -2
- package/dist/run/sandbox-ids.js +27 -11
- package/dist/run/secrets.d.ts +2 -1
- package/dist/run/secrets.js +18 -22
- package/dist/run/stored-runs.js +14 -7
- package/dist/run/transient-comms-secrets.d.ts +6 -5
- package/dist/run/transient-comms-secrets.js +23 -39
- package/dist/run-study.js +22 -13
- package/dist/study/adapter-scorer-loader.js +6 -4
- package/dist/study/discover.js +9 -6
- package/dist/study/init-paths.js +7 -5
- package/dist/study/persona-resolve.d.ts +2 -2
- package/dist/study/persona-resolve.js +9 -5
- package/dist/study/plan-types.d.ts +2 -2
- package/dist/study/plan.d.ts +5 -3
- package/dist/study/plan.js +18 -9
- package/dist/study/preflight-probes.js +11 -3
- package/dist/study/preflight.js +9 -5
- package/dist/study/summary.d.ts +12 -7
- package/dist/study/summary.js +24 -24
- package/dist/study/warnings.d.ts +19 -12
- package/dist/study/warnings.js +39 -17
- package/dist/substrates/e2b/connection.d.ts +12 -0
- package/dist/substrates/e2b/connection.js +35 -0
- package/dist/substrates/e2b/desktop-executor.d.ts +1 -3
- package/dist/substrates/e2b/desktop-executor.js +42 -9
- package/dist/substrates/e2b/lifetime.d.ts +24 -4
- package/dist/substrates/e2b/lifetime.js +41 -4
- package/dist/substrates/e2b/sandbox.d.ts +4 -3
- package/dist/substrates/e2b/sandbox.js +13 -18
- package/dist/substrates/e2b/sdk.d.ts +20 -9
- package/dist/substrates/local/capacity.d.ts +18 -2
- package/dist/substrates/local/capacity.js +21 -2
- package/dist/substrates/local/runtime-host.js +21 -14
- package/dist/substrates/local/runtime.js +3 -11
- package/dist/tui/contract.d.ts +30 -5
- package/dist/tui/contract.js +4 -1
- package/dist/tui-app.js +149 -147
- package/dist/verify/artifacts.d.ts +9 -2
- package/dist/verify/artifacts.js +37 -13
- package/dist/verify/liveness.js +19 -5
- package/dist/verify/verify.js +54 -27
- package/docs/architecture/actor-contract.md +3 -3
- package/docs/architecture/guest-desktop.md +7 -2
- package/docs/architecture/restricted-codex-analysis.md +1 -1
- package/docs/architecture/state-driven-executor.md +5 -3
- package/docs/contracts/run-bundle.md +25 -13
- package/docs/contracts/schemas.md +38 -29
- package/docs/contracts/study-analysis.md +22 -5
- package/docs/product/automatic-analysis.md +7 -1
- package/package.json +8 -7
- package/skills/humanish/SKILL.md +1 -2
|
@@ -8,7 +8,7 @@ import { actorRegistry, isTerminalActorDescriptor } from "../../actors/registry.
|
|
|
8
8
|
import { isNonEmpty, planBase } from "../../study/plan-base.js";
|
|
9
9
|
import { studyUrlCredentialReason } from "../../study/url-credentials.js";
|
|
10
10
|
import { desktopMediaValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
|
|
11
|
-
import {
|
|
11
|
+
import { ceilingAdvice } from "../../substrates/e2b/lifetime.js";
|
|
12
12
|
import { NODE_BOOTSTRAP_TIMEOUT_MS } from "../../subject/node-bootstrap.js";
|
|
13
13
|
import { terminalSandboxTimeoutMs } from "./lifetime.js";
|
|
14
14
|
import { isExactRuntimeVersion, TERMINAL_RUNTIME_VERSION_TIMEOUT_MS } from "./runtime.js";
|
|
@@ -105,13 +105,16 @@ export function planTerminalStudy(config, input) {
|
|
|
105
105
|
if (maxUsd > 0 && input.hasCostProbe !== true)
|
|
106
106
|
return refuse("HUMANISH_TERMINAL_UNPRICED_CAP", `caps.maxUsd=${maxUsd} cannot be enforced: Codex reports no provider cost for the participant (its token cost is only estimated after the run) and no product, media or payment spend is measured, so a positive dollar cap can never trip. Set caps.maxUsd to 0 and bound the run with caps.maxMinutes, the codex command's wall-clock kill. No sandbox was created and the runtime key was not used.`, descriptor.id);
|
|
107
107
|
// The sandbox's timeout covers the steps before the codex command, maxMinutes and the teardown
|
|
108
|
-
// buffer, and E2B refuses a sandbox
|
|
109
|
-
// after the plan prints, from a provider 400 that names neither knob.
|
|
108
|
+
// buffer, and E2B refuses a sandbox past the plan's ceiling. Refuse here, with the arithmetic,
|
|
109
|
+
// rather than after the plan prints, from a provider 400 that names neither knob.
|
|
110
|
+
const ceiling = input.sandboxCeiling;
|
|
111
|
+
if (!ceiling.ok)
|
|
112
|
+
return refuse("HUMANISH_TERMINAL_CAPS_INVALID", ceiling.message, descriptor.id);
|
|
110
113
|
const productInstall = shared.product.install !== undefined;
|
|
111
114
|
const sandboxTimeoutMs = terminalSandboxTimeoutMs({ maxMinutes, productInstall });
|
|
112
|
-
if (sandboxTimeoutMs >
|
|
115
|
+
if (sandboxTimeoutMs > ceiling.ms) {
|
|
113
116
|
const headroomMinutes = (sandboxTimeoutMs - maxMinutes * 60_000) / 60_000;
|
|
114
|
-
return refuse("HUMANISH_TERMINAL_CAPS_INVALID", `caps.maxMinutes ${maxMinutes} derives a ${sandboxTimeoutMs / 60_000}m sandbox deadline, and a sandbox may not live longer than ${
|
|
117
|
+
return refuse("HUMANISH_TERMINAL_CAPS_INVALID", `caps.maxMinutes ${maxMinutes} derives a ${sandboxTimeoutMs / 60_000}m sandbox deadline, and a sandbox may not live longer than ${ceiling.ms / 60_000}m. The deadline is maxMinutes plus ${headroomMinutes}m: the Node bootstrap (${NODE_BOOTSTRAP_TIMEOUT_MS / 60_000}m), the runtime version check (${TERMINAL_RUNTIME_VERSION_TIMEOUT_MS / 60_000}m)${productInstall ? `, the product setup (${PRODUCT_SETUP_TIMEOUT_MS / 60_000}m)` : ""} and the teardown buffer (${TERMINAL_SANDBOX_TIMEOUT_BUFFER_MS / 60_000}m). Lower caps.maxMinutes to at most ${ceiling.ms / 60_000 - headroomMinutes}.${ceilingAdvice(sandboxTimeoutMs)} No sandbox was created and the runtime key was not used.`, descriptor.id);
|
|
115
118
|
}
|
|
116
119
|
return {
|
|
117
120
|
ok: true,
|
|
@@ -13,6 +13,5 @@ export declare function terminalUsageRecords(lines: string): string[];
|
|
|
13
13
|
*/
|
|
14
14
|
export declare function parseTerminalTokenUsage(transcript: string): ActorTokenUsage | undefined;
|
|
15
15
|
/** Human-readable token statement for the cost ledger and the no-spend proof. Reports what was
|
|
16
|
-
* counted
|
|
17
|
-
* "nothing was consumed". */
|
|
16
|
+
* counted, so a reader never reads "no charge recorded" as "nothing was consumed". */
|
|
18
17
|
export declare function describeTokenUsage(usage: ActorTokenUsage): string;
|
|
@@ -9,10 +9,9 @@
|
|
|
9
9
|
// {"type":"turn.completed","usage":{"input_tokens":201536,"cached_input_tokens":170558,
|
|
10
10
|
// "cache_write_input_tokens":30951,"output_tokens":2283,"reasoning_output_tokens":902}}
|
|
11
11
|
//
|
|
12
|
-
// So the route
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// tokens-known-rate-unknown rather than a guessed dollar figure.
|
|
12
|
+
// So the route records a measured token fact whether or not a rate prices it. Rates stay out of this
|
|
13
|
+
// module: the route prices these counts once, from the model it passed to Codex, with
|
|
14
|
+
// src/run/pricing.ts (live-finish.ts), and the ledger and run.json both read that estimate.
|
|
16
15
|
// Bounded to a single line on purpose. `[^}]*` would run past a truncated record's missing brace
|
|
17
16
|
// and swallow the next, valid record's body, silently dropping a real turn from the count.
|
|
18
17
|
const USAGE_RE = /"type"\s*:\s*"turn\.completed"\s*,\s*"usage"\s*:\s*(\{[^}\n]*\})/g;
|
|
@@ -81,8 +80,7 @@ export function parseTerminalTokenUsage(transcript) {
|
|
|
81
80
|
};
|
|
82
81
|
}
|
|
83
82
|
/** Human-readable token statement for the cost ledger and the no-spend proof. Reports what was
|
|
84
|
-
* counted
|
|
85
|
-
* "nothing was consumed". */
|
|
83
|
+
* counted, so a reader never reads "no charge recorded" as "nothing was consumed". */
|
|
86
84
|
export function describeTokenUsage(usage) {
|
|
87
85
|
const parts = [];
|
|
88
86
|
if (usage.input !== undefined)
|
|
@@ -232,6 +232,11 @@ export interface InterventionRecord {
|
|
|
232
232
|
export interface CostLine {
|
|
233
233
|
/** known zero (0) | not measured (null). The key is always present when the line is applicable. */
|
|
234
234
|
usd: number | null;
|
|
235
|
+
/** The provider line's price for its counted tokens, from the trace's `estimatedCost` (the
|
|
236
|
+
* figure run.json's model-tokens line gives), when no charge was measured. An estimate from a
|
|
237
|
+
* rate table: `usd` stays null, and neither `knownTotalUsd` nor caps.maxUsd counts it. Present
|
|
238
|
+
* only with source `estimated-token-usage`. */
|
|
239
|
+
estimatedUsd?: number;
|
|
235
240
|
/** Optional billable-unit count, same discipline: a known count, or null = not measured. */
|
|
236
241
|
count?: number | null;
|
|
237
242
|
/** How this line's value was established (provenance for the verifier + the human reviewer). */
|
|
@@ -239,7 +244,10 @@ export interface CostLine {
|
|
|
239
244
|
/** Tokens were counted but no rate could price them, so `usd` stays null while the note
|
|
240
245
|
* carries the measured token totals. Distinct from "unmeasured", which means no
|
|
241
246
|
* signal at all. */
|
|
242
|
-
| "unpriced-token-usage"
|
|
247
|
+
| "unpriced-token-usage"
|
|
248
|
+
/** Tokens were counted and priced from a rate table: `estimatedUsd` holds the estimate and
|
|
249
|
+
* `usd` stays null, since no charge was measured. */
|
|
250
|
+
| "estimated-token-usage";
|
|
243
251
|
/** A short, public-safe note (never a secret value). */
|
|
244
252
|
note: string;
|
|
245
253
|
}
|
package/dist/run/bundle-shape.js
CHANGED
|
@@ -213,10 +213,15 @@ export const ARCHIVE_SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
|
213
213
|
function isRunSubjectProvenance(value) {
|
|
214
214
|
if (!isRecord(value))
|
|
215
215
|
return false;
|
|
216
|
-
if (value.source !== "clone" &&
|
|
216
|
+
if (value.source !== "clone" &&
|
|
217
|
+
value.source !== "app-url" &&
|
|
218
|
+
value.source !== "local-tree" &&
|
|
219
|
+
value.source !== "desktop-cli")
|
|
217
220
|
return false;
|
|
218
221
|
if (value.repo !== undefined && typeof value.repo !== "string")
|
|
219
222
|
return false;
|
|
223
|
+
if (value.product !== undefined && typeof value.product !== "string")
|
|
224
|
+
return false;
|
|
220
225
|
if (value.commit !== undefined && typeof value.commit !== "string")
|
|
221
226
|
return false;
|
|
222
227
|
if (value.archiveSha256 !== undefined &&
|
package/dist/run/bundle.d.ts
CHANGED
|
@@ -158,9 +158,11 @@ export interface RunSubjectStateStepRecord {
|
|
|
158
158
|
* pre-existing bundles).
|
|
159
159
|
*/
|
|
160
160
|
export interface RunSubjectProvenance {
|
|
161
|
-
source: "clone" | "app-url" | "local-tree";
|
|
161
|
+
source: "clone" | "app-url" | "local-tree" | "desktop-cli";
|
|
162
162
|
/** Clone-route only. Honors policies.redactRepos exactly as the provenance event does. */
|
|
163
163
|
repo?: string;
|
|
164
|
+
/** Desktop-cli only: the product the study declares in `subject.product.name`. */
|
|
165
|
+
product?: string;
|
|
164
166
|
/** Clone-route: the cloned commit SHA. Local-tree route: the host-side HEAD at pack time,
|
|
165
167
|
* when the packed root was a git work tree. */
|
|
166
168
|
commit?: string;
|
|
@@ -184,8 +186,8 @@ export interface RunSubjectProvenance {
|
|
|
184
186
|
* an external DB is still unpinned overall).
|
|
185
187
|
* declared-not-run: steps declared but not (all) executed ok: dry-run contract bundles
|
|
186
188
|
* and failed live provisioning.
|
|
187
|
-
* undeclared: no subject.state block (stateless apps, app-url subjects),
|
|
188
|
-
* "absence declared" marker that provenance requires when there is none.
|
|
189
|
+
* undeclared: no subject.state block (stateless apps, app-url and desktop-cli subjects),
|
|
190
|
+
* the explicit "absence declared" marker that provenance requires when there is none.
|
|
189
191
|
* external-public: an operator-declared, operator-owned public deployment used
|
|
190
192
|
* directly as the shared plane; humanish neither provisioned nor seeded it (no getHost, no
|
|
191
193
|
* clone, no in-sandbox filesystem). It is not "seeded" (nothing was seeded) and not "unpinned" (this is
|
|
@@ -39,19 +39,68 @@ export declare function prepareContainedOutputDirectoryRoot(rootInput: PreparedO
|
|
|
39
39
|
export declare function prepareContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<string>;
|
|
40
40
|
export declare function writeContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string | Uint8Array, encoding?: BufferEncoding): Promise<void>;
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
42
|
+
* Creates a new file inside the root under its final name, refusing with EEXIST when that name
|
|
43
|
+
* exists, so it never replaces a file. The file is opened with O_EXCL and O_NOFOLLOW, filled in one
|
|
44
|
+
* write and synced, so a reader may briefly see it empty or short. It never has a temporary name or
|
|
45
|
+
* a second link, so the run directory passes its single-link check at every moment.
|
|
46
|
+
* writeContainedOutputFile renames a finished file into place instead, replacing what is there.
|
|
46
47
|
*/
|
|
47
|
-
export declare function
|
|
48
|
-
/** Read one regular file only when both lexical and physical paths stay in root. */
|
|
49
|
-
export declare function readContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<Buffer | null>;
|
|
48
|
+
export declare function createContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string): Promise<void>;
|
|
50
49
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* A file in a run directory or a preflight journal. verify reads every run file whole to scan it,
|
|
51
|
+
* and a frame whole to check it, so this covers the largest screenshot src/evidence/image.ts
|
|
52
|
+
* accepts (32 MiB). Every other reader of a run file uses the same limit, so no command reads a
|
|
53
|
+
* run file that verify could not scan.
|
|
53
54
|
*/
|
|
54
|
-
export declare
|
|
55
|
+
export declare const RUN_ARTIFACT_MAX_BYTES: number;
|
|
56
|
+
/** `.humanish/runs/latest.json`: a run id and a path. */
|
|
57
|
+
export declare const LATEST_POINTER_MAX_BYTES: number;
|
|
58
|
+
/** A file a person writes in the project: study, persona and scenario YAML, package.json, agent instructions. */
|
|
59
|
+
export declare const PROJECT_FILE_MAX_BYTES: number;
|
|
60
|
+
/**
|
|
61
|
+
* Why a contained read did not read a file that is there. `too-large`: it holds more than the
|
|
62
|
+
* limit. `changed`: it, its folder or the root changed while it was opened or read, which
|
|
63
|
+
* includes growing past the limit. `directory`: a folder is at the path. `not-regular`: a link, a
|
|
64
|
+
* hard link or a special file. `unsafe-path`: the path leaves the root or passes through a link.
|
|
65
|
+
* `unreadable`: the system refused the open or the read.
|
|
66
|
+
*/
|
|
67
|
+
export type ContainedRefusalReason = "too-large" | "changed" | "directory" | "not-regular" | "unsafe-path" | "unreadable";
|
|
68
|
+
/** A file that is there and was not read: why, and the most the read would have taken. */
|
|
69
|
+
export interface ContainedRefusal {
|
|
70
|
+
readonly status: "refused";
|
|
71
|
+
readonly reason: ContainedRefusalReason;
|
|
72
|
+
readonly limit: number;
|
|
73
|
+
}
|
|
74
|
+
/** What a contained read found: the bytes, nothing at the path, or a file it refused. */
|
|
75
|
+
export type ContainedRead = {
|
|
76
|
+
readonly status: "read";
|
|
77
|
+
readonly bytes: Buffer;
|
|
78
|
+
} | {
|
|
79
|
+
readonly status: "missing";
|
|
80
|
+
} | ContainedRefusal;
|
|
81
|
+
/** `relativePath` and why it was refused, as a clause: "x.yaml is larger than 4194304 bytes, ...". */
|
|
82
|
+
export declare function refusalText(relativePath: string, refusal: ContainedRefusal): string;
|
|
83
|
+
/** A refused contained read, for a caller that stops on one. The message names the file. */
|
|
84
|
+
export declare class ContainedReadRefusedError extends Error {
|
|
85
|
+
readonly relativePath: string;
|
|
86
|
+
readonly refusal: ContainedRefusal;
|
|
87
|
+
constructor(relativePath: string, refusal: ContainedRefusal);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Read one regular file only when both lexical and physical paths stay in root and it holds at
|
|
91
|
+
* most `maxBytes`. A larger file is refused without being read, and one that grows past
|
|
92
|
+
* `maxBytes` while it is read is refused after at most `maxBytes + 1` bytes. `missing` means
|
|
93
|
+
* nothing is at the path, or a folder on it is missing.
|
|
94
|
+
*/
|
|
95
|
+
export declare function readContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string, maxBytes: number): Promise<ContainedRead>;
|
|
96
|
+
/**
|
|
97
|
+
* At most `maxBytes` from the start of an opened file, or null when it holds more. It reads by
|
|
98
|
+
* position, in chunks of up to 64 KiB, and stops one byte past the limit, so a file that grew
|
|
99
|
+
* after it was opened is never read whole.
|
|
100
|
+
*/
|
|
101
|
+
export declare function readOpenedAtMost(handle: FileHandle, maxBytes: number): Promise<Buffer | null>;
|
|
55
102
|
/** The caller owns this checked descriptor and must close it after reading/streaming. */
|
|
56
103
|
export declare function openContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<FileHandle | null>;
|
|
57
104
|
export declare function assertSafeOutputPathSegment(value: string, label?: string): void;
|
|
105
|
+
/** A non-empty relative path with no empty, `.` or `..` segment: the names a contained read accepts. */
|
|
106
|
+
export declare function isSafeRelativeFilePath(value: string): boolean;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { assertDirectoryIdentity, assertRegularFileOrMissing, isPathInside, prepareHumanishStorageDirectory, resolveExistingHumanishStorageDirectory, validatePreparedRunArtifactPaths, validatePreparedRunRootIdentity, } from "./paths.js";
|
|
@@ -124,106 +124,188 @@ export async function writeContainedOutputFile(rootInput, relativePath, data, en
|
|
|
124
124
|
});
|
|
125
125
|
}
|
|
126
126
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
127
|
+
* Creates a new file inside the root under its final name, refusing with EEXIST when that name
|
|
128
|
+
* exists, so it never replaces a file. The file is opened with O_EXCL and O_NOFOLLOW, filled in one
|
|
129
|
+
* write and synced, so a reader may briefly see it empty or short. It never has a temporary name or
|
|
130
|
+
* a second link, so the run directory passes its single-link check at every moment.
|
|
131
|
+
* writeContainedOutputFile renames a finished file into place instead, replacing what is there.
|
|
131
132
|
*/
|
|
132
|
-
export async function
|
|
133
|
+
export async function createContainedOutputFile(rootInput, relativePath, data) {
|
|
133
134
|
const filePath = await prepareContainedOutputFile(rootInput, relativePath);
|
|
134
135
|
const root = await resolveOutputRoot(rootInput);
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
throw new Error("Output root changed after it was prepared.");
|
|
139
|
-
}
|
|
140
|
-
await assertContainedDirectoryChain(root, parent);
|
|
141
|
-
};
|
|
142
|
-
await revalidate();
|
|
143
|
-
const temporary = path.join(parent, `.humanish-write-${process.pid}-${randomUUID()}.tmp`);
|
|
144
|
-
let handle;
|
|
136
|
+
await assertContainedDirectoryChain(root, path.dirname(filePath));
|
|
137
|
+
const bytes = Buffer.from(data, "utf8");
|
|
138
|
+
const handle = await open(filePath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
145
139
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
140
|
+
const { bytesWritten } = await handle.write(bytes, 0, bytes.length, 0);
|
|
141
|
+
if (bytesWritten !== bytes.length)
|
|
142
|
+
throw new Error("The file was not written in full.");
|
|
148
143
|
await handle.sync();
|
|
149
|
-
await handle.close();
|
|
150
|
-
handle = undefined;
|
|
151
|
-
await revalidate();
|
|
152
|
-
await link(temporary, filePath);
|
|
153
144
|
}
|
|
154
|
-
|
|
155
|
-
await handle
|
|
156
|
-
|
|
145
|
+
catch (error) {
|
|
146
|
+
await handle.close().catch(() => undefined);
|
|
147
|
+
// O_EXCL created this name here, so removing it removes only this file.
|
|
148
|
+
await unlink(filePath).catch(() => undefined);
|
|
149
|
+
throw error;
|
|
157
150
|
}
|
|
158
|
-
await
|
|
151
|
+
await handle.close();
|
|
159
152
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
153
|
+
// The most humanish reads of one file of each kind. A larger file is refused unread.
|
|
154
|
+
/**
|
|
155
|
+
* A file in a run directory or a preflight journal. verify reads every run file whole to scan it,
|
|
156
|
+
* and a frame whole to check it, so this covers the largest screenshot src/evidence/image.ts
|
|
157
|
+
* accepts (32 MiB). Every other reader of a run file uses the same limit, so no command reads a
|
|
158
|
+
* run file that verify could not scan.
|
|
159
|
+
*/
|
|
160
|
+
export const RUN_ARTIFACT_MAX_BYTES = 32 * 1024 * 1024;
|
|
161
|
+
/** `.humanish/runs/latest.json`: a run id and a path. */
|
|
162
|
+
export const LATEST_POINTER_MAX_BYTES = 64 * 1024;
|
|
163
|
+
/** A file a person writes in the project: study, persona and scenario YAML, package.json, agent instructions. */
|
|
164
|
+
export const PROJECT_FILE_MAX_BYTES = 4 * 1024 * 1024;
|
|
165
|
+
/** `relativePath` and why it was refused, as a clause: "x.yaml is larger than 4194304 bytes, ...". */
|
|
166
|
+
export function refusalText(relativePath, refusal) {
|
|
167
|
+
switch (refusal.reason) {
|
|
168
|
+
case "too-large":
|
|
169
|
+
return `${relativePath} is larger than ${refusal.limit} bytes, the most humanish reads of it`;
|
|
170
|
+
case "changed":
|
|
171
|
+
return `${relativePath} changed while humanish read it`;
|
|
172
|
+
case "directory":
|
|
173
|
+
return `${relativePath} is a folder`;
|
|
174
|
+
case "not-regular":
|
|
175
|
+
return `${relativePath} is not a single-link regular file`;
|
|
176
|
+
case "unsafe-path":
|
|
177
|
+
return `${relativePath} leaves its folder or passes through a link`;
|
|
178
|
+
case "unreadable":
|
|
179
|
+
return `${relativePath} could not be read`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** A refused contained read, for a caller that stops on one. The message names the file. */
|
|
183
|
+
export class ContainedReadRefusedError extends Error {
|
|
184
|
+
relativePath;
|
|
185
|
+
refusal;
|
|
186
|
+
constructor(relativePath, refusal) {
|
|
187
|
+
super(`${refusalText(relativePath, refusal)}.`);
|
|
188
|
+
this.relativePath = relativePath;
|
|
189
|
+
this.refusal = refusal;
|
|
190
|
+
this.name = "ContainedReadRefusedError";
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Read one regular file only when both lexical and physical paths stay in root and it holds at
|
|
195
|
+
* most `maxBytes`. A larger file is refused without being read, and one that grows past
|
|
196
|
+
* `maxBytes` while it is read is refused after at most `maxBytes + 1` bytes. `missing` means
|
|
197
|
+
* nothing is at the path, or a folder on it is missing.
|
|
198
|
+
*/
|
|
199
|
+
export async function readContainedRegularFile(rootInput, relativePath, maxBytes) {
|
|
200
|
+
const opened = await openContained(rootInput, relativePath);
|
|
201
|
+
if (opened.status === "missing")
|
|
202
|
+
return opened;
|
|
203
|
+
const refused = (reason) => ({
|
|
204
|
+
status: "refused",
|
|
205
|
+
reason,
|
|
206
|
+
limit: maxBytes,
|
|
207
|
+
});
|
|
208
|
+
if (opened.status === "refused")
|
|
209
|
+
return refused(opened.reason);
|
|
210
|
+
const { handle } = opened;
|
|
165
211
|
try {
|
|
166
|
-
|
|
212
|
+
if ((await handle.stat()).size > maxBytes)
|
|
213
|
+
return refused("too-large");
|
|
214
|
+
const bytes = await readOpenedAtMost(handle, maxBytes);
|
|
215
|
+
return bytes === null ? refused("changed") : { status: "read", bytes };
|
|
167
216
|
}
|
|
168
217
|
catch {
|
|
169
|
-
return
|
|
218
|
+
return refused("unreadable");
|
|
170
219
|
}
|
|
171
220
|
finally {
|
|
172
221
|
await handle.close().catch(() => { });
|
|
173
222
|
}
|
|
174
223
|
}
|
|
175
224
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
225
|
+
* At most `maxBytes` from the start of an opened file, or null when it holds more. It reads by
|
|
226
|
+
* position, in chunks of up to 64 KiB, and stops one byte past the limit, so a file that grew
|
|
227
|
+
* after it was opened is never read whole.
|
|
178
228
|
*/
|
|
179
|
-
export async function
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
229
|
+
export async function readOpenedAtMost(handle, maxBytes) {
|
|
230
|
+
const chunks = [];
|
|
231
|
+
let total = 0;
|
|
232
|
+
for (;;) {
|
|
233
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
|
|
234
|
+
const { bytesRead } = await handle.read(chunk, 0, chunk.length, total);
|
|
235
|
+
if (bytesRead === 0)
|
|
236
|
+
return Buffer.concat(chunks, total);
|
|
237
|
+
total += bytesRead;
|
|
238
|
+
if (total > maxBytes)
|
|
239
|
+
return null;
|
|
240
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
188
241
|
}
|
|
189
242
|
}
|
|
190
243
|
/** The caller owns this checked descriptor and must close it after reading/streaming. */
|
|
191
244
|
export async function openContainedRegularFile(rootInput, relativePath) {
|
|
192
|
-
|
|
245
|
+
const opened = await openContained(rootInput, relativePath);
|
|
246
|
+
return opened.status === "open" ? opened.handle : null;
|
|
247
|
+
}
|
|
248
|
+
/** The open behind both readers: every check, and what stopped it when one failed. */
|
|
249
|
+
async function openContained(rootInput, relativePath) {
|
|
250
|
+
const refused = (reason) => ({ status: "refused", reason });
|
|
251
|
+
const missingOr = (error, reason) => isNodeError(error) && error.code === "ENOENT"
|
|
252
|
+
? { status: "missing" }
|
|
253
|
+
: isNodeError(error) && (error.code === "EACCES" || error.code === "EPERM")
|
|
254
|
+
? refused("unreadable")
|
|
255
|
+
: refused(reason);
|
|
193
256
|
try {
|
|
194
257
|
assertSafeRelativeOutputPath(relativePath, false);
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return refused("unsafe-path");
|
|
261
|
+
}
|
|
262
|
+
let root;
|
|
263
|
+
try {
|
|
264
|
+
root = await resolveOutputRoot(rootInput);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return refused("changed");
|
|
268
|
+
}
|
|
269
|
+
const candidate = path.resolve(root, normalizeRelativeOutputPath(relativePath));
|
|
270
|
+
if (!isPathInside(root, candidate) || candidate === root)
|
|
271
|
+
return refused("unsafe-path");
|
|
272
|
+
let before;
|
|
273
|
+
try {
|
|
200
274
|
await assertContainedDirectoryChain(root, path.dirname(candidate));
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
275
|
+
before = await lstat(candidate, { bigint: true });
|
|
276
|
+
}
|
|
277
|
+
catch (error) {
|
|
278
|
+
return missingOr(error, "unsafe-path");
|
|
279
|
+
}
|
|
280
|
+
if (before.isDirectory())
|
|
281
|
+
return refused("directory");
|
|
282
|
+
if (before.isSymbolicLink() || !before.isFile() || before.nlink > 1n)
|
|
283
|
+
return refused("not-regular");
|
|
284
|
+
let handle;
|
|
285
|
+
try {
|
|
286
|
+
if (!isPathInside(root, await realpath(candidate)))
|
|
287
|
+
return refused("unsafe-path");
|
|
288
|
+
// O_NONBLOCK: a file swapped for a FIFO after the lstat opens at once and fails the fstat
|
|
289
|
+
// below, where a blocking open would wait for a writer.
|
|
290
|
+
handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
return missingOr(error, "changed");
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
210
296
|
const after = await handle.stat({ bigint: true });
|
|
211
297
|
if (!after.isFile() ||
|
|
212
298
|
after.nlink > 1n ||
|
|
213
299
|
after.dev !== before.dev ||
|
|
214
|
-
after.ino !== before.ino
|
|
300
|
+
after.ino !== before.ino ||
|
|
301
|
+
(await resolveOutputRoot(rootInput)) !== root)
|
|
215
302
|
throw new Error("Artifact identity changed.");
|
|
216
|
-
}
|
|
217
|
-
const revalidatedRoot = await resolveOutputRoot(rootInput);
|
|
218
|
-
if (revalidatedRoot !== root) {
|
|
219
|
-
throw new Error("Artifact root changed.");
|
|
220
|
-
}
|
|
221
303
|
await assertContainedDirectoryChain(root, path.dirname(candidate));
|
|
222
|
-
return handle;
|
|
304
|
+
return { status: "open", handle };
|
|
223
305
|
}
|
|
224
306
|
catch {
|
|
225
|
-
await handle
|
|
226
|
-
return
|
|
307
|
+
await handle.close().catch(() => { });
|
|
308
|
+
return refused("changed");
|
|
227
309
|
}
|
|
228
310
|
}
|
|
229
311
|
export function assertSafeOutputPathSegment(value, label = "Output path segment") {
|
|
@@ -236,6 +318,16 @@ export function assertSafeOutputPathSegment(value, label = "Output path segment"
|
|
|
236
318
|
throw new Error(`${label} must be one non-empty path segment.`);
|
|
237
319
|
}
|
|
238
320
|
}
|
|
321
|
+
/** A non-empty relative path with no empty, `.` or `..` segment: the names a contained read accepts. */
|
|
322
|
+
export function isSafeRelativeFilePath(value) {
|
|
323
|
+
try {
|
|
324
|
+
assertSafeRelativeOutputPath(value, false);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
239
331
|
function assertSafeRelativeOutputPath(value, allowEmpty) {
|
|
240
332
|
if ((!allowEmpty && value.length === 0) || value.includes("\0")) {
|
|
241
333
|
throw new Error("Output path must be a non-empty relative path.");
|
package/dist/run/detail.d.ts
CHANGED
|
@@ -50,7 +50,8 @@ export interface RunDetail {
|
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
52
|
* Read one run's participants. Returns null when the run has no readable bundle yet, an ordinary
|
|
53
|
-
* state for a run that has just started, not a failure.
|
|
53
|
+
* state for a run that has just started, not a failure. Throws when run.json is there and cannot
|
|
54
|
+
* be read: larger than RUN_ARTIFACT_MAX_BYTES, a link, or refused by the system.
|
|
54
55
|
*
|
|
55
56
|
* Reads the bundle narrowly rather than through `loadRunBundle`, which applies the strict
|
|
56
57
|
* evidence-of-record guard. That guard is right for verification and wrong here: a mid-run flush is
|
package/dist/run/detail.js
CHANGED
|
@@ -14,7 +14,7 @@ import { resolveRunPath } from "./locate.js";
|
|
|
14
14
|
import { RUN_BUNDLE_FILE } from "./bundle.js";
|
|
15
15
|
import { savedCaption } from "./participant-caption.js";
|
|
16
16
|
import { recordedPersonaId, streamParticipantIdOf } from "./participant-records.js";
|
|
17
|
-
import { readContainedRegularFile } from "./contained-output.js";
|
|
17
|
+
import { ContainedReadRefusedError, readContainedRegularFile, RUN_ARTIFACT_MAX_BYTES, } from "./contained-output.js";
|
|
18
18
|
import { isPathInside, resolvePhysicalCwd } from "./paths.js";
|
|
19
19
|
const RUN_DETAIL_SCHEMA = "humanish.run-detail.v1";
|
|
20
20
|
/**
|
|
@@ -105,7 +105,8 @@ function costOf(trace) {
|
|
|
105
105
|
}
|
|
106
106
|
/**
|
|
107
107
|
* Read one run's participants. Returns null when the run has no readable bundle yet, an ordinary
|
|
108
|
-
* state for a run that has just started, not a failure.
|
|
108
|
+
* state for a run that has just started, not a failure. Throws when run.json is there and cannot
|
|
109
|
+
* be read: larger than RUN_ARTIFACT_MAX_BYTES, a link, or refused by the system.
|
|
109
110
|
*
|
|
110
111
|
* Reads the bundle narrowly rather than through `loadRunBundle`, which applies the strict
|
|
111
112
|
* evidence-of-record guard. That guard is right for verification and wrong here: a mid-run flush is
|
|
@@ -118,13 +119,17 @@ export async function readRunDetail(cwdInput, runId) {
|
|
|
118
119
|
const runPaths = await resolveRunPath(cwd, runId).catch(() => null);
|
|
119
120
|
if (runPaths === null)
|
|
120
121
|
return null;
|
|
122
|
+
// A contained read: a run.json swapped for a symlink or hardlink after resolve is refused.
|
|
123
|
+
const raw = await readContainedRegularFile(runPaths, RUN_BUNDLE_FILE, RUN_ARTIFACT_MAX_BYTES);
|
|
124
|
+
// One that changed while it was read is being written now, as a torn one is below. One that is
|
|
125
|
+
// there and refused for another reason is not a run that has yet to write it.
|
|
126
|
+
if (raw.status === "refused" && raw.reason !== "changed")
|
|
127
|
+
throw new ContainedReadRefusedError(RUN_BUNDLE_FILE, raw);
|
|
128
|
+
if (raw.status !== "read")
|
|
129
|
+
return null;
|
|
121
130
|
let bundle;
|
|
122
131
|
try {
|
|
123
|
-
|
|
124
|
-
const raw = await readContainedRegularFile(runPaths, RUN_BUNDLE_FILE);
|
|
125
|
-
if (raw === null)
|
|
126
|
-
return null;
|
|
127
|
-
const parsed = JSON.parse(raw.toString("utf8"));
|
|
132
|
+
const parsed = JSON.parse(raw.bytes.toString("utf8"));
|
|
128
133
|
if (parsed === null || typeof parsed !== "object")
|
|
129
134
|
return null;
|
|
130
135
|
bundle = parsed;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stretches of the text a value's view drops or decodes, in order and without overlap, each
|
|
3
|
+
* as [start, end): operating-system commands (`ESC ]`), control sequences (`ESC [`) and the
|
|
4
|
+
* two-byte escapes `ESC 7`, `ESC 8`, `ESC =` and `ESC >`, raw or JSON-escaped (`\u001b`), and runs
|
|
5
|
+
* of percent escapes. An operating-system command ends at the first bell character after it or,
|
|
6
|
+
* with none after it, at the last `ESC \` in the text. A JSON-escaped one ends at the first
|
|
7
|
+
* backslash after it, which must start `\u0007` or `\u001b\\`. A start with no end is left as
|
|
8
|
+
* written. One regex of the six shapes finds the same stretches, in time quadratic in a run of
|
|
9
|
+
* unterminated commands; this takes time linear in the text.
|
|
10
|
+
*/
|
|
11
|
+
export declare function escapeSequences(text: string): [number, number][];
|