humanish 0.140.0 → 0.141.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 +12 -0
- package/dist/actors/codex/restricted-participant.js +1 -1
- package/dist/actors/computer-use/external-handoff.d.ts +22 -0
- package/dist/actors/computer-use/external-handoff.js +83 -0
- package/dist/actors/computer-use/loop/observation.d.ts +7 -0
- package/dist/actors/computer-use/loop/observation.js +17 -1
- package/dist/actors/computer-use/loop/types.d.ts +6 -0
- package/dist/analysis/analysis-limits.d.ts +6 -0
- package/dist/analysis/analysis-limits.js +6 -0
- package/dist/analysis/analysis-lock.d.ts +8 -0
- package/dist/analysis/analysis-lock.js +129 -0
- package/dist/analysis/automatic-completion.js +7 -0
- package/dist/analysis/execute.js +1 -1
- package/dist/analysis/job.d.ts +1 -0
- package/dist/analysis/job.js +6 -2
- package/dist/analysis/recording-ended.d.ts +16 -0
- package/dist/analysis/recording-ended.js +29 -0
- package/dist/analysis/service.d.ts +1 -3
- package/dist/analysis/service.js +23 -50
- package/dist/analysis/source.d.ts +1 -1
- package/dist/browser-control/protocol.d.ts +8 -0
- package/dist/browser-control/protocol.js +21 -0
- package/dist/cli/commands/analysis-signals.d.ts +10 -4
- package/dist/cli/commands/analysis-signals.js +28 -6
- package/dist/cli/commands/runs.js +8 -2
- package/dist/cli/commands/study-route-computer-use.js +2 -2
- package/dist/cli/commands/study-route-scripted.js +2 -2
- package/dist/cli/commands/study-route-shared-world.js +2 -2
- package/dist/cli/commands/study-route-terminal.js +2 -2
- package/dist/cli/findings.d.ts +23 -0
- package/dist/cli/findings.js +80 -4
- package/dist/cli/io.js +1 -0
- package/dist/evidence/browser-hygiene.d.ts +12 -1
- package/dist/evidence/browser-hygiene.js +45 -2
- package/dist/feedback/export-bundle.js +1 -1
- package/dist/guest/external-links.d.ts +14 -0
- package/dist/guest/external-links.js +48 -0
- package/dist/guest/runtime-desktop.js +5 -2
- package/dist/observer/data.d.ts +4 -0
- package/dist/observer/run-routes.js +12 -3
- package/dist/observer-app.html +2 -2
- package/dist/routes/computer-use/e2b-desktop/desktop.js +27 -17
- package/dist/routes/computer-use/e2b-desktop/teardown.js +1 -1
- package/dist/routes/computer-use/in-process-desktop.js +2 -2
- package/dist/routes/computer-use/live-phase.js +1 -1
- package/dist/routes/computer-use/setup.js +2 -2
- package/dist/routes/shared-world/route.js +1 -1
- package/dist/run/display.d.ts +6 -1
- package/dist/run/display.js +11 -0
- package/dist/run/host-suspension.js +4 -1
- package/dist/run/preflight-receipts.d.ts +3 -12
- package/dist/run/preflight-receipts.js +7 -67
- package/dist/run/process-owner.d.ts +28 -0
- package/dist/run/process-owner.js +83 -0
- package/dist/run/projection.js +1 -1
- package/dist/run/reclaim.d.ts +6 -0
- package/dist/run/reclaim.js +32 -2
- package/dist/run/route-shell.js +1 -1
- package/dist/run/run-index.d.ts +3 -1
- package/dist/run/run-index.js +23 -11
- package/dist/run/run.d.ts +16 -5
- package/dist/run/run.js +25 -10
- package/dist/run/status.d.ts +57 -2
- package/dist/run/status.js +87 -1
- package/dist/run/stored-runs.d.ts +3 -0
- package/dist/run/stored-runs.js +9 -4
- package/dist/study/run-study-events.d.ts +7 -1
- package/dist/substrates/e2b/desktop-browser.js +3 -1
- package/dist/substrates/e2b/desktop-executor.d.ts +7 -0
- package/dist/substrates/e2b/desktop-executor.js +3 -0
- package/dist/substrates/e2b/desktop-handoff.d.ts +22 -0
- package/dist/substrates/e2b/desktop-handoff.js +86 -0
- package/dist/substrates/local/runtime-release.js +8 -8
- package/dist/tui-app.js +115 -115
- package/docs/architecture/observer.md +7 -3
- package/docs/contracts/schemas.md +24 -3
- package/docs/contracts/study-analysis.md +1 -1
- package/docs/product/automatic-analysis.md +29 -7
- package/package.json +1 -1
- package/skills/humanish/SKILL.md +108 -5
package/dist/analysis/service.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defaultCodexCliVersion, describeCodexCliAdmission, } from "../actors/codex/codex-admission.js";
|
|
2
2
|
import { validCodexAnalysisConfig } from "./codex-config.js";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
-
import {
|
|
4
|
+
import { realpath } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { renderObserver } from "../observer/render.js";
|
|
7
7
|
import { containsSensitive } from "../evidence/redaction.js";
|
|
@@ -11,6 +11,8 @@ import { verifyRunPrepared } from "../verify/verify.js";
|
|
|
11
11
|
import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
|
|
12
12
|
import { physicalCwdOf, runIdOf, resolvePhysicalCwd, validatePreparedRunRootIdentity, } from "../run/paths.js";
|
|
13
13
|
import { isRunStatusRecord, RUN_STATUS_FILE } from "../run/status.js";
|
|
14
|
+
import { assertRecordingEnded } from "./recording-ended.js";
|
|
15
|
+
import { withAnalysisLock } from "./analysis-lock.js";
|
|
14
16
|
import { captureEvidence, EVIDENCE_LIMITS } from "./evidence.js";
|
|
15
17
|
import { AnalysisSourceRefusedError, runFileTooLarge, throwAsAnalysisRefusal } from "./source.js";
|
|
16
18
|
import { pathMissing, readBoundedFile, readBoundedFileResult } from "../run/evidence-files.js";
|
|
@@ -46,12 +48,12 @@ export async function resolveAnalysisRun(cwd, run, expectedRun) {
|
|
|
46
48
|
const messages = {
|
|
47
49
|
ANALYSIS_RUN_NOT_FOUND: "The selected run is unavailable or its storage is unsafe.",
|
|
48
50
|
ANALYSIS_VERIFY_FAILED: "The run must pass verification before analysis.",
|
|
49
|
-
ANALYSIS_RUN_ACTIVE: "
|
|
51
|
+
ANALYSIS_RUN_ACTIVE: "This run's status.json is missing, unreadable or names another run, so humanish cannot tell whether its recording has ended. `humanish verify` checks the run's files.",
|
|
50
52
|
ANALYSIS_NO_PARTICIPANTS: "This run contains no participant evidence to analyze.",
|
|
51
53
|
ANALYSIS_REQUIRES_LIVE_RUN: "A dry run has no participant behavior to analyze. Select a completed live run.",
|
|
52
54
|
ANALYSIS_SOURCE_UNAVAILABLE: "The source recording is missing or unsafe.",
|
|
53
55
|
ANALYSIS_SOURCE_CHANGED: "The source recording changed during analysis. The result cannot be published against different evidence.",
|
|
54
|
-
ANALYSIS_BUSY: "Another analysis holds this run's .analysis-lock directory
|
|
56
|
+
ANALYSIS_BUSY: "Another analysis holds this run's .analysis-lock directory, and its process is still running or humanish cannot check it (another host, or a lock written by humanish 0.139.1 or earlier). Wait for it to finish; if no humanish process is running for this run, remove that directory.",
|
|
55
57
|
ANALYSIS_HISTORY_UNAVAILABLE: "Analysis history is unavailable or full. No request was sent. Inspect this run's analysis and analysis-attempts storage before retrying.",
|
|
56
58
|
ANALYSIS_CONFIG_INVALID: "OpenAI analysis needs a supported model, positive USD admission limit and 256–32768 output tokens. Codex account analysis uses its qualified model and null dollar/output-token caps. Both require timeout 1–600000 ms.",
|
|
57
59
|
ANALYSIS_QUESTION_UNSAFE: "The reviewer question matched a sensitive-text pattern. Remove sensitive details before retrying.",
|
|
@@ -109,43 +111,7 @@ export async function refreshObserver(cwd, runId, prepared) {
|
|
|
109
111
|
return false;
|
|
110
112
|
}
|
|
111
113
|
}
|
|
112
|
-
/**
|
|
113
|
-
export async function withAnalysisLock(prepared, action) {
|
|
114
|
-
await validatePreparedRunRootIdentity(prepared);
|
|
115
|
-
const target = path.join(prepared.physicalRunRoot, ".analysis-lock");
|
|
116
|
-
try {
|
|
117
|
-
await mkdir(target, { mode: 0o700 });
|
|
118
|
-
}
|
|
119
|
-
catch (error) {
|
|
120
|
-
if (error.code === "EEXIST")
|
|
121
|
-
throw new Error("ANALYSIS_BUSY");
|
|
122
|
-
throw new Error("ANALYSIS_UNAVAILABLE");
|
|
123
|
-
}
|
|
124
|
-
const identity = await lstat(target, { bigint: true });
|
|
125
|
-
try {
|
|
126
|
-
await validatePreparedRunRootIdentity(prepared);
|
|
127
|
-
if (!identity.isDirectory() || identity.isSymbolicLink())
|
|
128
|
-
throw new Error("ANALYSIS_UNAVAILABLE");
|
|
129
|
-
return await action();
|
|
130
|
-
}
|
|
131
|
-
finally {
|
|
132
|
-
// Only remove our empty, unchanged directory. No recursive deletion and no replacement authority.
|
|
133
|
-
try {
|
|
134
|
-
await validatePreparedRunRootIdentity(prepared);
|
|
135
|
-
const current = await lstat(target, { bigint: true });
|
|
136
|
-
if (current.isDirectory() &&
|
|
137
|
-
!current.isSymbolicLink() &&
|
|
138
|
-
current.dev === identity.dev &&
|
|
139
|
-
current.ino === identity.ino &&
|
|
140
|
-
current.birthtimeNs === identity.birthtimeNs)
|
|
141
|
-
await rmdir(target);
|
|
142
|
-
}
|
|
143
|
-
catch {
|
|
144
|
-
/* Leave ambiguous storage in place for explicit operator inspection. */
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
/** The completion gate: the run verifies, is live and finished, and has participants. */
|
|
114
|
+
/** The completion gate: the run verifies, is live, its recording ended, and it has participants. */
|
|
149
115
|
export async function readCompletedAnalysisSource(cwd, prepared) {
|
|
150
116
|
const read = await readBoundedFileResult(prepared, RUN_BUNDLE_FILE, EVIDENCE_LIMITS.sourceBytes);
|
|
151
117
|
if (read.state === "limit")
|
|
@@ -170,18 +136,19 @@ export async function readCompletedAnalysisSource(cwd, prepared) {
|
|
|
170
136
|
if (!missing)
|
|
171
137
|
throw new Error("ANALYSIS_RUN_ACTIVE");
|
|
172
138
|
}
|
|
139
|
+
let status;
|
|
173
140
|
if (statusBytes) {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
if (!isRunStatusRecord(status) ||
|
|
177
|
-
status.runId !== loaded.bundle.runId ||
|
|
178
|
-
status.state !== "finished") {
|
|
141
|
+
const record = JSON.parse(statusBytes.toString("utf8"));
|
|
142
|
+
if (!isRunStatusRecord(record) || record.runId !== loaded.bundle.runId)
|
|
179
143
|
throw new Error("ANALYSIS_RUN_ACTIVE");
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
if (loaded.bundle.streams.some((stream) => !TERMINAL_SIMULATION_STATUSES.has(stream.status) || stream.liveActor !== undefined)) {
|
|
183
|
-
throw new Error("ANALYSIS_RUN_ACTIVE");
|
|
144
|
+
status = record;
|
|
184
145
|
}
|
|
146
|
+
assertRecordingEnded({
|
|
147
|
+
runId: loaded.bundle.runId,
|
|
148
|
+
status,
|
|
149
|
+
runningStreams: loaded.bundle.streams.filter((stream) => !TERMINAL_SIMULATION_STATUSES.has(stream.status) || stream.liveActor !== undefined).length,
|
|
150
|
+
streams: loaded.bundle.streams.length,
|
|
151
|
+
});
|
|
185
152
|
return bytes;
|
|
186
153
|
}
|
|
187
154
|
/** The refusal code for a configuration analyzeRun cannot run, before any run file is read. */
|
|
@@ -384,7 +351,13 @@ export async function analyzeRun(cwdInput, run, options, deps = {}) {
|
|
|
384
351
|
if (deps.expectedRun !== undefined)
|
|
385
352
|
cwd = physicalCwdOf(prepared);
|
|
386
353
|
const execute = () => executeAnalysis({ cwd, run, prepared, config, options, deps, dryRun });
|
|
387
|
-
|
|
354
|
+
if (dryRun)
|
|
355
|
+
return await execute();
|
|
356
|
+
const tookOver = [];
|
|
357
|
+
const result = await withAnalysisLock(prepared, execute, (warning) => tookOver.push(warning));
|
|
358
|
+
return tookOver.length === 0
|
|
359
|
+
? result
|
|
360
|
+
: { ...result, warnings: [...tookOver, ...result.warnings] };
|
|
388
361
|
}
|
|
389
362
|
catch (error) {
|
|
390
363
|
if (error instanceof AnalysisSourceRefusedError)
|
|
@@ -7,7 +7,7 @@ import type { RunStream, StoredRunStream } from "../run/streams.js";
|
|
|
7
7
|
*/
|
|
8
8
|
export declare class AnalysisSourceRefusedError extends Error {
|
|
9
9
|
readonly reason: string;
|
|
10
|
-
constructor(code: "ANALYSIS_SOURCE_TOO_LARGE" | "ANALYSIS_SOURCE_INVALID", reason: string);
|
|
10
|
+
constructor(code: "ANALYSIS_SOURCE_TOO_LARGE" | "ANALYSIS_SOURCE_INVALID" | "ANALYSIS_RUN_ACTIVE" | "ANALYSIS_RUN_INCOMPLETE" | "ANALYSIS_RUN_UNCONFIRMED", reason: string);
|
|
11
11
|
}
|
|
12
12
|
/** The refusal of a run.json of `size` bytes, more than the `max` analysis reads of it. */
|
|
13
13
|
export declare function runFileTooLarge(size: number | bigint, max: number): AnalysisSourceRefusedError;
|
|
@@ -287,6 +287,10 @@ declare const observationSchema: z.ZodObject<{
|
|
|
287
287
|
text: z.ZodString;
|
|
288
288
|
durationMs: z.ZodNumber;
|
|
289
289
|
}, z.core.$strict>>>;
|
|
290
|
+
externalHandoffs: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
291
|
+
scheme: z.ZodString;
|
|
292
|
+
recipients: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
293
|
+
}, z.core.$strict>>>;
|
|
290
294
|
}, z.core.$strict>;
|
|
291
295
|
type WireObservation = z.infer<typeof observationSchema>;
|
|
292
296
|
declare const requestSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
@@ -503,6 +507,10 @@ declare const replySchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
503
507
|
text: z.ZodString;
|
|
504
508
|
durationMs: z.ZodNumber;
|
|
505
509
|
}, z.core.$strict>>>;
|
|
510
|
+
externalHandoffs: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
511
|
+
scheme: z.ZodString;
|
|
512
|
+
recipients: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
513
|
+
}, z.core.$strict>>>;
|
|
506
514
|
}, z.core.$strict>>;
|
|
507
515
|
recording: z.ZodOptional<z.ZodObject<{
|
|
508
516
|
mimeType: z.ZodLiteral<"video/mp4">;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { PNG } from "pngjs";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { CUA_SPEECH_LIMITS } from "../actors/computer-use/speech.js";
|
|
4
|
+
import { EXTERNAL_HANDOFF_LIMITS } from "../actors/computer-use/external-handoff.js";
|
|
4
5
|
import { ComputerUseExecutorError, CUA_REJECTION_REASONS, isComputerUseExecutorError, } from "../actors/computer-use/executor-error.js";
|
|
5
6
|
import { CUA_DIAGNOSTIC_CATEGORIES, CUA_DIAGNOSTIC_STEPS, } from "../actors/computer-use/executor-diagnostic.js";
|
|
6
7
|
import { desktopRecordingMetadataSchema } from "../evidence/desktop-recording-types.js";
|
|
@@ -152,6 +153,17 @@ const heardSpeechListSchema = z
|
|
|
152
153
|
.min(1)
|
|
153
154
|
.max(BROWSER_CONTROL_LIMITS.heardSpeech)
|
|
154
155
|
.refine((rows) => new Set(rows.map((row) => row.id)).size === rows.length);
|
|
156
|
+
// The guest sends a handoff as externalHandoffOf made it: a scheme and a mailto link's recipients.
|
|
157
|
+
const externalHandoffListSchema = z
|
|
158
|
+
.array(z.strictObject({
|
|
159
|
+
scheme: z.string().regex(/^[a-z][a-z0-9+.-]{0,31}$/),
|
|
160
|
+
recipients: z
|
|
161
|
+
.array(z.string().min(3).max(EXTERNAL_HANDOFF_LIMITS.addressLength))
|
|
162
|
+
.max(EXTERNAL_HANDOFF_LIMITS.recipients)
|
|
163
|
+
.optional(),
|
|
164
|
+
}))
|
|
165
|
+
.min(1)
|
|
166
|
+
.max(EXTERNAL_HANDOFF_LIMITS.perObservation);
|
|
155
167
|
const observationSchema = z.strictObject({
|
|
156
168
|
png: z
|
|
157
169
|
.string()
|
|
@@ -165,6 +177,7 @@ const observationSchema = z.strictObject({
|
|
|
165
177
|
text: text.optional(),
|
|
166
178
|
scrollY: coordinate.optional(),
|
|
167
179
|
heardSpeech: heardSpeechListSchema.optional(),
|
|
180
|
+
externalHandoffs: externalHandoffListSchema.optional(),
|
|
168
181
|
});
|
|
169
182
|
const common = {
|
|
170
183
|
version: z.literal(BROWSER_CONTROL_VERSION),
|
|
@@ -344,6 +357,9 @@ export function encodeBrowserControlObservation(observation) {
|
|
|
344
357
|
...(observation.text !== undefined ? { text: observation.text } : {}),
|
|
345
358
|
...(observation.scrollY !== undefined ? { scrollY: observation.scrollY } : {}),
|
|
346
359
|
...(observation.heardSpeech !== undefined ? { heardSpeech: observation.heardSpeech } : {}),
|
|
360
|
+
...(observation.externalHandoffs !== undefined
|
|
361
|
+
? { externalHandoffs: observation.externalHandoffs }
|
|
362
|
+
: {}),
|
|
347
363
|
});
|
|
348
364
|
if (!parsed.success)
|
|
349
365
|
throw new ComputerUseExecutorError("invalid_response", "outcome_uncertain");
|
|
@@ -366,6 +382,11 @@ export function decodeBrowserControlObservation(value) {
|
|
|
366
382
|
...(state.text !== undefined ? { text: state.text } : {}),
|
|
367
383
|
...(state.scrollY !== undefined ? { scrollY: state.scrollY } : {}),
|
|
368
384
|
...(state.heardSpeech !== undefined ? { heardSpeech: state.heardSpeech } : {}),
|
|
385
|
+
...(state.externalHandoffs !== undefined
|
|
386
|
+
? {
|
|
387
|
+
externalHandoffs: state.externalHandoffs.map(({ scheme, recipients }) => recipients === undefined ? { scheme } : { scheme, recipients }),
|
|
388
|
+
}
|
|
389
|
+
: {}),
|
|
369
390
|
};
|
|
370
391
|
}
|
|
371
392
|
export function safeBrowserControlFailure(error, dispatched) {
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { RunStudyOptions } from "../../run-study.js";
|
|
2
|
+
import { type HostClock } from "../../run/host-suspension.js";
|
|
2
3
|
import type { CliIo } from "../io.js";
|
|
3
4
|
/**
|
|
4
|
-
*
|
|
5
|
-
* it.
|
|
6
|
-
* (run-signals.ts) hold the signals and
|
|
5
|
+
* The CLI's run events. When the run starts, one stderr line names it and the command that
|
|
6
|
+
* follows it. While analysis runs, SIGINT, SIGTERM and SIGHUP abort it; the listeners exist only
|
|
7
|
+
* during analysis, since before it the run's own handlers (run-signals.ts) hold the signals and
|
|
8
|
+
* hand them over here. A human also gets a line when analysis starts and one with its elapsed
|
|
9
|
+
* time every 20 s; `--json` gets only the first line, on stderr, so stdout stays one result.
|
|
7
10
|
*/
|
|
8
|
-
export declare function
|
|
11
|
+
export declare function cliRunEventOptions(io: Pick<CliIo, "writeErr">, options?: {
|
|
12
|
+
json?: boolean;
|
|
13
|
+
clock?: HostClock;
|
|
14
|
+
}): Pick<RunStudyOptions, "onEvent" | "analysisSignal">;
|
|
@@ -1,25 +1,47 @@
|
|
|
1
|
+
import { ANALYSIS_USUALLY_TAKES } from "../../analysis/analysis-limits.js";
|
|
2
|
+
import { systemHostClock } from "../../run/host-suspension.js";
|
|
3
|
+
import { formatDuration } from "../../run/projection.js";
|
|
4
|
+
import { cli } from "../invocation.js";
|
|
1
5
|
import { handOverRunSignals } from "./run-signals.js";
|
|
6
|
+
/** How often a human sees how long analysis has run: often enough to look alive, rarely enough
|
|
7
|
+
* to read as one line per glance. */
|
|
8
|
+
const ANALYSIS_PROGRESS_MS = 20_000;
|
|
2
9
|
/**
|
|
3
|
-
*
|
|
4
|
-
* it.
|
|
5
|
-
* (run-signals.ts) hold the signals and
|
|
10
|
+
* The CLI's run events. When the run starts, one stderr line names it and the command that
|
|
11
|
+
* follows it. While analysis runs, SIGINT, SIGTERM and SIGHUP abort it; the listeners exist only
|
|
12
|
+
* during analysis, since before it the run's own handlers (run-signals.ts) hold the signals and
|
|
13
|
+
* hand them over here. A human also gets a line when analysis starts and one with its elapsed
|
|
14
|
+
* time every 20 s; `--json` gets only the first line, on stderr, so stdout stays one result.
|
|
6
15
|
*/
|
|
7
|
-
export function
|
|
16
|
+
export function cliRunEventOptions(io, options = {}) {
|
|
17
|
+
const clock = options.clock ?? systemHostClock;
|
|
8
18
|
const controller = new AbortController();
|
|
9
19
|
const cancel = () => {
|
|
10
20
|
controller.abort();
|
|
11
21
|
};
|
|
12
22
|
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
23
|
+
let stopProgress;
|
|
13
24
|
return {
|
|
14
25
|
analysisSignal: controller.signal,
|
|
15
26
|
onEvent: (event) => {
|
|
16
|
-
if (event.type === "
|
|
17
|
-
io.writeErr(
|
|
27
|
+
if (event.type === "run-started") {
|
|
28
|
+
io.writeErr(`Run ${event.runId} started. Follow it with ${cli(`observe --run ${event.runId}`)}; ${cli("runs")} lists its state.\n`);
|
|
29
|
+
}
|
|
30
|
+
else if (event.type === "analysis-started") {
|
|
31
|
+
io.writeErr(`Participants finished. Writing findings; this usually takes ${ANALYSIS_USUALLY_TAKES}.\n`);
|
|
32
|
+
if (options.json !== true) {
|
|
33
|
+
const startedMs = clock.now();
|
|
34
|
+
stopProgress = clock.every(ANALYSIS_PROGRESS_MS, () => {
|
|
35
|
+
io.writeErr(`Still writing findings: ${formatDuration(clock.now() - startedMs)} elapsed.\n`);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
18
38
|
handOverRunSignals();
|
|
19
39
|
for (const signal of signals)
|
|
20
40
|
process.on(signal, cancel);
|
|
21
41
|
}
|
|
22
42
|
else if (event.type === "analysis-finished") {
|
|
43
|
+
stopProgress?.();
|
|
44
|
+
stopProgress = undefined;
|
|
23
45
|
for (const signal of signals)
|
|
24
46
|
process.off(signal, cancel);
|
|
25
47
|
}
|
|
@@ -6,7 +6,7 @@ import { DEFAULT_EXPORT_MAX_BYTES, exportRun, formatExportHuman } from "../../fe
|
|
|
6
6
|
import { listRuns, readReview } from "../../run/stored-runs.js";
|
|
7
7
|
import { verifyRun } from "../../verify/verify.js";
|
|
8
8
|
import { reclaimPreflightSandboxes, reclaimRunSandboxes, } from "../../run/reclaim.js";
|
|
9
|
-
import { runDisplay } from "../../run/display.js";
|
|
9
|
+
import { analysisPhaseLabel, runDisplay } from "../../run/display.js";
|
|
10
10
|
import { addRunOptions, handleRun } from "./run-command.js";
|
|
11
11
|
import { applyEnvFileOption, CWD_OPTION_DESCRIPTION, DOTENV_OPTION_DESCRIPTION, dotenvPathOf, envFileAliasOption, JSON_OPTION_DESCRIPTION, parsePositiveInteger, RUN_OPTION_DESCRIPTION, writeResult, humanError, } from "../io.js";
|
|
12
12
|
import { plural } from "../../run/text.js";
|
|
@@ -251,6 +251,8 @@ function formatReclaimHuman(result, cwd, preflight) {
|
|
|
251
251
|
for (const outcome of result.outcomes) {
|
|
252
252
|
lines.push(` sandbox ${outcome.sandboxIdDigest} (${outcome.laneId}, from ${outcome.source}): ${outcome.state}${outcome.detail ? `: ${outcome.detail}` : ""}`);
|
|
253
253
|
}
|
|
254
|
+
if (result.markedInterrupted === true)
|
|
255
|
+
lines.push("Its status.json had stopped updating while it still said running, so the run is now recorded as interrupted.");
|
|
254
256
|
for (const warning of result.warnings)
|
|
255
257
|
lines.push(` warning: ${warning}`);
|
|
256
258
|
if (result.error === undefined && result.state !== "clean")
|
|
@@ -378,6 +380,10 @@ function formatRunsHuman(result) {
|
|
|
378
380
|
}
|
|
379
381
|
return ([
|
|
380
382
|
`latest: ${result.latest ?? "none"}`,
|
|
381
|
-
...result.runs.map((run) =>
|
|
383
|
+
...result.runs.map((run) => {
|
|
384
|
+
const analysis = analysisPhaseLabel(run.analysis);
|
|
385
|
+
const label = `${run.display?.label ?? "unreadable"}${analysis === undefined ? "" : `, ${analysis}`}`;
|
|
386
|
+
return `- ${run.runId} ${run.mode ?? "unknown"} ${label} ${run.createdAt ?? "unknown"} ${run.path}`;
|
|
387
|
+
}),
|
|
382
388
|
].join("\n") + "\n");
|
|
383
389
|
}
|
|
@@ -5,7 +5,7 @@ import { resolveStudyDryRun } from "../../study/plan.js";
|
|
|
5
5
|
import { serveObserver } from "../../observer/render.js";
|
|
6
6
|
import { startExposedObserver, validateExposure } from "../../observer/exposure.js";
|
|
7
7
|
import { ServeTunnelError } from "../../observer/tunnel.js";
|
|
8
|
-
import {
|
|
8
|
+
import { cliRunEventOptions } from "./analysis-signals.js";
|
|
9
9
|
import { onRunShutdown } from "./run-signals.js";
|
|
10
10
|
import { parseStudyCount, parseParticipantIds, parseObserverPort, wantsJson, writeResult, } from "../io.js";
|
|
11
11
|
import { exposureRequestFromOptions, followDecision, followObserver, planObserver, showObserver, staticObserverOpen, withObserverServer, } from "../observer-follow.js";
|
|
@@ -127,7 +127,7 @@ function prepareCuaWatch(args, settings) {
|
|
|
127
127
|
function cuaRunOptions(args, settings, prepared, live) {
|
|
128
128
|
const { finishedPlan } = prepared;
|
|
129
129
|
return {
|
|
130
|
-
...
|
|
130
|
+
...cliRunEventOptions(args.io, { json: wantsJson(args.command) }),
|
|
131
131
|
cwd: args.options.cwd,
|
|
132
132
|
// A followed watch opens the served Observer (or prints the phone target under --expose), so
|
|
133
133
|
// its static render never opens. A non-follow watch opens what its plan says, once. Run mode
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { automaticAnalysisSucceeded } from "../../analysis/automatic-completion.js";
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import {
|
|
3
|
+
import { cliRunEventOptions } from "./analysis-signals.js";
|
|
4
4
|
import { wantsJson } from "../io.js";
|
|
5
5
|
import { writeRunResult } from "../run-findings.js";
|
|
6
6
|
import { showObserver } from "../observer-follow.js";
|
|
@@ -23,7 +23,7 @@ export function scriptedRouteRun(args) {
|
|
|
23
23
|
return undefined;
|
|
24
24
|
return {
|
|
25
25
|
options: {
|
|
26
|
-
...
|
|
26
|
+
...cliRunEventOptions(args.io, { json: wantsJson(args.command) }),
|
|
27
27
|
cwd: args.options.cwd,
|
|
28
28
|
open: observerOpen(args.mode, finishedPlan, shouldOpen),
|
|
29
29
|
...(args.options.dryRun === undefined ? {} : { dryRun: args.options.dryRun }),
|
|
@@ -3,7 +3,7 @@ import { Command } from "commander";
|
|
|
3
3
|
import { resolveStudyDryRun } from "../../study/plan.js";
|
|
4
4
|
import { serveObserver } from "../../observer/render.js";
|
|
5
5
|
import { redactText } from "../../evidence/redaction.js";
|
|
6
|
-
import {
|
|
6
|
+
import { cliRunEventOptions } from "./analysis-signals.js";
|
|
7
7
|
import { parseObserverPort, wantsJson, writeResult, } from "../io.js";
|
|
8
8
|
import { followDecision, followObserver, showObserver, staticObserverOpen, withObserverServer, } from "../observer-follow.js";
|
|
9
9
|
import { writeRunResult } from "../run-findings.js";
|
|
@@ -65,7 +65,7 @@ export function sharedWorldRouteRun(args) {
|
|
|
65
65
|
let observerFailure;
|
|
66
66
|
return {
|
|
67
67
|
options: {
|
|
68
|
-
...
|
|
68
|
+
...cliRunEventOptions(args.io, { json: wantsJson(args.command) }),
|
|
69
69
|
cwd: args.options.cwd,
|
|
70
70
|
open: wantsFollow
|
|
71
71
|
? false
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { automaticAnalysisSucceeded } from "../../analysis/automatic-completion.js";
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import {
|
|
3
|
+
import { cliRunEventOptions } from "./analysis-signals.js";
|
|
4
4
|
import { wantsJson } from "../io.js";
|
|
5
5
|
import { writeRunResult } from "../run-findings.js";
|
|
6
6
|
import { showObserver } from "../observer-follow.js";
|
|
@@ -23,7 +23,7 @@ export function terminalRouteRun(args) {
|
|
|
23
23
|
return undefined;
|
|
24
24
|
return {
|
|
25
25
|
options: {
|
|
26
|
-
...
|
|
26
|
+
...cliRunEventOptions(args.io, { json: wantsJson(args.command) }),
|
|
27
27
|
cwd: args.options.cwd,
|
|
28
28
|
open: observerOpen(args.mode, finishedPlan, shouldOpen),
|
|
29
29
|
...(args.options.dryRun === undefined ? {} : { dryRun: args.options.dryRun }),
|
package/dist/cli/findings.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AnalysisArtifact, LoadedAnalysis } from "../analysis/types.js";
|
|
2
|
+
import { type RunAnalysisPhase } from "../run/status.js";
|
|
2
3
|
import { type RunStudyProvenance } from "../run/study-provenance.js";
|
|
3
4
|
export declare const ANALYSIS_FINDINGS_SCHEMA = "humanish.analysis-findings.v1";
|
|
4
5
|
type Finding = NonNullable<AnalysisArtifact["result"]>["findings"][number];
|
|
@@ -121,6 +122,28 @@ interface FindingsSource {
|
|
|
121
122
|
* skipped for a missing OpenAI key, whose next step it picks.
|
|
122
123
|
*/
|
|
123
124
|
codexSignedIn?: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Where the run is before its findings, from status.json: its participants still running, or
|
|
127
|
+
* the automatic analysis its own process started. Undefined once neither applies.
|
|
128
|
+
*/
|
|
129
|
+
progress?: {
|
|
130
|
+
stage: "participants";
|
|
131
|
+
}
|
|
132
|
+
/** The run ended while a participant stream was still running: nothing complete to analyze. */
|
|
133
|
+
| {
|
|
134
|
+
stage: "cut-off";
|
|
135
|
+
running: number;
|
|
136
|
+
streams: number;
|
|
137
|
+
} | {
|
|
138
|
+
stage: "analysis";
|
|
139
|
+
phase: RunAnalysisPhase;
|
|
140
|
+
/** status.json's last heartbeat. */
|
|
141
|
+
updatedAt: string;
|
|
142
|
+
/** The analyst the run's study gets, read only for an analysis whose process stopped. */
|
|
143
|
+
provider?: string;
|
|
144
|
+
};
|
|
145
|
+
/** When the view was built, for how long an analysis has run. */
|
|
146
|
+
nowMs?: number;
|
|
124
147
|
}
|
|
125
148
|
/** A study `run` can start live after a dry run. */
|
|
126
149
|
interface LiveStudy {
|
package/dist/cli/findings.js
CHANGED
|
@@ -5,11 +5,15 @@
|
|
|
5
5
|
// starts an analysis.
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { costRefusal } from "../analysis/admission.js";
|
|
8
|
-
import { DEFAULT_ANALYSIS_MAX_COST_USD } from "../analysis/automatic-config.js";
|
|
8
|
+
import { DEFAULT_ANALYSIS_MAX_COST_USD, resolveAutomaticAnalysis, } from "../analysis/automatic-config.js";
|
|
9
|
+
import { ANALYSIS_USUALLY_TAKES } from "../analysis/analysis-limits.js";
|
|
9
10
|
import { bySeverity } from "../analysis/finding-lead.js";
|
|
10
11
|
import { loadAnalysis } from "../analysis/load.js";
|
|
11
12
|
import { projectShareCheckedAnalysis } from "../analysis/sharing.js";
|
|
12
|
-
import { loadRunBundlePrepared, resolveRunPath } from "../run/locate.js";
|
|
13
|
+
import { loadRunBundlePrepared, readRunJsonIfExists, resolveRunPath, runJsonValue, } from "../run/locate.js";
|
|
14
|
+
import { classifyRunStatus, isRunStatusRecord, RUN_STATUS_FILE, runAnalysisPhase, } from "../run/status.js";
|
|
15
|
+
import { TERMINAL_SIMULATION_STATUSES } from "../run/streams.js";
|
|
16
|
+
import { formatDuration } from "../run/projection.js";
|
|
13
17
|
import { resolvePhysicalCwd, runIdOf } from "../run/paths.js";
|
|
14
18
|
import { studyProvenanceOf } from "../run/study-provenance.js";
|
|
15
19
|
import { resolveStudyManifest } from "../study/discover.js";
|
|
@@ -164,6 +168,57 @@ function missingKeyFindings(source, reason) {
|
|
|
164
168
|
const file = source.study?.path === undefined ? "the study file" : source.study.path;
|
|
165
169
|
return withoutFindings(source, "skipped", reason, `${opening} Codex is signed in on this machine, so its ChatGPT account can analyze the run instead, sending the evidence to OpenAI under that account. The next command does it once; review.analysis.provider: codex in ${file} makes it this study's analyst.`, analyzeCommand(source, "codex"));
|
|
166
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* The view of a run whose findings are not written yet: participants still running, an analysis
|
|
173
|
+
* running, or an analysis whose process stopped before it finished. Undefined otherwise.
|
|
174
|
+
*/
|
|
175
|
+
function pendingFindings(source, job) {
|
|
176
|
+
const again = cli(`review --run ${source.runId}${source.cwdFlag}`);
|
|
177
|
+
const progress = source.progress;
|
|
178
|
+
if (progress?.stage === "participants" && job === undefined)
|
|
179
|
+
return withoutFindings(source, "running", null, source.analysisOffIn === undefined
|
|
180
|
+
? "Participants are still running in this run, so no analysis has started. Automatic analysis starts when they finish, and the findings follow it."
|
|
181
|
+
: "Participants are still running in this run. Analysis is turned off in its study, so none starts when they finish.", again);
|
|
182
|
+
if (progress?.stage === "cut-off" && job === undefined)
|
|
183
|
+
return withoutFindings(source, "skipped", "ANALYSIS_RUN_INCOMPLETE", `This run ended while ${progress.running} of ${progress.streams} participant streams were still running, so its recording is incomplete and no analysis ran. Run the study again with the next command.`, cli(`run ${source.study?.id ?? "<study>"}${source.cwdFlag}`));
|
|
184
|
+
const phase = progress?.stage === "analysis" ? progress.phase : undefined;
|
|
185
|
+
const jobPending = job?.state === "queued" || job?.state === "running";
|
|
186
|
+
if (jobPending || (job === undefined && phase?.state === "running")) {
|
|
187
|
+
const since = phase === undefined
|
|
188
|
+
? ""
|
|
189
|
+
: ` It started at ${phase.startedAt}, ${formatDuration(Math.max(0, (source.nowMs ?? Date.now()) - Date.parse(phase.startedAt)))} ago.`;
|
|
190
|
+
return withoutFindings(source, "running", null, `The automatic analysis is running.${since} Findings usually take ${ANALYSIS_USUALLY_TAKES}; check again then.`, again);
|
|
191
|
+
}
|
|
192
|
+
const stopped = (job?.state === "unknown" && job.reason === "AUTOMATIC_ANALYSIS_STOPPED") ||
|
|
193
|
+
(job === undefined && phase?.state === "stopped");
|
|
194
|
+
if (!stopped)
|
|
195
|
+
return undefined;
|
|
196
|
+
const at = job?.updatedAt ?? (progress?.stage === "analysis" ? progress.updatedAt : undefined);
|
|
197
|
+
return withoutFindings(source, "failed", "AUTOMATIC_ANALYSIS_STOPPED", `The automatic analysis stopped before it wrote findings: the process running it stopped updating its record${at === undefined ? "" : ` at ${at}`}, most likely because it ended. Analyze the run again with the next command.`, analyzeCommand(source, progress?.stage === "analysis" ? progress.provider : undefined));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* A run that ended, by a signal, a process that left without publishing, or a heartbeat that
|
|
201
|
+
* stopped, while some participant stream was still running. Undefined for any other run.
|
|
202
|
+
*/
|
|
203
|
+
function cutOff(bundle, record, nowMs) {
|
|
204
|
+
if (bundle === undefined || bundle.outcome?.state === "finished")
|
|
205
|
+
return undefined;
|
|
206
|
+
if (bundle.outcome === undefined &&
|
|
207
|
+
(record === null || classifyRunStatus(record, nowMs) !== "interrupted"))
|
|
208
|
+
return undefined;
|
|
209
|
+
const running = bundle.streams.filter((stream) => !TERMINAL_SIMULATION_STATUSES.has(stream.status) || stream.liveActor !== undefined).length;
|
|
210
|
+
return running === 0 ? undefined : { stage: "cut-off", running, streams: bundle.streams.length };
|
|
211
|
+
}
|
|
212
|
+
/** The analyst the run's study gets, when its file still names the same study. */
|
|
213
|
+
async function studyAnalyst(cwd, study) {
|
|
214
|
+
if (study?.path === undefined)
|
|
215
|
+
return undefined;
|
|
216
|
+
const resolved = await resolveStudyManifest(cwd, study.path).catch(() => null);
|
|
217
|
+
if (!resolved?.ok || resolved.config.id !== study.id)
|
|
218
|
+
return undefined;
|
|
219
|
+
const analysis = resolveAutomaticAnalysis(resolved.config.review?.analysis, resolved.config.actor);
|
|
220
|
+
return analysis.ok ? analysis.config?.provider : undefined;
|
|
221
|
+
}
|
|
167
222
|
/** The findings view of a run's selected analysis, or why it has none. */
|
|
168
223
|
export function analysisFindings(source) {
|
|
169
224
|
if (source.mode !== undefined && source.mode !== "live")
|
|
@@ -200,8 +255,9 @@ export function analysisFindings(source) {
|
|
|
200
255
|
if (loaded.warnings.includes("ANALYSIS_SENSITIVE_TEXT_QUARANTINED"))
|
|
201
256
|
return withoutFindings(source, "unavailable", "ANALYSIS_SENSITIVE_TEXT_QUARANTINED", `The analysis text matched a sensitive-text pattern, so it is not printed. ${cli(`analyze show --run ${source.runId}${source.cwdFlag} --json`)} prints the raw record.`, null);
|
|
202
257
|
const job = loaded.automatic;
|
|
203
|
-
|
|
204
|
-
|
|
258
|
+
const pending = pendingFindings(source, job);
|
|
259
|
+
if (pending !== undefined)
|
|
260
|
+
return pending;
|
|
205
261
|
if (analysis && analysis.result === null)
|
|
206
262
|
return withoutFindings(source, "failed", analysis.error, `Analysis attempt ${analysis.id} ${analysis.status === "cancelled" ? "was cancelled" : "failed"}${analysis.error ? ` (${analysis.error})` : ""} and left no findings.`, analyzeCommand(source, analysis.provider));
|
|
207
263
|
if (job?.state === "failed" || job?.state === "cancelled") {
|
|
@@ -278,6 +334,24 @@ export async function readRunAnalysis(cwd, run, analysisId, deps = {}) {
|
|
|
278
334
|
const analysisOffIn = mode === "live" ? await studyAnalysisOff(physical, study) : undefined;
|
|
279
335
|
const liveStudy = mode !== undefined && mode !== "live" ? await liveStudyAfterDryRun(physical, study) : undefined;
|
|
280
336
|
const loaded = await loadAnalysis(prepared, analysisId);
|
|
337
|
+
const nowMs = Date.now();
|
|
338
|
+
const status = runJsonValue(await readRunJsonIfExists(prepared, RUN_STATUS_FILE));
|
|
339
|
+
const record = isRunStatusRecord(status) && status.runId === runIdOf(prepared) ? status : null;
|
|
340
|
+
const phase = record === null ? undefined : runAnalysisPhase(record, nowMs);
|
|
341
|
+
const stopped = phase?.state === "stopped" || loaded.automatic?.reason === "AUTOMATIC_ANALYSIS_STOPPED";
|
|
342
|
+
const provider = stopped ? await studyAnalyst(physical, study) : undefined;
|
|
343
|
+
const progress = phase !== undefined && record !== null
|
|
344
|
+
? {
|
|
345
|
+
stage: "analysis",
|
|
346
|
+
phase,
|
|
347
|
+
updatedAt: record.updatedAt,
|
|
348
|
+
...(provider === undefined ? {} : { provider }),
|
|
349
|
+
}
|
|
350
|
+
: bundle?.bundle.outcome === undefined &&
|
|
351
|
+
record !== null &&
|
|
352
|
+
classifyRunStatus(record, nowMs) === "running"
|
|
353
|
+
? { stage: "participants" }
|
|
354
|
+
: cutOff(bundle?.bundle, record, nowMs);
|
|
281
355
|
const codexSignedIn = loaded.automatic?.reason === "AUTOMATIC_ANALYSIS_KEY_MISSING"
|
|
282
356
|
? await (deps.codexSignedIn ?? codexSignedInHere)().catch(() => false)
|
|
283
357
|
: undefined;
|
|
@@ -293,6 +367,8 @@ export async function readRunAnalysis(cwd, run, analysisId, deps = {}) {
|
|
|
293
367
|
? {}
|
|
294
368
|
: { study: { id: study.id, ...(study.path === undefined ? {} : { path: study.path }) } }),
|
|
295
369
|
...(codexSignedIn === undefined ? {} : { codexSignedIn }),
|
|
370
|
+
...(progress === undefined ? {} : { progress }),
|
|
371
|
+
nowMs,
|
|
296
372
|
};
|
|
297
373
|
}
|
|
298
374
|
async function codexSignedInHere() {
|
package/dist/cli/io.js
CHANGED
|
@@ -315,6 +315,7 @@ const ANALYSIS_REASON_TEXT = {
|
|
|
315
315
|
AUTOMATIC_ANALYSIS_CANCELLATION_UNAVAILABLE: "outcome unknown: a cancellation could not be recorded",
|
|
316
316
|
AUTOMATIC_ANALYSIS_STORAGE_UNAVAILABLE: "outcome unknown: analysis storage is unavailable",
|
|
317
317
|
AUTOMATIC_ANALYSIS_OUTCOME_UNKNOWN: "outcome unknown",
|
|
318
|
+
AUTOMATIC_ANALYSIS_STOPPED: "outcome unknown: the process running it stopped before it finished",
|
|
318
319
|
};
|
|
319
320
|
export function analysisOutcomeText(analysis) {
|
|
320
321
|
if (analysis.reason === null)
|
|
@@ -4,4 +4,15 @@
|
|
|
4
4
|
* place so browser-backed routes can prefer product pixels over browser UI.
|
|
5
5
|
*/
|
|
6
6
|
export declare const CHROMIUM_EVIDENCE_HYGIENE_FLAGS: readonly ["--disable-background-networking", "--disable-component-update", "--disable-default-apps", "--disable-domain-reliability", "--disable-extensions", "--disable-sync", "--no-default-browser-check", "--no-first-run", "--password-store=basic", "--use-mock-keychain"];
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* The profile preference that lets a web `targetUrl`'s origin open the external app schemes
|
|
9
|
+
* without Chrome's prompt: the "always allow" choice Chrome stores per origin, which needs no root
|
|
10
|
+
* and no managed policy. Links from other origins still prompt. Undefined for any other target.
|
|
11
|
+
*/
|
|
12
|
+
export declare function externalAppPreferences(targetUrl: string | undefined): {
|
|
13
|
+
protocol_handler: {
|
|
14
|
+
allowed_origin_protocol_pairs: Record<string, Record<string, true>>;
|
|
15
|
+
};
|
|
16
|
+
} | undefined;
|
|
17
|
+
/** The profile's Preferences file, with externalAppPreferences for a web `targetUrl`. */
|
|
18
|
+
export declare function chromiumEvidenceProfilePreferencesJson(targetUrl?: string): string;
|
|
@@ -37,6 +37,49 @@ const CHROMIUM_EVIDENCE_PROFILE_PREFERENCES = {
|
|
|
37
37
|
password_manager_enabled: false,
|
|
38
38
|
},
|
|
39
39
|
};
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Schemes whose links Chrome hands to another app after asking "Open xdg-open?". The desktop's
|
|
42
|
+
* stand-ins answer them (src/substrates/e2b/desktop-handoff.ts), so the prompt is browser chrome
|
|
43
|
+
* a person on their own computer would not see for that app. Chrome never asks for mailto.
|
|
44
|
+
*/
|
|
45
|
+
const EXTERNAL_APP_SCHEMES = [
|
|
46
|
+
"callto",
|
|
47
|
+
"facetime",
|
|
48
|
+
"facetime-audio",
|
|
49
|
+
"geo",
|
|
50
|
+
"maps",
|
|
51
|
+
"mms",
|
|
52
|
+
"msteams",
|
|
53
|
+
"sip",
|
|
54
|
+
"skype",
|
|
55
|
+
"slack",
|
|
56
|
+
"sms",
|
|
57
|
+
"tel",
|
|
58
|
+
"webcal",
|
|
59
|
+
"whatsapp",
|
|
60
|
+
"zoommtg",
|
|
61
|
+
];
|
|
62
|
+
/**
|
|
63
|
+
* The profile preference that lets a web `targetUrl`'s origin open the external app schemes
|
|
64
|
+
* without Chrome's prompt: the "always allow" choice Chrome stores per origin, which needs no root
|
|
65
|
+
* and no managed policy. Links from other origins still prompt. Undefined for any other target.
|
|
66
|
+
*/
|
|
67
|
+
export function externalAppPreferences(targetUrl) {
|
|
68
|
+
const target = targetUrl === undefined ? null : URL.parse(targetUrl);
|
|
69
|
+
if (target === null || !/^https?:$/.test(target.protocol))
|
|
70
|
+
return undefined;
|
|
71
|
+
return {
|
|
72
|
+
protocol_handler: {
|
|
73
|
+
allowed_origin_protocol_pairs: {
|
|
74
|
+
[target.origin]: Object.fromEntries(EXTERNAL_APP_SCHEMES.map((scheme) => [scheme, true])),
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/** The profile's Preferences file, with externalAppPreferences for a web `targetUrl`. */
|
|
80
|
+
export function chromiumEvidenceProfilePreferencesJson(targetUrl) {
|
|
81
|
+
return JSON.stringify({
|
|
82
|
+
...CHROMIUM_EVIDENCE_PROFILE_PREFERENCES,
|
|
83
|
+
...externalAppPreferences(targetUrl),
|
|
84
|
+
});
|
|
42
85
|
}
|
|
@@ -47,7 +47,7 @@ function omittedReason(relative) {
|
|
|
47
47
|
return "automatic execution intent and liveness do not transfer to a derivative";
|
|
48
48
|
if (isAnalysisRecordPath(relative))
|
|
49
49
|
return "analysis records bind original evidence hashes; reanalyze the derivative";
|
|
50
|
-
if (relative.startsWith(".analysis-lock
|
|
50
|
+
if (relative.startsWith(".analysis-lock"))
|
|
51
51
|
return "local analysis execution lock";
|
|
52
52
|
return (OMITTED.get(relative) ??
|
|
53
53
|
(["feedback/draft.json", "feedback/issue.md"].includes(relative)
|