humanish 0.110.1 → 0.110.3
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/ARCHITECTURE.md +11 -16
- package/CHANGELOG.md +26 -0
- package/CONTEXT.md +1 -1
- package/README.md +8 -7
- package/dist/actors/computer-use/actor.js +2 -2
- package/dist/actors/computer-use/loop/ending.js +1 -1
- package/dist/actors/local-agent/cli.d.ts +6 -0
- package/dist/actors/local-agent/cli.js +8 -0
- package/dist/actors/local-agent/readiness.js +2 -1
- package/dist/actors/registry.js +1 -1
- package/dist/analysis/automatic-completion.js +8 -0
- package/dist/cli/commands/observe.js +18 -4
- package/dist/cli/commands/run-signals.js +13 -4
- package/dist/cli/commands/runs.js +36 -13
- package/dist/cli/commands/setup.js +23 -14
- package/dist/cli/commands/study-route-computer-use.js +7 -3
- package/dist/cli/commands/study-route-open.js +1 -0
- package/dist/cli/commands/study-route-preview.js +1 -0
- package/dist/cli/commands/study-route-shared-world.js +4 -2
- package/dist/cli/commands/study-run.js +9 -19
- package/dist/cli/commands/watch.js +14 -4
- package/dist/cli/doctor.js +9 -6
- package/dist/cli/findings.d.ts +5 -0
- package/dist/cli/findings.js +28 -6
- package/dist/cli/first-run-path.d.ts +27 -7
- package/dist/cli/first-run-path.js +112 -75
- package/dist/cli/invocation.d.ts +7 -0
- package/dist/cli/invocation.js +32 -0
- package/dist/cli/io.d.ts +5 -2
- package/dist/cli/io.js +9 -16
- package/dist/cli/observer-follow.d.ts +24 -0
- package/dist/cli/observer-follow.js +48 -3
- package/dist/cli/orientation.js +16 -14
- package/dist/cli/version.d.ts +1 -0
- package/dist/cli/version.js +11 -0
- package/dist/evidence/encoded-text.d.ts +9 -6
- package/dist/evidence/encoded-text.js +84 -19
- package/dist/evidence/redaction.d.ts +2 -0
- package/dist/evidence/redaction.js +134 -21
- package/dist/feedback/export-bundle.js +2 -2
- package/dist/keys/key-resolution.js +6 -5
- package/dist/observer/exposure.js +1 -1
- package/dist/observer/library.js +3 -2
- package/dist/observer/render.js +2 -1
- package/dist/observer/run-routes.js +8 -1
- package/dist/observer/serve.js +2 -1
- package/dist/routes/computer-use/participant-runs.d.ts +6 -9
- package/dist/routes/computer-use/participant-runs.js +16 -36
- package/dist/routes/computer-use/plan.d.ts +2 -2
- package/dist/routes/computer-use/plan.js +6 -1
- package/dist/routes/computer-use/route.d.ts +5 -18
- package/dist/routes/computer-use/route.js +17 -78
- package/dist/routes/computer-use/setup.d.ts +3 -2
- package/dist/routes/computer-use/types.d.ts +6 -10
- package/dist/routes/scripted/route.d.ts +4 -17
- package/dist/routes/scripted/route.js +10 -42
- package/dist/routes/scripted/types.d.ts +1 -6
- package/dist/routes/shared-world/plan.js +5 -1
- package/dist/routes/shared-world/route.d.ts +5 -11
- package/dist/routes/shared-world/route.js +24 -63
- package/dist/routes/shared-world/types.d.ts +2 -6
- package/dist/routes/terminal/bundle.js +2 -1
- package/dist/routes/terminal/credentials.d.ts +5 -5
- package/dist/routes/terminal/credentials.js +14 -6
- package/dist/routes/terminal/plan.js +8 -0
- package/dist/routes/terminal/route.d.ts +3 -13
- package/dist/routes/terminal/route.js +24 -63
- package/dist/routes/terminal/runtime-auth.d.ts +6 -0
- package/dist/routes/terminal/runtime-auth.js +5 -0
- package/dist/routes/terminal/session.d.ts +2 -3
- package/dist/routes/terminal/session.js +2 -3
- package/dist/routes/terminal/types.d.ts +2 -7
- package/dist/run/dry-run.js +2 -1
- package/dist/run/judge.js +2 -1
- package/dist/run/local-only-files.d.ts +13 -0
- package/dist/run/local-only-files.js +35 -0
- package/dist/run/reclaim.d.ts +5 -0
- package/dist/run/reclaim.js +34 -2
- package/dist/run/route-shell.d.ts +60 -0
- package/dist/run/route-shell.js +51 -0
- package/dist/run/run-not-found.js +3 -2
- package/dist/run/run.d.ts +6 -1
- package/dist/run/run.js +11 -2
- package/dist/run/sandbox-creates.js +5 -3
- package/dist/run/sandbox-receipts.d.ts +6 -5
- package/dist/run/sandbox-receipts.js +12 -7
- package/dist/run/status.d.ts +2 -0
- package/dist/run/status.js +5 -0
- package/dist/study/agents-section.d.ts +21 -0
- package/dist/study/agents-section.js +86 -0
- package/dist/study/composition-rules.js +1 -1
- package/dist/study/doctor.js +5 -4
- package/dist/study/init-templates.d.ts +1 -1
- package/dist/study/init-templates.js +43 -129
- package/dist/study/init.js +32 -35
- package/dist/study/local-browser-host.d.ts +11 -0
- package/dist/study/local-browser-host.js +42 -0
- package/dist/study/parse/actors.js +9 -1
- package/dist/study/parse/subject.js +13 -1
- package/dist/study/parse/url-credentials.d.ts +9 -0
- package/dist/study/parse/url-credentials.js +50 -0
- package/dist/study/preflight-probes.js +2 -1
- package/dist/study/types.d.ts +8 -6
- package/dist/study/url-credentials.d.ts +6 -0
- package/dist/study/url-credentials.js +34 -0
- package/dist/substrates/e2b/sandbox.js +33 -15
- package/dist/substrates/local/firecracker-desktop.js +3 -2
- package/dist/substrates/local/runtime-host.js +5 -4
- package/dist/substrates/local/runtime.js +5 -4
- package/dist/tui/contract.js +5 -4
- package/dist/verify/liveness.js +2 -1
- package/dist/verify/verify.js +2 -1
- package/docs/architecture/actor-contract.md +1 -1
- package/docs/architecture/external-public-shared-world.md +1 -1
- package/docs/architecture/terminal-product-route.md +26 -24
- package/docs/contracts/run-bundle.md +20 -4
- package/docs/contracts/schemas.md +6 -5
- package/docs/contracts/study-analysis.md +5 -2
- package/docs/product/automatic-analysis.md +1 -1
- package/package.json +4 -2
- package/skills/humanish/SKILL.md +22 -18
package/dist/cli/io.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { discoverProviderKeys, type DotenvLoad } from "../keys/key-resolution.js
|
|
|
3
3
|
import { type TelemetryProperties } from "./telemetry.js";
|
|
4
4
|
import type { RunResult } from "../run/results.js";
|
|
5
5
|
export declare const CLI_RESPONSE_SCHEMA = "humanish.cli-response.v1";
|
|
6
|
-
export
|
|
6
|
+
export { CLI_VERSION } from "./version.js";
|
|
7
7
|
export interface CliIo {
|
|
8
8
|
writeOut(text: string): void;
|
|
9
9
|
writeErr(text: string): void;
|
|
@@ -17,7 +17,8 @@ export declare const DOTENV_OPTION_DESCRIPTION = "Load unset variables from this
|
|
|
17
17
|
/**
|
|
18
18
|
* The hidden `--env-file <path>` that `--dotenv` replaced. Node scans the whole argv for
|
|
19
19
|
* `--env-file` before a script runs, and stops only at `--` (src/node_dotenv.cc), so a missing file
|
|
20
|
-
* exits 9 through Node and never reaches humanish. `--dotenv` does not collide.
|
|
20
|
+
* exits 9 through Node and never reaches humanish. `--dotenv` does not collide.
|
|
21
|
+
* Removed in 0.112.0: deprecated in 0.110.0 on 2026-10-03, it stays 30 days and one minor release.
|
|
21
22
|
*/
|
|
22
23
|
export declare function envFileAliasOption(): Option;
|
|
23
24
|
/** The dotenv path a command was given: `--dotenv`, or the `--env-file` alias with its warning. */
|
|
@@ -41,6 +42,8 @@ export interface StudyCommandOptions {
|
|
|
41
42
|
port?: string | undefined;
|
|
42
43
|
rerunFailedFrom?: string | undefined;
|
|
43
44
|
runId?: string | undefined;
|
|
45
|
+
/** watch only: serve and follow the Observer even without an interactive terminal. */
|
|
46
|
+
serve?: boolean | undefined;
|
|
44
47
|
/** Repo-relative path to an adopter scorer module; overrides review.scorer.ref when set. */
|
|
45
48
|
scorer?: string | undefined;
|
|
46
49
|
expose?: boolean | undefined;
|
package/dist/cli/io.js
CHANGED
|
@@ -1,34 +1,26 @@
|
|
|
1
1
|
import { automaticAnalysisSucceeded, defaultAnalysisOverBudget, } from "../analysis/automatic-completion.js";
|
|
2
2
|
import { DEFAULT_ANALYSIS_MAX_COST_USD } from "../analysis/automatic-config.js";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { dirname, resolve } from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
3
|
import { Command, Option } from "commander";
|
|
7
4
|
import { loadEnvFile, recordDotenvNames } from "../keys/env-file.js";
|
|
8
5
|
import { discoverProviderKeys } from "../keys/key-resolution.js";
|
|
9
6
|
import { deriveRunFacts } from "./telemetry.js";
|
|
10
7
|
import { warnAndQueue, withQueuedWarnings } from "./deprecations.js";
|
|
11
8
|
import { forTerminal } from "../routes/terminal/encoding.js";
|
|
9
|
+
import { cli } from "./invocation.js";
|
|
12
10
|
export const CLI_RESPONSE_SCHEMA = "humanish.cli-response.v1";
|
|
13
|
-
|
|
14
|
-
const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
15
|
-
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
16
|
-
return typeof packageJson.version === "string" && packageJson.version.trim()
|
|
17
|
-
? packageJson.version
|
|
18
|
-
: "0.0.0";
|
|
19
|
-
}
|
|
20
|
-
export const CLI_VERSION = readCliVersion();
|
|
11
|
+
export { CLI_VERSION } from "./version.js";
|
|
21
12
|
// One text per shared flag, so the same flag reads the same on every command.
|
|
22
13
|
// tests/cli/shared-options.test.ts walks the program and holds every use to these.
|
|
23
14
|
export const JSON_OPTION_DESCRIPTION = "Print a machine-readable JSON response.";
|
|
24
15
|
export const CWD_OPTION_DESCRIPTION = "Project directory.";
|
|
25
16
|
export const RUN_OPTION_DESCRIPTION = "Run id, or latest.";
|
|
26
17
|
export const DOTENV_OPTION_DESCRIPTION = "Load unset variables from this dotenv file. Values are never printed or saved.";
|
|
27
|
-
const ENV_FILE_RENAMED = "`--env-file` is now `--dotenv`; `--env-file` is removed in 0.
|
|
18
|
+
const ENV_FILE_RENAMED = "`--env-file` is now `--dotenv`; `--env-file` is removed in 0.112.0.";
|
|
28
19
|
/**
|
|
29
20
|
* The hidden `--env-file <path>` that `--dotenv` replaced. Node scans the whole argv for
|
|
30
21
|
* `--env-file` before a script runs, and stops only at `--` (src/node_dotenv.cc), so a missing file
|
|
31
|
-
* exits 9 through Node and never reaches humanish. `--dotenv` does not collide.
|
|
22
|
+
* exits 9 through Node and never reaches humanish. `--dotenv` does not collide.
|
|
23
|
+
* Removed in 0.112.0: deprecated in 0.110.0 on 2026-10-03, it stays 30 days and one minor release.
|
|
32
24
|
*/
|
|
33
25
|
export function envFileAliasOption() {
|
|
34
26
|
return new Option("--env-file <path>").hideHelp().conflicts("dotenv");
|
|
@@ -147,11 +139,12 @@ export function parseParticipantIds(value) {
|
|
|
147
139
|
* command: the run-not-found and missing-study messages do.
|
|
148
140
|
*/
|
|
149
141
|
const NEXT_COMMAND = {
|
|
150
|
-
HUMANISH_STUDY_NOT_FOUND: "
|
|
142
|
+
HUMANISH_STUDY_NOT_FOUND: "study list",
|
|
151
143
|
};
|
|
152
144
|
/** `<command> failed: <message>`, then `code: <CODE>`, then `next: <command>` when one is known. */
|
|
153
145
|
export function formatCliError(command, error) {
|
|
154
|
-
const
|
|
146
|
+
const rest = error.code === undefined ? undefined : NEXT_COMMAND[error.code];
|
|
147
|
+
const next = rest === undefined ? undefined : cli(rest);
|
|
155
148
|
return ([
|
|
156
149
|
`${command} failed: ${error.message}`,
|
|
157
150
|
...(error.code === undefined ? [] : [`code: ${error.code}`]),
|
|
@@ -316,7 +309,7 @@ function overBudgetAnalysisHint(result) {
|
|
|
316
309
|
const estimate = result.automaticAnalysis?.result?.admission?.estimatedCostUsd ?? null;
|
|
317
310
|
const suggested = Math.ceil(estimate ?? DEFAULT_ANALYSIS_MAX_COST_USD + 1);
|
|
318
311
|
const shown = estimate === null ? "" : ` ($${estimate.toFixed(2)})`;
|
|
319
|
-
return `analysis: its estimate${shown} is over the default $${DEFAULT_ANALYSIS_MAX_COST_USD} cap, so no request was sent. To analyze this run:
|
|
312
|
+
return `analysis: its estimate${shown} is over the default $${DEFAULT_ANALYSIS_MAX_COST_USD} cap, so no request was sent. To analyze this run: ${cli(`analyze --run ${result.runId ?? "latest"} --max-cost ${suggested}`)}\n`;
|
|
320
313
|
}
|
|
321
314
|
export function automaticAnalysisEnvelope(result) {
|
|
322
315
|
if (result === null ||
|
|
@@ -8,7 +8,30 @@ export interface ObserverPlan {
|
|
|
8
8
|
/** Open the page: the served URL when following, the static file otherwise. */
|
|
9
9
|
shouldOpen: boolean;
|
|
10
10
|
wantsFollow: boolean;
|
|
11
|
+
/** Set when the command would have followed, but nobody is at a terminal to stop it. */
|
|
12
|
+
unattended?: boolean;
|
|
11
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Whether a person can stop a served Observer: stdin and stdout are terminals, and no agent runner
|
|
16
|
+
* claims the session. An agent's shell or a pipe has nobody to press Ctrl-C, so a server there
|
|
17
|
+
* waits forever. A TTY alone is not enough, since Codex gives the commands it runs a terminal.
|
|
18
|
+
*/
|
|
19
|
+
export declare function personAtTerminal(env?: NodeJS.ProcessEnv): boolean;
|
|
20
|
+
/** Whether `watch` or `observe` serves the Observer and stays attached. */
|
|
21
|
+
export declare function followDecision(args: {
|
|
22
|
+
wantsMachine: boolean;
|
|
23
|
+
detach?: boolean | undefined;
|
|
24
|
+
serve?: boolean | undefined;
|
|
25
|
+
}): {
|
|
26
|
+
follow: boolean;
|
|
27
|
+
unattended: boolean;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The warning a command adds when it printed the Observer and exited because nobody was attached.
|
|
31
|
+
* It names the reason personAtTerminal found: an agent runner's marker, which can sit beside a real
|
|
32
|
+
* terminal, or no terminal at all.
|
|
33
|
+
*/
|
|
34
|
+
export declare function unattendedObserverWarning(command: "watch" | "observe", env?: NodeJS.ProcessEnv): string;
|
|
12
35
|
/**
|
|
13
36
|
* Parse --port and decide open and follow. On an invalid port it writes the failure, sets exit
|
|
14
37
|
* code 2 and returns null, so a caller refuses before starting a run.
|
|
@@ -20,6 +43,7 @@ export declare function planObserver(args: {
|
|
|
20
43
|
io: CliIo;
|
|
21
44
|
open?: boolean | undefined;
|
|
22
45
|
port: string;
|
|
46
|
+
serve?: boolean | undefined;
|
|
23
47
|
}): ObserverPlan | null;
|
|
24
48
|
/** The `open` a run's static Observer render takes: a followed Observer opens its served URL. */
|
|
25
49
|
export declare function staticObserverOpen(plan: ObserverPlan): boolean;
|
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { serveObserver } from "../observer/render.js";
|
|
3
|
+
import { detectAgentSession } from "../actors/agent-session.js";
|
|
3
4
|
import { formatRunHuman, parseObserverPort, wantsJson, writeResult, humanError, } from "./io.js";
|
|
5
|
+
/**
|
|
6
|
+
* Whether a person can stop a served Observer: stdin and stdout are terminals, and no agent runner
|
|
7
|
+
* claims the session. An agent's shell or a pipe has nobody to press Ctrl-C, so a server there
|
|
8
|
+
* waits forever. A TTY alone is not enough, since Codex gives the commands it runs a terminal.
|
|
9
|
+
*/
|
|
10
|
+
export function personAtTerminal(env = process.env) {
|
|
11
|
+
return (process.stdin.isTTY === true &&
|
|
12
|
+
process.stdout.isTTY === true &&
|
|
13
|
+
detectAgentSession(env) === undefined);
|
|
14
|
+
}
|
|
15
|
+
/** Whether `watch` or `observe` serves the Observer and stays attached. */
|
|
16
|
+
export function followDecision(args) {
|
|
17
|
+
if (args.detach === true)
|
|
18
|
+
return { follow: false, unattended: false };
|
|
19
|
+
if (args.serve === true)
|
|
20
|
+
return { follow: true, unattended: false };
|
|
21
|
+
if (args.wantsMachine)
|
|
22
|
+
return { follow: false, unattended: false };
|
|
23
|
+
const attended = personAtTerminal();
|
|
24
|
+
return { follow: attended, unattended: !attended };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The warning a command adds when it printed the Observer and exited because nobody was attached.
|
|
28
|
+
* It names the reason personAtTerminal found: an agent runner's marker, which can sit beside a real
|
|
29
|
+
* terminal, or no terminal at all.
|
|
30
|
+
*/
|
|
31
|
+
export function unattendedObserverWarning(command, env = process.env) {
|
|
32
|
+
const agent = detectAgentSession(env);
|
|
33
|
+
const reason = agent === undefined
|
|
34
|
+
? "No interactive terminal"
|
|
35
|
+
: `An agent environment was detected (${agent.marker} is set, which ${agent.runner} sets)`;
|
|
36
|
+
return `${reason}, so ${command} printed the Observer path and exited instead of serving it until Ctrl-C. Pass --serve to keep serving, or open the file.`;
|
|
37
|
+
}
|
|
4
38
|
/**
|
|
5
39
|
* Parse --port and decide open and follow. On an invalid port it writes the failure, sets exit
|
|
6
40
|
* code 2 and returns null, so a caller refuses before starting a run.
|
|
@@ -28,7 +62,13 @@ export function planObserver(args) {
|
|
|
28
62
|
: args.open === true
|
|
29
63
|
? true
|
|
30
64
|
: !wantsMachine && process.stdout.isTTY === true;
|
|
31
|
-
|
|
65
|
+
const decision = followDecision({ wantsMachine, detach: args.detach, serve: args.serve });
|
|
66
|
+
return {
|
|
67
|
+
port,
|
|
68
|
+
shouldOpen,
|
|
69
|
+
wantsFollow: decision.follow,
|
|
70
|
+
...(decision.unattended ? { unattended: true } : {}),
|
|
71
|
+
};
|
|
32
72
|
}
|
|
33
73
|
/** The `open` a run's static Observer render takes: a followed Observer opens its served URL. */
|
|
34
74
|
export function staticObserverOpen(plan) {
|
|
@@ -43,6 +83,9 @@ export async function showObserver(args) {
|
|
|
43
83
|
server = await serveObserver(rendered, { open: plan.shouldOpen, port: plan.port });
|
|
44
84
|
result = withObserverServer(rendered, server);
|
|
45
85
|
}
|
|
86
|
+
else if (rendered.ok && plan.unattended === true) {
|
|
87
|
+
result = { ...rendered, warnings: [...rendered.warnings, unattendedObserverWarning("watch")] };
|
|
88
|
+
}
|
|
46
89
|
writeResult(args.command, args.io, result, formatObserverHuman);
|
|
47
90
|
args.io.setExitCode(result.ok ? 0 : 2);
|
|
48
91
|
if (result.ok && server) {
|
|
@@ -77,8 +120,6 @@ export function formatObserverHuman(result) {
|
|
|
77
120
|
].join("\n") + "\n");
|
|
78
121
|
}
|
|
79
122
|
export async function followObserver(io, result, server, options = {}) {
|
|
80
|
-
io.writeOut(`watching: ${result.serverUrl ?? result.observerUrl ?? result.observerPath}\n`);
|
|
81
|
-
io.writeOut("watching: press Ctrl-C to stop\n");
|
|
82
123
|
await new Promise((resolve) => {
|
|
83
124
|
const signalTarget = options.signalTarget ?? process;
|
|
84
125
|
const signals = options.signals ?? ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
@@ -120,6 +161,10 @@ export async function followObserver(io, result, server, options = {}) {
|
|
|
120
161
|
handlers.set(signal, handler);
|
|
121
162
|
signalTarget.once(signal, handler);
|
|
122
163
|
}
|
|
164
|
+
// Print the prompt only once the handlers are in place: a Ctrl-C that arrives sooner would
|
|
165
|
+
// take Node's default action and end the process without closing the server.
|
|
166
|
+
io.writeOut(`watching: ${result.serverUrl ?? result.observerUrl ?? result.observerPath}\n`);
|
|
167
|
+
io.writeOut("watching: press Ctrl-C to stop\n");
|
|
123
168
|
});
|
|
124
169
|
}
|
|
125
170
|
export function exitCodeForSignal(signal) {
|
package/dist/cli/orientation.js
CHANGED
|
@@ -17,6 +17,7 @@ import { listRuns } from "../run/stored-runs.js";
|
|
|
17
17
|
import { plural } from "../run/text.js";
|
|
18
18
|
import { supportsLocalBrowser } from "./first-run-path.js";
|
|
19
19
|
import { PRODUCT_SENTENCE } from "./product-sentence.js";
|
|
20
|
+
import { cli } from "./invocation.js";
|
|
20
21
|
export const ORIENTATION_SCHEMA = "humanish.orientation.v1";
|
|
21
22
|
/**
|
|
22
23
|
* Read the project's state. Pure-ish: it only reads, never writes, and never touches the network or
|
|
@@ -69,7 +70,7 @@ function nextCommandsFor(args) {
|
|
|
69
70
|
? []
|
|
70
71
|
: [
|
|
71
72
|
{
|
|
72
|
-
command: "
|
|
73
|
+
command: cli("migrate --dry-run"),
|
|
73
74
|
why: `${plural(v2, "study file")} ${v2 === 1 ? "uses" : "use"} humanish.lab.v2, which humanish no longer reads; this lists the conversion, and humanish migrate writes it`,
|
|
74
75
|
},
|
|
75
76
|
]),
|
|
@@ -77,41 +78,42 @@ function nextCommandsFor(args) {
|
|
|
77
78
|
? []
|
|
78
79
|
: [
|
|
79
80
|
{
|
|
80
|
-
command: "
|
|
81
|
+
command: cli("study list"),
|
|
81
82
|
why: `${plural(moved, "study file")} ${moved === 1 ? "is" : "are"} in a labs/ directory, which humanish no longer reads; this names each one and the studies/ directory to move it to`,
|
|
82
83
|
},
|
|
83
84
|
]),
|
|
84
85
|
];
|
|
85
|
-
// A fix that is already `
|
|
86
|
+
// A fix that is already `study list` replaces the plain suggestion of the same command.
|
|
86
87
|
const named = new Set(fixes.map((fix) => fix.command));
|
|
87
88
|
return [...fixes, ...nextCommandsForStudies(args).filter((next) => !named.has(next.command))];
|
|
88
89
|
}
|
|
89
90
|
function nextCommandsForStudies(args) {
|
|
90
91
|
const dryRun = {
|
|
91
|
-
command: "
|
|
92
|
+
command: cli("run first-run"),
|
|
92
93
|
why: "a dry run: no browser or model runs, no keys, no spend",
|
|
93
94
|
};
|
|
94
95
|
if (!args.initialized) {
|
|
95
96
|
return [
|
|
96
97
|
{
|
|
97
|
-
command: "
|
|
98
|
+
command: cli("init --yes"),
|
|
98
99
|
why: "write starter studies, personas and an AGENTS.md (--dry-run lists every file first)",
|
|
99
100
|
},
|
|
100
101
|
dryRun,
|
|
101
102
|
];
|
|
102
103
|
}
|
|
103
|
-
// The starter live study this host can run
|
|
104
|
-
//
|
|
104
|
+
// The starter live study this host can run: the hosted try-live study first, since it needs no
|
|
105
|
+
// local Docker or VM, then the local one where the host shape supports it. The other starter
|
|
106
|
+
// studies are templates whose subject still names your-org/your-app, so they are never suggested.
|
|
105
107
|
const liveStudy = (supportsLocalBrowser(args.host.platform, args.host.arch)
|
|
106
|
-
? ["
|
|
108
|
+
? ["try-live", "local-browser"]
|
|
107
109
|
: ["try-live"]).find((id) => args.studyIds.includes(id));
|
|
108
110
|
if (!args.hasRun) {
|
|
109
111
|
return [
|
|
110
112
|
dryRun,
|
|
111
113
|
liveStudy === undefined
|
|
112
|
-
? { command: "
|
|
114
|
+
? { command: cli("study list"), why: "see the studies this project declares" }
|
|
113
115
|
: {
|
|
114
|
-
command: `
|
|
116
|
+
command: cli(`doctor --study ${liveStudy}`),
|
|
115
117
|
why: `check what the ${liveStudy} study still needs before a live run`,
|
|
116
118
|
},
|
|
117
119
|
];
|
|
@@ -119,12 +121,12 @@ function nextCommandsForStudies(args) {
|
|
|
119
121
|
return [
|
|
120
122
|
...(liveStudy === undefined
|
|
121
123
|
? []
|
|
122
|
-
: [{ command: `
|
|
124
|
+
: [{ command: cli(`run ${liveStudy}`), why: "run a real participant against an app" }]),
|
|
123
125
|
{
|
|
124
|
-
command: "
|
|
126
|
+
command: cli("verify --run latest"),
|
|
125
127
|
why: "check the last run's evidence and public-safety gates",
|
|
126
128
|
},
|
|
127
|
-
{ command: "
|
|
129
|
+
{ command: cli("observe --run latest"), why: "reopen the last run's Observer" },
|
|
128
130
|
];
|
|
129
131
|
}
|
|
130
132
|
/**
|
|
@@ -151,6 +153,6 @@ export function formatOrientationHuman(state) {
|
|
|
151
153
|
lines.push(` ${next.why}`);
|
|
152
154
|
}
|
|
153
155
|
lines.push("");
|
|
154
|
-
lines.push("
|
|
156
|
+
lines.push(`\`${cli("--help")}\` lists every command.`);
|
|
155
157
|
return `${lines.join("\n")}\n`;
|
|
156
158
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const CLI_VERSION: string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
function readCliVersion() {
|
|
5
|
+
const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
|
|
6
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
7
|
+
return typeof packageJson.version === "string" && packageJson.version.trim()
|
|
8
|
+
? packageJson.version
|
|
9
|
+
: "0.0.0";
|
|
10
|
+
}
|
|
11
|
+
export const CLI_VERSION = readCliVersion();
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
/** Undoes JSON and JS escapes, percent-encoding and HTML character references, one pass each. */
|
|
2
2
|
export declare function decodeEscapes(text: string): string;
|
|
3
|
+
/** How scanEncodedText reads text. */
|
|
4
|
+
export interface EncodedTextScanOptions {
|
|
5
|
+
/** Do not count an encoded binary run as opaque. */
|
|
6
|
+
readonly allowOpaqueBase64?: boolean;
|
|
7
|
+
/** Match secret shapes only, leaving local paths out, as a URL's path may look like one. */
|
|
8
|
+
readonly secretsOnly?: boolean;
|
|
9
|
+
}
|
|
3
10
|
export interface EncodedTextScan {
|
|
4
11
|
/** A secret or private path, in the text or in a decoding of it. */
|
|
5
12
|
readonly sensitive: boolean;
|
|
@@ -12,9 +19,7 @@ export interface EncodedTextScan {
|
|
|
12
19
|
* turn. A run that decodes to an archive, or to other binary past MIN_OPAQUE_BASE64_RUN characters,
|
|
13
20
|
* is opaque unless the caller allows opaque runs.
|
|
14
21
|
*/
|
|
15
|
-
export declare function scanEncodedText(text: string, options?:
|
|
16
|
-
allowOpaqueBase64?: boolean;
|
|
17
|
-
}, depth?: number): EncodedTextScan;
|
|
22
|
+
export declare function scanEncodedText(text: string, options?: EncodedTextScanOptions, depth?: number): EncodedTextScan;
|
|
18
23
|
/**
|
|
19
24
|
* scanEncodedText, cached in this process. The key is the sha256 of the scanned string's UTF-16
|
|
20
25
|
* code units, the options and ENCODED_SCAN_VERSION. Encoding as UTF-16LE keeps every code unit,
|
|
@@ -22,6 +27,4 @@ export declare function scanEncodedText(text: string, options?: {
|
|
|
22
27
|
* scan of the same string would give. Repeated verifies of an unchanged file, as serve admission
|
|
23
28
|
* and the Observer render do, skip the scan.
|
|
24
29
|
*/
|
|
25
|
-
export declare function scanEncodedTextCached(text: string, options?:
|
|
26
|
-
allowOpaqueBase64?: boolean;
|
|
27
|
-
}): EncodedTextScan;
|
|
30
|
+
export declare function scanEncodedTextCached(text: string, options?: EncodedTextScanOptions): EncodedTextScan;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// judge the same decoded text.
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { readPlainText } from "./plain-text.js";
|
|
7
|
-
import { containsSensitive } from "./redaction.js";
|
|
7
|
+
import { containsSecret, containsSensitive } from "./redaction.js";
|
|
8
8
|
// HTML5 named references that stand for printable ASCII. Letters and digits have only numeric
|
|
9
9
|
// references, which decodeEscapes handles.
|
|
10
10
|
const ASCII_ENTITIES = {
|
|
@@ -57,15 +57,13 @@ export function decodeEscapes(text) {
|
|
|
57
57
|
}
|
|
58
58
|
// Sixteen characters hold twelve bytes, enough for the start of a key. Shorter runs are mostly
|
|
59
59
|
// words and identifiers.
|
|
60
|
-
|
|
60
|
+
// Each open-ended repeat is written `[...]{n}[...]*`: V8 runs `{n,}` with a backtrack entry per
|
|
61
|
+
// character and overflows its stack on a run of several megabytes.
|
|
62
|
+
const BASE64_RUN = /[A-Za-z0-9+/]{16}[A-Za-z0-9+/]*={0,2}/g;
|
|
61
63
|
// The URL-safe alphabet swaps `+/` for `-_`. Only runs that use `-` or `_` need this pass.
|
|
62
|
-
const BASE64URL_RUN = /[A-Za-z0-9_-]{16
|
|
63
|
-
// MIME and PEM wrap base64 at 64 or 76 characters a line. The lookbehind starts a match only at the
|
|
64
|
-
// start of a run, and each lookahead-backreference pair takes a line whole, so a long unwrapped run
|
|
65
|
-
// cannot backtrack.
|
|
66
|
-
const WRAPPED_BASE64 = /(?<![A-Za-z0-9+/])(?=([A-Za-z0-9+/]{16,}))\1(?:[ \t]*\r?\n[ \t]*(?=([A-Za-z0-9+/]{4,}))\2)+={0,2}/g;
|
|
64
|
+
const BASE64URL_RUN = /[A-Za-z0-9_-]{16}[A-Za-z0-9_-]*={0,2}/g;
|
|
67
65
|
// Hex-encoded text, at least sixteen bytes of it.
|
|
68
|
-
const HEX_RUN = /
|
|
66
|
+
const HEX_RUN = /[0-9a-f]{32}[0-9a-f]*/gi;
|
|
69
67
|
// A base64 run that decodes to bytes that are neither text nor an archive is unreadable only past
|
|
70
68
|
// this length. Across 173 real run folders surveyed on 2026-10-01, the longest such run outside
|
|
71
69
|
// observer/index.html was a 69-character URL path in a terminal log; base64 of a 94-byte payload,
|
|
@@ -84,6 +82,61 @@ const ARCHIVE_MAGIC = [
|
|
|
84
82
|
[0x28, 0xb5, 0x2f, 0xfd],
|
|
85
83
|
];
|
|
86
84
|
const CONTROL_CHARACTERS = /[\x00-\x08\x0b\x0c\x0e-\x1f]/;
|
|
85
|
+
// A run such as `com/x/<base64>` from a URL path starts with segments that shift the alignment of
|
|
86
|
+
// the base64 after them. A run up to this long is also decoded one slash-separated piece at a time,
|
|
87
|
+
// and from just after each slash in its first SLASH_START_SPAN characters, for base64 that holds a
|
|
88
|
+
// slash itself. Longer runs are images, fonts and archives, not URLs.
|
|
89
|
+
const MAX_SLASH_START_RUN = 4096;
|
|
90
|
+
const SLASH_START_SPAN = 64;
|
|
91
|
+
function isBase64Code(code) {
|
|
92
|
+
return ((code >= 0x30 && code <= 0x39) ||
|
|
93
|
+
(code >= 0x41 && code <= 0x5a) ||
|
|
94
|
+
(code >= 0x61 && code <= 0x7a) ||
|
|
95
|
+
code === 0x2b ||
|
|
96
|
+
code === 0x2f);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Base64 that MIME and PEM wrap at 64 or 76 characters a line: a run of 16 or more characters that
|
|
100
|
+
* ends its line, then the leading run of 4 or more on each following line, for as long as a line
|
|
101
|
+
* holds nothing else. Read line by line, so a long wrapped block costs linear time and no regex
|
|
102
|
+
* stack.
|
|
103
|
+
*/
|
|
104
|
+
function wrappedBase64Runs(text) {
|
|
105
|
+
const runs = [];
|
|
106
|
+
let current = [];
|
|
107
|
+
const flush = () => {
|
|
108
|
+
if (current.length > 1)
|
|
109
|
+
runs.push(current.join(""));
|
|
110
|
+
current = [];
|
|
111
|
+
};
|
|
112
|
+
for (const raw of text.split("\n")) {
|
|
113
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
114
|
+
let end = line.length;
|
|
115
|
+
while (end > 0 && (line[end - 1] === " " || line[end - 1] === "\t"))
|
|
116
|
+
end -= 1;
|
|
117
|
+
let start = 0;
|
|
118
|
+
while (start < end && (line[start] === " " || line[start] === "\t"))
|
|
119
|
+
start += 1;
|
|
120
|
+
if (current.length > 0) {
|
|
121
|
+
let lead = start;
|
|
122
|
+
while (lead < end && isBase64Code(line.charCodeAt(lead)))
|
|
123
|
+
lead += 1;
|
|
124
|
+
if (lead - start >= 4) {
|
|
125
|
+
current.push(line.slice(start, lead));
|
|
126
|
+
if (lead === end)
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
flush();
|
|
130
|
+
}
|
|
131
|
+
let tail = end;
|
|
132
|
+
while (tail > 0 && isBase64Code(line.charCodeAt(tail - 1)))
|
|
133
|
+
tail -= 1;
|
|
134
|
+
if (end - tail >= 16)
|
|
135
|
+
current = [line.slice(tail, end)];
|
|
136
|
+
}
|
|
137
|
+
flush();
|
|
138
|
+
return runs;
|
|
139
|
+
}
|
|
87
140
|
// Base64 inside base64 is read this many levels deep.
|
|
88
141
|
const MAX_DEPTH = 2;
|
|
89
142
|
function startsWithArchive(bytes) {
|
|
@@ -136,9 +189,11 @@ function decodeTransferEscapes(text) {
|
|
|
136
189
|
.replace(/=\r?\n/g, "")
|
|
137
190
|
.replace(/=([0-9A-F]{2})/g, (match, hex) => codePoint(Number.parseInt(hex, 16), match));
|
|
138
191
|
}
|
|
192
|
+
const matcherOf = (options) => options.secretsOnly === true ? containsSecret : containsSensitive;
|
|
139
193
|
const CLEAN = Object.freeze({ sensitive: false, opaque: false });
|
|
140
194
|
const SENSITIVE = Object.freeze({ sensitive: true, opaque: false });
|
|
141
195
|
function inspectDecoded(bytes, run, options, depth) {
|
|
196
|
+
const matches = matcherOf(options);
|
|
142
197
|
if (startsWithArchive(bytes))
|
|
143
198
|
return { sensitive: false, opaque: options.allowOpaqueBase64 !== true };
|
|
144
199
|
const plain = readPlainText(bytes);
|
|
@@ -147,12 +202,10 @@ function inspectDecoded(bytes, run, options, depth) {
|
|
|
147
202
|
if (depth < MAX_DEPTH)
|
|
148
203
|
return scanEncodedText(inner, options, depth + 1);
|
|
149
204
|
const innerDecoded = decodeEscapes(inner);
|
|
150
|
-
return
|
|
151
|
-
? SENSITIVE
|
|
152
|
-
: CLEAN;
|
|
205
|
+
return matches(inner) || (innerDecoded !== inner && matches(innerDecoded)) ? SENSITIVE : CLEAN;
|
|
153
206
|
}
|
|
154
207
|
// A key next to a few binary bytes is still a printable stretch.
|
|
155
|
-
if (
|
|
208
|
+
if (matches(printableStretches(bytes)))
|
|
156
209
|
return SENSITIVE;
|
|
157
210
|
return {
|
|
158
211
|
sensitive: false,
|
|
@@ -168,28 +221,36 @@ function inspectDecoded(bytes, run, options, depth) {
|
|
|
168
221
|
* is opaque unless the caller allows opaque runs.
|
|
169
222
|
*/
|
|
170
223
|
export function scanEncodedText(text, options = {}, depth = 0) {
|
|
224
|
+
const matches = matcherOf(options);
|
|
171
225
|
const decoded = decodeEscapes(text);
|
|
172
226
|
const expanded = decodeTransferEscapes(decoded);
|
|
173
227
|
// A decoding that returns the same string would match the same way, so it is not matched again.
|
|
174
|
-
if (
|
|
175
|
-
(decoded !== text &&
|
|
176
|
-
(expanded !== decoded &&
|
|
228
|
+
if (matches(text) ||
|
|
229
|
+
(decoded !== text && matches(decoded)) ||
|
|
230
|
+
(expanded !== decoded && matches(expanded)))
|
|
177
231
|
return SENSITIVE;
|
|
178
232
|
const runs = [];
|
|
233
|
+
// A piece or a run decoded from a slash only adds findings; its bytes are not judged opaque.
|
|
234
|
+
const slashStarts = [];
|
|
179
235
|
for (const [match] of expanded.matchAll(BASE64_RUN)) {
|
|
180
236
|
// Hex digests and ids decode to noise; the hex pass below reads hex as hex.
|
|
181
237
|
if (/^[0-9a-f]+$/i.test(match.replace(/=+$/, "")))
|
|
182
238
|
continue;
|
|
183
239
|
runs.push({ run: match, bytes: Buffer.from(match, "base64") });
|
|
240
|
+
if (match.length > MAX_SLASH_START_RUN)
|
|
241
|
+
continue;
|
|
242
|
+
for (const piece of match.split("/"))
|
|
243
|
+
if (piece.length >= 16 && piece.length < match.length)
|
|
244
|
+
slashStarts.push(Buffer.from(piece, "base64"));
|
|
245
|
+
for (let slash = match.indexOf("/"); slash !== -1 && slash < SLASH_START_SPAN && match.length - slash > 16; slash = match.indexOf("/", slash + 1))
|
|
246
|
+
slashStarts.push(Buffer.from(match.slice(slash + 1), "base64"));
|
|
184
247
|
}
|
|
185
248
|
for (const [match] of expanded.matchAll(BASE64URL_RUN)) {
|
|
186
249
|
if (/[-_]/.test(match))
|
|
187
250
|
runs.push({ run: match, bytes: Buffer.from(match, "base64url") });
|
|
188
251
|
}
|
|
189
|
-
for (const
|
|
190
|
-
const run = match.replace(/\s+/g, "");
|
|
252
|
+
for (const run of wrappedBase64Runs(expanded))
|
|
191
253
|
runs.push({ run, bytes: Buffer.from(run, "base64") });
|
|
192
|
-
}
|
|
193
254
|
let opaque = false;
|
|
194
255
|
for (const { run, bytes } of runs) {
|
|
195
256
|
const result = inspectDecoded(bytes, run, options, depth);
|
|
@@ -197,6 +258,9 @@ export function scanEncodedText(text, options = {}, depth = 0) {
|
|
|
197
258
|
return result;
|
|
198
259
|
opaque ||= result.opaque;
|
|
199
260
|
}
|
|
261
|
+
for (const bytes of slashStarts)
|
|
262
|
+
if (inspectDecoded(bytes, "", { ...options, allowOpaqueBase64: true }, depth).sensitive)
|
|
263
|
+
return SENSITIVE;
|
|
200
264
|
for (const [match] of expanded.matchAll(HEX_RUN)) {
|
|
201
265
|
const plain = readPlainText(Buffer.from(match, "hex"));
|
|
202
266
|
if (plain.ok && depth < MAX_DEPTH && scanEncodedText(plain.text, options, depth + 1).sensitive)
|
|
@@ -207,7 +271,7 @@ export function scanEncodedText(text, options = {}, depth = 0) {
|
|
|
207
271
|
// Bump when scanEncodedText, decodeEscapes or the sensitive patterns change what they return. The
|
|
208
272
|
// cache lives in one process, so the version guards results across a hot reload or a test that
|
|
209
273
|
// swaps the scanner.
|
|
210
|
-
const ENCODED_SCAN_VERSION =
|
|
274
|
+
const ENCODED_SCAN_VERSION = 2;
|
|
211
275
|
// Distinct files one process verifies in a burst (a run's files, a serve library's runs).
|
|
212
276
|
const SCAN_CACHE_LIMIT = 256;
|
|
213
277
|
const scanCache = new Map();
|
|
@@ -222,6 +286,7 @@ export function scanEncodedTextCached(text, options = {}) {
|
|
|
222
286
|
const key = [
|
|
223
287
|
ENCODED_SCAN_VERSION,
|
|
224
288
|
options.allowOpaqueBase64 === true ? "opaque-allowed" : "opaque-unscanned",
|
|
289
|
+
options.secretsOnly === true ? "secrets-only" : "secrets-and-paths",
|
|
225
290
|
createHash("sha256").update(Buffer.from(text, "utf16le")).digest("hex"),
|
|
226
291
|
].join(":");
|
|
227
292
|
const cached = scanCache.get(key);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Every pattern containsSensitive tests. A test pins each to ASCII, which scanEncodedText relies on. */
|
|
2
2
|
export declare function sensitivePatterns(): readonly RegExp[];
|
|
3
|
+
/** True if the text contains a secret-shaped token. Local paths do not count. */
|
|
4
|
+
export declare function containsSecret(text: string): boolean;
|
|
3
5
|
/** True if the text contains any secret-shaped token or known local path. */
|
|
4
6
|
export declare function containsSensitive(text: string): boolean;
|
|
5
7
|
/**
|