humanish 0.110.1 → 0.110.2
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 +1 -1
- package/CHANGELOG.md +13 -0
- package/README.md +8 -7
- 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/cli/commands/observe.js +15 -2
- 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/watch.js +14 -4
- package/dist/cli/doctor.js +9 -6
- package/dist/cli/findings.js +7 -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 +3 -1
- package/dist/cli/io.js +6 -14
- package/dist/cli/observer-follow.d.ts +20 -0
- package/dist/cli/observer-follow.js +36 -1
- 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 +123 -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/plan.js +5 -0
- package/dist/routes/shared-world/plan.js +5 -1
- 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.js +5 -4
- package/dist/routes/terminal/runtime-auth.d.ts +6 -0
- package/dist/routes/terminal/runtime-auth.js +5 -0
- 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/run-not-found.js +3 -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/study/agents-section.d.ts +21 -0
- package/dist/study/agents-section.js +86 -0
- package/dist/study/doctor.js +5 -4
- package/dist/study/init-templates.js +0 -95
- package/dist/study/init.js +31 -34
- 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 +12 -0
- 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/terminal-product-route.md +14 -12
- package/docs/contracts/run-bundle.md +20 -4
- package/docs/contracts/schemas.md +6 -5
- package/package.json +4 -2
- package/skills/humanish/SKILL.md +22 -18
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { isAnalysisRecordPath, projectShareCheckedAnalysis } from "../analysis/sharing.js";
|
|
6
6
|
import { loadAnalysis } from "../analysis/load.js";
|
|
7
|
+
import { isLocalOnlyRunFile, isLocalOnlyRunFileIdentity } from "../run/local-only-files.js";
|
|
7
8
|
import { listRuns } from "../run/stored-runs.js";
|
|
8
9
|
import { readAnalysisAccounting } from "../run/costs.js";
|
|
9
10
|
import { bindExistingRunArtifactPaths, isPathInside, isSafeRunIdSegment, } from "../run/paths.js";
|
|
@@ -70,9 +71,11 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
|
|
|
70
71
|
// file serving bypass its current-content checks or a warmed source-only admission cache.
|
|
71
72
|
const derivedRoot = cleanedRelativePath.split("/")[0];
|
|
72
73
|
const derivedLeaf = path.posix.basename(cleanedRelativePath);
|
|
74
|
+
// Local-only files (raw sandbox ids, the recording pid) are read in process, never served.
|
|
73
75
|
if (derivedRoot === ".analysis-lock" ||
|
|
74
76
|
derivedLeaf.startsWith(".humanish-write-") ||
|
|
75
|
-
isAnalysisRecordPath(cleanedRelativePath)
|
|
77
|
+
isAnalysisRecordPath(cleanedRelativePath) ||
|
|
78
|
+
isLocalOnlyRunFile(cleanedRelativePath)) {
|
|
76
79
|
writeResponse(response, 404, "Not found", "text/plain; charset=utf-8");
|
|
77
80
|
return;
|
|
78
81
|
}
|
|
@@ -103,6 +106,10 @@ export async function serveRunPath(runRoot, relativePath, response, runtimeStrea
|
|
|
103
106
|
await serveContainedMedia(runRoot, filePath, response, request);
|
|
104
107
|
return;
|
|
105
108
|
}
|
|
109
|
+
if (await isLocalOnlyRunFileIdentity(root, filePath)) {
|
|
110
|
+
writeResponse(response, 404, "Not found", "text/plain; charset=utf-8");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
106
113
|
try {
|
|
107
114
|
const body = await readContainedFile(runRoot, filePath);
|
|
108
115
|
if (!body) {
|
package/dist/observer/serve.js
CHANGED
|
@@ -10,6 +10,7 @@ import { buildServeSecurityHeaders, hostAllowed, parsePublicOrigin, } from "./ht
|
|
|
10
10
|
import { isSafeRunIdSegment } from "../run/paths.js";
|
|
11
11
|
import { listRuns } from "../run/stored-runs.js";
|
|
12
12
|
import { verifyRun } from "../verify/verify.js";
|
|
13
|
+
import { cli } from "../cli/invocation.js";
|
|
13
14
|
export const SERVE_SCHEMA = "humanish.serve-result.v1";
|
|
14
15
|
export function createShareSafetyAdmission(cwd, options = {}) {
|
|
15
16
|
const verifyImpl = options.verifyImpl ?? verifyRun;
|
|
@@ -196,7 +197,7 @@ export async function serveObserverLibrary(cwdInput, options) {
|
|
|
196
197
|
ok: false,
|
|
197
198
|
error: {
|
|
198
199
|
code: "HUMANISH_RUN_NOT_FOUND",
|
|
199
|
-
message:
|
|
200
|
+
message: `No run library found under .humanish/runs. Run \`${cli("run first-run")}\` to create the first run.`,
|
|
200
201
|
},
|
|
201
202
|
};
|
|
202
203
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
|
|
6
6
|
import { actorRegistry, isCuaActorDescriptor, } from "../../actors/registry.js";
|
|
7
7
|
import { isHttpUrl, isLoopbackUrl } from "../../study/parse/subject.js";
|
|
8
|
+
import { studyUrlCredentialReason } from "../../study/url-credentials.js";
|
|
8
9
|
import { subjectStateInvalidReason } from "../../study/parse/subject-state.js";
|
|
9
10
|
import { brainOf, callerBrainOf, capsOf, desktopRequirements, planBase, provisionedSubject, } from "../../study/plan-base.js";
|
|
10
11
|
import { computerUseParticipants, declaredTargets } from "../../study/plan-participants.js";
|
|
@@ -134,6 +135,10 @@ function subjectStructureReason(config, subjectRoute) {
|
|
|
134
135
|
*/
|
|
135
136
|
function entryTargetReason(config, subjectRoute) {
|
|
136
137
|
const { desktopCliRoute, provisionedRoute, localAppSubject, appUrl } = subjectRoute;
|
|
138
|
+
// A library caller's config skips the parser, which refuses these first.
|
|
139
|
+
const credential = studyUrlCredentialReason(config);
|
|
140
|
+
if (credential)
|
|
141
|
+
return { code: "HUMANISH_COMPUTER_USE_SUBJECT_UNSAFE", message: credential };
|
|
137
142
|
if (desktopCliRoute)
|
|
138
143
|
return undefined;
|
|
139
144
|
const allowPublicTargets = config.policies?.allowPublicTargets === true;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// external state: keys and subject env values, the external comms catch, the packed working tree
|
|
4
4
|
// and email receiving setup.
|
|
5
5
|
import { DEFAULT_OPENAI_CU_MODEL } from "../../actors/computer-use/openai-provider.js";
|
|
6
|
+
import { studyUrlCredentialReason } from "../../study/url-credentials.js";
|
|
6
7
|
import { actorRegistry, isCuaActorDescriptor, } from "../../actors/registry.js";
|
|
7
8
|
import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
|
|
8
9
|
import { brainOf, capsOf, desktopRequirements, isNonEmpty, planBase, } from "../../study/plan-base.js";
|
|
@@ -48,7 +49,10 @@ export function planSharedWorldStudy(config, input) {
|
|
|
48
49
|
// exists because getHost is internet-reachable and harness-owned, and a public site the harness
|
|
49
50
|
// neither provisioned nor exposed is neither.
|
|
50
51
|
const externalPublic = config.subject.source === "app-url";
|
|
51
|
-
const invalidReason =
|
|
52
|
+
const invalidReason =
|
|
53
|
+
// A library caller's config skips the parser, which refuses these first.
|
|
54
|
+
studyUrlCredentialReason(config) ??
|
|
55
|
+
outputTokenLimitValidationReason(config) ??
|
|
52
56
|
scenarioCapsValidationReason(config) ??
|
|
53
57
|
(externalPublic
|
|
54
58
|
? externalPublicSharedWorldValidationReason(config)
|
|
@@ -6,6 +6,7 @@ import { reviewOutcome } from "../../run/display.js";
|
|
|
6
6
|
import {} from "../../run/streams.js";
|
|
7
7
|
import { participantEvent, participantIds, participantRecord, participantStream, } from "../../run/participant-records.js";
|
|
8
8
|
import { TERMINAL_EVENTS_ARTIFACT, TERMINAL_LEDGERS_ARTIFACT, TERMINAL_TRANSCRIPT_ARTIFACT, } from "../../run/terminal-contract.js";
|
|
9
|
+
import { DEFAULT_RUNTIME_AUTH } from "./runtime-auth.js";
|
|
9
10
|
import { describeMeasuredSpend, noSpendLineMeasured, noSpendNotEstablished } from "./ledger.js";
|
|
10
11
|
/**
|
|
11
12
|
* Project the terminal-product run into a humanish.run-bundle.v1 (no schema change: a new
|
|
@@ -59,7 +60,7 @@ export function buildTerminalProductBundle(args) {
|
|
|
59
60
|
type: "terminal-lab.credentials.declared",
|
|
60
61
|
// Names-only evidence: the runtime-auth channel is declared; no value is ever
|
|
61
62
|
// recorded. The deny-by-default policies are recorded so the credential posture is auditable.
|
|
62
|
-
message: `Runtime auth channel: ${args.runtimeAuth ??
|
|
63
|
+
message: `Runtime auth channel: ${args.runtimeAuth ?? `${DEFAULT_RUNTIME_AUTH}, the default`} (names only; values never persist; the live engine applies the selected key placement, while this dry-run performs no injection). Credential policies (deny-by-default): allowPrivateRepoAccess=${args.policies.allowPrivateRepoAccess}, allowProviderCredentials=${args.policies.allowProviderCredentials}, allowPaymentCredentials=${args.policies.allowPaymentCredentials}, allowGitHubMutation=${args.policies.allowGitHubMutation}.`,
|
|
63
64
|
}),
|
|
64
65
|
participantEvent(TERMINAL_IDS, {
|
|
65
66
|
id: "event-003-caps",
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { StudyRuntimeAuth } from "../../study/types.js";
|
|
2
2
|
/**
|
|
3
|
-
* Resolve the runtime key on the host.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* credential is forwarded. The real value must never be logged or persisted in either mode.
|
|
3
|
+
* Resolve the runtime key on the host. openai-egress, the default, returns an inert command env
|
|
4
|
+
* while retaining the actual value for the external transform and literal redaction; openai-env
|
|
5
|
+
* passes it command-scoped. Only CODEX_API_KEY/OPENAI_API_KEY are accepted as sources. No other
|
|
6
|
+
* operator credential is forwarded. The real value must never be logged or persisted in either mode.
|
|
7
7
|
*/
|
|
8
8
|
export declare function buildRuntimeAuth(args: {
|
|
9
|
-
/** Undefined
|
|
9
|
+
/** Undefined selects openai-egress, so the raw key stays out of the sandbox unless a study opts in. */
|
|
10
10
|
runtimeAuth: StudyRuntimeAuth | undefined;
|
|
11
11
|
/** The operator environment the key value is read from (process.env or a test fake). */
|
|
12
12
|
env: Record<string, string | undefined>;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { E2B_SYSTEM_CA_BUNDLE, OPENAI_EGRESS_PLACEHOLDER } from "./runtime-auth.js";
|
|
1
|
+
import { DEFAULT_RUNTIME_AUTH, E2B_SYSTEM_CA_BUNDLE, OPENAI_EGRESS_PLACEHOLDER, } from "./runtime-auth.js";
|
|
2
2
|
import { TERMINAL_PRODUCT_STUDY_PROVIDER_METADATA } from "./types.js";
|
|
3
3
|
/**
|
|
4
|
-
* Resolve the runtime key on the host.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* credential is forwarded. The real value must never be logged or persisted in either mode.
|
|
4
|
+
* Resolve the runtime key on the host. openai-egress, the default, returns an inert command env
|
|
5
|
+
* while retaining the actual value for the external transform and literal redaction; openai-env
|
|
6
|
+
* passes it command-scoped. Only CODEX_API_KEY/OPENAI_API_KEY are accepted as sources. No other
|
|
7
|
+
* operator credential is forwarded. The real value must never be logged or persisted in either mode.
|
|
8
8
|
*/
|
|
9
9
|
export function buildRuntimeAuth(args) {
|
|
10
10
|
// The "openai-env" channel accepts CODEX_API_KEY or OPENAI_API_KEY as the runtime key source
|
|
@@ -46,7 +46,15 @@ export function buildRuntimeAuth(args) {
|
|
|
46
46
|
// GITHUB_TOKEN/GH_TOKEN, no payment/deploy/db/media key, excluded by construction. When the
|
|
47
47
|
// source was OPENAI_API_KEY, the same value is also injected as CODEX_API_KEY so codex exec's
|
|
48
48
|
// documented single-invocation auth channel is populated either way (see the comment above).
|
|
49
|
-
const mode = args.runtimeAuth ??
|
|
49
|
+
const mode = args.runtimeAuth ?? DEFAULT_RUNTIME_AUTH;
|
|
50
|
+
// Only the exact openai-env value places the raw key in the command, so a misspelled mode from a
|
|
51
|
+
// config that skipped the planner is refused, never read as openai-env.
|
|
52
|
+
if (mode !== "openai-egress" && mode !== "openai-env")
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
code: "HUMANISH_TERMINAL_CREDENTIAL_DENIED",
|
|
56
|
+
message: "Runtime auth must be openai-egress or openai-env; no key was placed.",
|
|
57
|
+
};
|
|
50
58
|
const envs = mode === "openai-egress"
|
|
51
59
|
? // Codex documents this verified-TLS trust channel. The stock image's default OpenSSL CA
|
|
52
60
|
// file can be absent even though E2B has installed its proxy CA in the system bundle.
|
|
@@ -6,6 +6,7 @@ import { resolveAutomaticAnalysis } from "../../analysis/automatic-config.js";
|
|
|
6
6
|
import { isReasoningEffort } from "../../actors/reasoning-effort.js";
|
|
7
7
|
import { actorRegistry, isTerminalActorDescriptor } from "../../actors/registry.js";
|
|
8
8
|
import { isNonEmpty, planBase } from "../../study/plan-base.js";
|
|
9
|
+
import { studyUrlCredentialReason } from "../../study/url-credentials.js";
|
|
9
10
|
import { desktopMediaValidationReason, taskProtocolValidationReason, } from "../../study/validation.js";
|
|
10
11
|
import { MAX_SANDBOX_MS } from "../../substrates/e2b/lifetime.js";
|
|
11
12
|
import { NODE_BOOTSTRAP_TIMEOUT_MS } from "../../subject/node-bootstrap.js";
|
|
@@ -26,6 +27,13 @@ export function planTerminalStudy(config, input) {
|
|
|
26
27
|
const mediaReason = desktopMediaValidationReason(config);
|
|
27
28
|
if (mediaReason)
|
|
28
29
|
return refuse("HUMANISH_TERMINAL_SUBJECT_INVALID", mediaReason);
|
|
30
|
+
// A library caller's config skips the parser, which refuses these first.
|
|
31
|
+
const credential = studyUrlCredentialReason(config);
|
|
32
|
+
if (credential)
|
|
33
|
+
return refuse("HUMANISH_TERMINAL_SUBJECT_INVALID", credential);
|
|
34
|
+
const runtimeAuth = config.execution?.runtimeAuth;
|
|
35
|
+
if (runtimeAuth !== undefined && runtimeAuth !== "openai-egress" && runtimeAuth !== "openai-env")
|
|
36
|
+
return refuse("HUMANISH_TERMINAL_CREDENTIAL_DENIED", "`execution.runtimeAuth` must be openai-egress or openai-env. Leave it out for openai-egress, which keeps the OpenAI key outside the sandbox.");
|
|
29
37
|
const analysis = resolveAutomaticAnalysis(config.review?.analysis);
|
|
30
38
|
if (!analysis.ok)
|
|
31
39
|
return refuse("HUMANISH_STUDY_ANALYSIS_INVALID", analysis.message);
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
// The safety contract (`docs/history/goals/terminal-product-lane/goal.md`) is enforced by construction in
|
|
12
12
|
// the files each item names, and checked by the verifier (verify/actor.ts
|
|
13
13
|
// validateTerminalProductEvidence):
|
|
14
|
-
// 1. Explicit key placement. openai-
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// 1. Explicit key placement. openai-egress (default) sends the raw runtime key only in the
|
|
15
|
+
// host-side E2B header transform and passes an inert command placeholder. The proxy is
|
|
16
|
+
// spendable by every sandbox process from creation; this protects the raw key, not provider
|
|
17
|
+
// spending. Opt-in openai-env injects the raw key command-scoped, never through
|
|
18
|
+
// Sandbox.create({envs}).
|
|
18
19
|
// Enforced in credentials.ts and runtime-auth.ts.
|
|
19
20
|
// 2. Fail-closed cap. The live key is never exercised without scenario.caps in force: maxUsd
|
|
20
21
|
// (default/require 0 = no-spend) + maxMinutes (wall-clock kill of the codex command).
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import type { StudyRuntimeAuth } from "../../study/types.js";
|
|
1
2
|
import type { E2BNetworkOptions } from "../../substrates/e2b/sdk.js";
|
|
3
|
+
/**
|
|
4
|
+
* The runtime auth a study gets when it declares none. A shell agent can read its own command env,
|
|
5
|
+
* so the raw key stays in the host-side E2B request unless a study declares openai-env.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_RUNTIME_AUTH: StudyRuntimeAuth;
|
|
2
8
|
/** E2B envd installs the sandbox-specific proxy CA into this system bundle before routing. */
|
|
3
9
|
export declare const E2B_SYSTEM_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
|
|
4
10
|
/** An inert nonsecret value satisfies Codex exec's auth prerequisite. E2B replaces the header. */
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime auth a study gets when it declares none. A shell agent can read its own command env,
|
|
3
|
+
* so the raw key stays in the host-side E2B request unless a study declares openai-env.
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_RUNTIME_AUTH = "openai-egress";
|
|
1
6
|
const OPENAI_EGRESS_HOST = "api.openai.com";
|
|
2
7
|
/** E2B envd installs the sandbox-specific proxy CA into this system bundle before routing. */
|
|
3
8
|
export const E2B_SYSTEM_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
|
package/dist/run/dry-run.js
CHANGED
|
@@ -11,6 +11,7 @@ import { implicitProjectDirectoryExists, readPackageName, validateCwd } from "./
|
|
|
11
11
|
import { loadDryRunInputs } from "./dry-run-inputs.js";
|
|
12
12
|
import { judgeExecution, judgePreview, OUTCOME_POLICIES, resultOk, verdictText, } from "./judge.js";
|
|
13
13
|
import { reviewOutcome } from "./display.js";
|
|
14
|
+
import { cli } from "../cli/invocation.js";
|
|
14
15
|
/**
|
|
15
16
|
* The preview route's run. The run scope closes the run it started on every exit, including
|
|
16
17
|
* the fail-closed ones. It renders an Observer only when `options.observer` asks for one.
|
|
@@ -49,7 +50,7 @@ async function runDryRunInScope(options, scope) {
|
|
|
49
50
|
if (!options.dryRun) {
|
|
50
51
|
return refused(requestedCwd, warnings, {
|
|
51
52
|
code: "HUMANISH_LIVE_RUN_UNIMPLEMENTED",
|
|
52
|
-
message:
|
|
53
|
+
message: `humanish run needs a study. List studies with ${cli("study list")}, or run ${cli("run --dry-run")} for a sample bundle.`,
|
|
53
54
|
});
|
|
54
55
|
}
|
|
55
56
|
const createdAt = new Date().toISOString();
|
package/dist/run/judge.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// 4. foldScorerFailures, which every route with a scorer applies before the run finishes;
|
|
13
13
|
// 5. the outcome: judgeExecution applies the route's policy to its execution failures, and
|
|
14
14
|
// resultOk gives the result's ok.
|
|
15
|
+
import { cli } from "../cli/invocation.js";
|
|
15
16
|
/**
|
|
16
17
|
* The participant said it reached the goal having taken no action and said nothing, and no stop
|
|
17
18
|
* condition ended the session. It most likely saw a blank or loading screen, so it is not a pass.
|
|
@@ -126,7 +127,7 @@ export const EXECUTION_FAILURE_KINDS = [
|
|
|
126
127
|
export function sandboxCleanupFailure(owner, warning, runId, recovery) {
|
|
127
128
|
return {
|
|
128
129
|
kind: "sandbox-cleanup",
|
|
129
|
-
message: `${owner}: ${warning ?? "Sandbox release is unconfirmed."} ${recovery ?? `Reclaim it by recorded id with
|
|
130
|
+
message: `${owner}: ${warning ?? "Sandbox release is unconfirmed."} ${recovery ?? `Reclaim it by recorded id with \`${cli(`reclaim --run ${runId}`)}\`.`}`,
|
|
130
131
|
};
|
|
131
132
|
}
|
|
132
133
|
/** Each route's outcome policy, in one place. */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Each local-only file, relative to the run directory, with why a shared copy omits it. */
|
|
2
|
+
export declare const LOCAL_ONLY_RUN_FILES: ReadonlyMap<string, string>;
|
|
3
|
+
/**
|
|
4
|
+
* Whether a run-relative posix path names a local-only file. Case is ignored, so a request that
|
|
5
|
+
* spells the name differently cannot reach the file on a case-insensitive filesystem.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isLocalOnlyRunFile(relativePath: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Whether `filePath` is one of the local-only files of the run at `runRoot` under another
|
|
10
|
+
* spelling: the same device and inode. A case-insensitive filesystem also folds Unicode, so a name
|
|
11
|
+
* check alone cannot rule every spelling out.
|
|
12
|
+
*/
|
|
13
|
+
export declare function isLocalOnlyRunFileIdentity(runRoot: string, filePath: string): Promise<boolean>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Run files that mean something only on the machine that recorded the run. sandbox-receipts.ndjson
|
|
2
|
+
// holds the raw sandbox ids reclaim kills by, and status.json holds the recording process's pid.
|
|
3
|
+
// Bundle export leaves both out of a shared copy, and the Observer servers never hand them out.
|
|
4
|
+
import { lstat } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { SANDBOX_RECEIPTS_ARTIFACT } from "./sandbox-receipts.js";
|
|
7
|
+
import { RUN_STATUS_FILE } from "./status.js";
|
|
8
|
+
/** Each local-only file, relative to the run directory, with why a shared copy omits it. */
|
|
9
|
+
export const LOCAL_ONLY_RUN_FILES = new Map([
|
|
10
|
+
[RUN_STATUS_FILE, "local process status is not a new attempt"],
|
|
11
|
+
[SANDBOX_RECEIPTS_ARTIFACT, "operational journal does not confer a derivative resource lease"],
|
|
12
|
+
]);
|
|
13
|
+
/**
|
|
14
|
+
* Whether a run-relative posix path names a local-only file. Case is ignored, so a request that
|
|
15
|
+
* spells the name differently cannot reach the file on a case-insensitive filesystem.
|
|
16
|
+
*/
|
|
17
|
+
export function isLocalOnlyRunFile(relativePath) {
|
|
18
|
+
return LOCAL_ONLY_RUN_FILES.has(relativePath.toLowerCase());
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Whether `filePath` is one of the local-only files of the run at `runRoot` under another
|
|
22
|
+
* spelling: the same device and inode. A case-insensitive filesystem also folds Unicode, so a name
|
|
23
|
+
* check alone cannot rule every spelling out.
|
|
24
|
+
*/
|
|
25
|
+
export async function isLocalOnlyRunFileIdentity(runRoot, filePath) {
|
|
26
|
+
const requested = await lstat(filePath, { bigint: true }).catch(() => null);
|
|
27
|
+
if (requested === null)
|
|
28
|
+
return false;
|
|
29
|
+
for (const name of LOCAL_ONLY_RUN_FILES.keys()) {
|
|
30
|
+
const local = await lstat(path.join(runRoot, name), { bigint: true }).catch(() => null);
|
|
31
|
+
if (local !== null && local.dev === requested.dev && local.ino === requested.ino)
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
package/dist/run/reclaim.d.ts
CHANGED
|
@@ -18,6 +18,11 @@ export interface ReclaimResult {
|
|
|
18
18
|
tagSearch: ReclaimTagSearch;
|
|
19
19
|
/** Creates that had not returned when reclaim finished; only the signal handler sees any. */
|
|
20
20
|
createsInFlight: number;
|
|
21
|
+
/**
|
|
22
|
+
* Why there was nothing to look for: `dry-run` when run.json or status.json records the run as
|
|
23
|
+
* a dry run, which creates no sandboxes. Reclaim then contacts E2B for nothing.
|
|
24
|
+
*/
|
|
25
|
+
reason?: "dry-run";
|
|
21
26
|
warnings: string[];
|
|
22
27
|
error?: {
|
|
23
28
|
code: "HUMANISH_RECLAIM_RUN_NOT_FOUND" | "HUMANISH_RECLAIM_E2B_DEBUG" | "HUMANISH_RECLAIM_MODULE_UNAVAILABLE" | "HUMANISH_RECLAIM_RECEIPTS_UNREADABLE";
|
package/dist/run/reclaim.js
CHANGED
|
@@ -11,12 +11,16 @@
|
|
|
11
11
|
import { E2B_DEBUG_ENV, e2bDebugMode, loadE2BDesktopModule, } from "../substrates/e2b/sdk.js";
|
|
12
12
|
import { containedPathAbsent, readContainedRegularFile, } from "./contained-output.js";
|
|
13
13
|
import { discardPreflightJournal, listPreflightJournals, preflightReclaimDecision, } from "./preflight-receipts.js";
|
|
14
|
-
import { resolveRunPath } from "./locate.js";
|
|
14
|
+
import { readRunJsonIfExists, resolveRunPath } from "./locate.js";
|
|
15
|
+
import { RUN_BUNDLE_FILE } from "./bundle.js";
|
|
16
|
+
import { RUN_STATUS_FILE } from "./status.js";
|
|
17
|
+
import { isRecord } from "./type-guards.js";
|
|
15
18
|
import { parseSandboxOwners, parseSandboxReceipts, SANDBOX_RECEIPTS_ARTIFACT, } from "./sandbox-receipts.js";
|
|
16
19
|
import { sandboxOwnerTags, } from "./sandbox-creates.js";
|
|
17
20
|
import { earlierOutcomes, GONE, RECLAIM_RECEIPT_ARTIFACT, RECLAIM_RESULT_SCHEMA, receiptWriter, rootState, searchTagSets, settledWithin, targetSet, } from "./reclaim-outcomes.js";
|
|
18
21
|
import { redactText, toErrorMessage } from "../evidence/redaction.js";
|
|
19
22
|
import { runIdOf } from "./paths.js";
|
|
23
|
+
import { cli } from "../cli/invocation.js";
|
|
20
24
|
export { RECLAIM_RECEIPT_ARTIFACT };
|
|
21
25
|
export function reclaimRunSandboxes(cwd, runInput, hooks = {}) {
|
|
22
26
|
return reclaimRun(cwd, runInput, () => resolveRunPath(cwd, runInput), hooks);
|
|
@@ -54,11 +58,14 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
|
|
|
54
58
|
...base,
|
|
55
59
|
error: {
|
|
56
60
|
code: "HUMANISH_RECLAIM_RUN_NOT_FOUND",
|
|
57
|
-
message: `No run found for "${runInput}" (use
|
|
61
|
+
message: `No run found for "${runInput}" (use \`${cli("runs")}\` to list runs).`,
|
|
58
62
|
},
|
|
59
63
|
};
|
|
60
64
|
}
|
|
61
65
|
const runId = runIdOf(runPaths);
|
|
66
|
+
// The signal handler passes the creates it stopped and decides from what they reported.
|
|
67
|
+
if (hooks.creates === undefined && (await recordedDryRun(runPaths)))
|
|
68
|
+
return { ...base, ok: true, state: "clean", runId, reason: "dry-run" };
|
|
62
69
|
const reclaimed = await reclaimRoot(runPaths, runId, hooks, warnings);
|
|
63
70
|
if (reclaimed.kind === "module-unavailable")
|
|
64
71
|
return {
|
|
@@ -85,6 +92,31 @@ async function reclaimRun(cwd, runInput, locate, hooks) {
|
|
|
85
92
|
createsInFlight,
|
|
86
93
|
};
|
|
87
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Whether the run records that it was a dry run: run.json and status.json, each one present, say
|
|
97
|
+
* `dry-run`. A run with neither record is not taken for one. A journal with any line in it, or an
|
|
98
|
+
* earlier reclaim receipt that names a sandbox, means a create ran, so that run is reclaimed as a
|
|
99
|
+
* live one whatever its records say.
|
|
100
|
+
*/
|
|
101
|
+
async function recordedDryRun(runPaths) {
|
|
102
|
+
const modes = [];
|
|
103
|
+
for (const file of [RUN_BUNDLE_FILE, RUN_STATUS_FILE]) {
|
|
104
|
+
const record = await readRunJsonIfExists(runPaths, file);
|
|
105
|
+
if (isRecord(record))
|
|
106
|
+
modes.push(record.mode);
|
|
107
|
+
}
|
|
108
|
+
if (modes.length === 0 || modes.some((mode) => mode !== "dry-run"))
|
|
109
|
+
return false;
|
|
110
|
+
if (!(await containedPathAbsent(runPaths, RECLAIM_RECEIPT_ARTIFACT))) {
|
|
111
|
+
const earlier = await readRunJsonIfExists(runPaths, RECLAIM_RECEIPT_ARTIFACT);
|
|
112
|
+
if (!isRecord(earlier) || !Array.isArray(earlier.outcomes) || earlier.outcomes.length > 0)
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
if (await containedPathAbsent(runPaths, SANDBOX_RECEIPTS_ARTIFACT))
|
|
116
|
+
return true;
|
|
117
|
+
const journal = await readContainedRegularFile(runPaths, SANDBOX_RECEIPTS_ARTIFACT);
|
|
118
|
+
return journal !== null && journal.toString("utf8").trim() === "";
|
|
119
|
+
}
|
|
88
120
|
/**
|
|
89
121
|
* Kill the sandboxes left by interrupted `humanish study check` probes. A probe journals under
|
|
90
122
|
* .humanish/preflight/<probe-id>/ and removes the journal after a confirmed kill, so what is left
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { resolveRunsRoot } from "./paths.js";
|
|
4
|
+
import { cli } from "../cli/invocation.js";
|
|
4
5
|
/**
|
|
5
6
|
* The message for a run that is not there: how to start one when the project has no runs, or how
|
|
6
7
|
* to list them when it has. Only the runs directory's entry names are read.
|
|
@@ -10,6 +11,6 @@ export async function runNotFoundMessage(cwdInput, runInput) {
|
|
|
10
11
|
const entries = await readdir(resolveRunsRoot(cwd)).catch(() => []);
|
|
11
12
|
const hasRun = entries.some((name) => !name.startsWith(".") && !name.endsWith(".json"));
|
|
12
13
|
return hasRun
|
|
13
|
-
? `No run ${runInput};
|
|
14
|
-
: `No runs in ${cwd} yet; start one with
|
|
14
|
+
? `No run ${runInput}; ${cli("runs")} lists them.`
|
|
15
|
+
: `No runs in ${cwd} yet; start one with ${cli("run first-run")}.`;
|
|
15
16
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// The sandboxes this process is creating for each run directory, and the E2B tags that name the
|
|
2
|
-
// directory.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// directory. E2B creates a sandbox before its id reaches this process: on @e2b/desktop the startup
|
|
3
|
+
// guard reports the id, and its receipt is written, only once the SDK has the handle, and a
|
|
4
|
+
// create from an SDK without the guard reports it when `Sandbox.create` resolves, after desktop
|
|
5
|
+
// startup. The CLI's signal handler (src/cli/commands/run-signals.ts) uses this registry to cover
|
|
6
|
+
// the window before a receipt lands:
|
|
5
7
|
// it stops new creates, kills every id a create has reported, and kills each in-flight create's
|
|
6
8
|
// sandbox as soon as its id arrives. The tags let `humanish reclaim` find a sandbox whose id
|
|
7
9
|
// never reached this process at all.
|
|
@@ -23,12 +23,13 @@ export interface ParsedSandboxReceipt extends Omit<SandboxReceipt, "provider"> {
|
|
|
23
23
|
/** The ids this process has receipted for `root`, whether or not the journal write landed. */
|
|
24
24
|
export declare function appendedSandboxIds(root: PreparedOutputRoot): string[];
|
|
25
25
|
/**
|
|
26
|
-
* Append one receipt. Best-effort by design: the receipt exists to
|
|
27
|
-
* receipt write must never fail the participant. The only cost of a
|
|
28
|
-
* this id and the TTL backstop covers it instead. Containment is
|
|
29
|
-
* artifact write uses; append (not atomic-replace) keeps racing
|
|
26
|
+
* Append one receipt; false when the write failed. Best-effort by design: the receipt exists to
|
|
27
|
+
* protect the run, so a failed receipt write must never fail the participant. The only cost of a
|
|
28
|
+
* miss is that `reclaim` cannot see this id and the TTL backstop covers it instead. Containment is
|
|
29
|
+
* the same prepare step every artifact write uses; append (not atomic-replace) keeps racing
|
|
30
|
+
* participants' receipts intact.
|
|
30
31
|
*/
|
|
31
|
-
export declare function appendSandboxReceipt(root: PreparedOutputRoot, receipt: SandboxReceipt): Promise<
|
|
32
|
+
export declare function appendSandboxReceipt(root: PreparedOutputRoot, receipt: SandboxReceipt): Promise<boolean>;
|
|
32
33
|
/** Append one owner line; false when the write failed, which never fails the create. */
|
|
33
34
|
export declare function appendSandboxOwner(root: PreparedOutputRoot, owner: SandboxOwnerTags): Promise<boolean>;
|
|
34
35
|
/** The owner tags every sandbox carries; sandboxOwnerTags in src/run/sandbox-creates.ts. */
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
// Every created sandbox id is journaled to the run dir
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Every created sandbox id is journaled to the run dir as soon as it reaches this process: when the
|
|
2
|
+
// desktop startup guard reports it, or when create returns, before any work happens in it. The
|
|
3
|
+
// participant loop lives in the caller's local process, so a sleeping laptop, crash or kill loses
|
|
4
|
+
// every in-memory id; this append-only file keeps them for `humanish reclaim`, and tells verify and
|
|
5
|
+
// the sweep which ids to look for.
|
|
4
6
|
import { appendFile } from "node:fs/promises";
|
|
5
7
|
import { prepareContainedOutputFile } from "./contained-output.js";
|
|
6
8
|
export const SANDBOX_RECEIPTS_ARTIFACT = "sandbox-receipts.ndjson";
|
|
@@ -22,10 +24,11 @@ export function appendedSandboxIds(root) {
|
|
|
22
24
|
return [...(appended.get(rootKey(root)) ?? [])];
|
|
23
25
|
}
|
|
24
26
|
/**
|
|
25
|
-
* Append one receipt. Best-effort by design: the receipt exists to
|
|
26
|
-
* receipt write must never fail the participant. The only cost of a
|
|
27
|
-
* this id and the TTL backstop covers it instead. Containment is
|
|
28
|
-
* artifact write uses; append (not atomic-replace) keeps racing
|
|
27
|
+
* Append one receipt; false when the write failed. Best-effort by design: the receipt exists to
|
|
28
|
+
* protect the run, so a failed receipt write must never fail the participant. The only cost of a
|
|
29
|
+
* miss is that `reclaim` cannot see this id and the TTL backstop covers it instead. Containment is
|
|
30
|
+
* the same prepare step every artifact write uses; append (not atomic-replace) keeps racing
|
|
31
|
+
* participants' receipts intact.
|
|
29
32
|
*/
|
|
30
33
|
export async function appendSandboxReceipt(root, receipt) {
|
|
31
34
|
const key = rootKey(root);
|
|
@@ -40,9 +43,11 @@ export async function appendSandboxReceipt(root, receipt) {
|
|
|
40
43
|
try {
|
|
41
44
|
const filePath = await prepareContainedOutputFile(root, SANDBOX_RECEIPTS_ARTIFACT);
|
|
42
45
|
await appendFile(filePath, `${JSON.stringify(receipt)}\n`, "utf8");
|
|
46
|
+
return true;
|
|
43
47
|
}
|
|
44
48
|
catch {
|
|
45
49
|
// Swallowed on purpose; see the contract above.
|
|
50
|
+
return false;
|
|
46
51
|
}
|
|
47
52
|
}
|
|
48
53
|
/** Append one owner line; false when the write failed, which never fails the create. */
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type AgentsSectionReplacement =
|
|
2
|
+
/** The file has no humanish section. */
|
|
3
|
+
{
|
|
4
|
+
kind: "absent";
|
|
5
|
+
}
|
|
6
|
+
/** A start marker with no end marker, in text no release wrote: possibly hand-edited. */
|
|
7
|
+
| {
|
|
8
|
+
kind: "unknown";
|
|
9
|
+
}
|
|
10
|
+
/** The file with humanish's section replaced; equal to the input when it was already current. */
|
|
11
|
+
| {
|
|
12
|
+
kind: "replaced";
|
|
13
|
+
contents: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* `existing` with humanish's section replaced by `section` (as `agentsSection()` returns it, with
|
|
17
|
+
* its leading blank line). The replaced range runs from the start of the heading line that holds
|
|
18
|
+
* the start marker to the end of the end marker's line, or, without an end marker, through the
|
|
19
|
+
* text of the release that wrote it.
|
|
20
|
+
*/
|
|
21
|
+
export declare function replaceAgentsSection(existing: string, section: string): AgentsSectionReplacement;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Finds humanish's own section in a project's `AGENTS.md` so init can replace it on a rerun and
|
|
2
|
+
// leave every other byte alone. Releases up to 0.110.1 wrote the section with a start marker and
|
|
3
|
+
// no end marker, so for those the section's extent is known only by matching a text init wrote.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { AGENTS_SECTION_END_MARKER, AGENTS_SECTION_MARKER } from "../cli/first-run-path.js";
|
|
6
|
+
/**
|
|
7
|
+
* Each section text a release wrote before the end marker existed, from its `## humanish` heading
|
|
8
|
+
* to its final newline: the UTF-16 length and the sha256 of its UTF-8 bytes. Comparing digests
|
|
9
|
+
* keeps the retired command and key names those versions taught out of the source.
|
|
10
|
+
*/
|
|
11
|
+
const SECTIONS_WITHOUT_END_MARKER = [
|
|
12
|
+
{
|
|
13
|
+
releases: "0.59.0 to 0.80.0",
|
|
14
|
+
length: 1171,
|
|
15
|
+
sha256: "3739c98204cc5b2df603238398bffc5c4a491782a3ab72475b0ad9d967698a11",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
releases: "0.81.0 to 0.93.1",
|
|
19
|
+
length: 1191,
|
|
20
|
+
sha256: "169d646f329f35af1b5e51eb02cd0454a717cf29a447a230f3aa2d5a7354729c",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
releases: "0.94.0 to 0.99.1",
|
|
24
|
+
length: 1214,
|
|
25
|
+
sha256: "68f987769893f041ab78e0b1cb4cd8db14a438511f15b58f9eaa412ea002b51f",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
releases: "0.100.0 to 0.105.0",
|
|
29
|
+
length: 1548,
|
|
30
|
+
sha256: "f60c37003d21b1d8280d002f203130c60bc3c23454b018b3207407faa01cb76f",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
releases: "0.106.0 to 0.107.0",
|
|
34
|
+
length: 1686,
|
|
35
|
+
sha256: "c2cd9d831e17021ad4477f300dbea13df1613bec8f30ebbabfabcd749b73f7f7",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
releases: "0.108.0",
|
|
39
|
+
length: 1679,
|
|
40
|
+
sha256: "6352ec71d7c5073b2bf4993293753944eaf7cfd21169a311f42d9f52bdacfead",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
releases: "0.109.0 to 0.110.0",
|
|
44
|
+
length: 1681,
|
|
45
|
+
sha256: "2559a93463cf50225aadf6753e84dd6e32b25aa3afdf8066e90d19c5d88d0ef0",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
releases: "0.110.1",
|
|
49
|
+
length: 2014,
|
|
50
|
+
sha256: "9fb4cb94400c7447ab0fece861339e50de8e9be99797cf0763d7502be0fe668d",
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* `existing` with humanish's section replaced by `section` (as `agentsSection()` returns it, with
|
|
55
|
+
* its leading blank line). The replaced range runs from the start of the heading line that holds
|
|
56
|
+
* the start marker to the end of the end marker's line, or, without an end marker, through the
|
|
57
|
+
* text of the release that wrote it.
|
|
58
|
+
*/
|
|
59
|
+
export function replaceAgentsSection(existing, section) {
|
|
60
|
+
const marker = existing.indexOf(AGENTS_SECTION_MARKER);
|
|
61
|
+
if (marker < 0)
|
|
62
|
+
return { kind: "absent" };
|
|
63
|
+
const start = existing.lastIndexOf("\n", marker) + 1;
|
|
64
|
+
const replacement = section.replace(/^\n/, "");
|
|
65
|
+
const endMarker = existing.indexOf(AGENTS_SECTION_END_MARKER, marker);
|
|
66
|
+
if (endMarker >= 0) {
|
|
67
|
+
const lineEnd = existing.indexOf("\n", endMarker);
|
|
68
|
+
const end = lineEnd < 0 ? existing.length : lineEnd + 1;
|
|
69
|
+
return {
|
|
70
|
+
kind: "replaced",
|
|
71
|
+
contents: existing.slice(0, start) + replacement + existing.slice(end),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
for (const known of SECTIONS_WITHOUT_END_MARKER) {
|
|
75
|
+
const candidate = existing.slice(start, start + known.length);
|
|
76
|
+
if (candidate.length !== known.length)
|
|
77
|
+
continue;
|
|
78
|
+
if (createHash("sha256").update(candidate).digest("hex") !== known.sha256)
|
|
79
|
+
continue;
|
|
80
|
+
return {
|
|
81
|
+
kind: "replaced",
|
|
82
|
+
contents: existing.slice(0, start) + replacement + existing.slice(start + known.length),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { kind: "unknown" };
|
|
86
|
+
}
|