@proagentstore/cli 0.4.72 → 0.4.74
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.
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { LOCAL_BROWSER_SOURCE_FAILURE_REASONS, redactDetail } from "./contract.js";
|
|
2
2
|
/** The browser tools a research run may use. Everything else from the browser is not even listed. */
|
|
3
3
|
const READ_TOOLS = new Set(["browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_wait_for", "browser_click"]);
|
|
4
|
+
/** The research tools the bridge adds; the CLI records its results through these. */
|
|
5
|
+
const RESEARCH_TOOL_NAMES = ["record_finding", "report_source_failure", "finish_research"];
|
|
4
6
|
/** Refused BY NAME with the reason, so a CLI that guesses one is told why rather than "unknown tool". */
|
|
5
7
|
const WRITE_TOOLS = new Set([
|
|
6
8
|
"browser_type",
|
|
@@ -18,6 +20,11 @@ const WRITE_TOOLS = new Set([
|
|
|
18
20
|
/** Roles a click may target in research mode: moving between pages, never acting on one. */
|
|
19
21
|
const CLICKABLE_ROLES = new Set(["link", "tab", "menuitem", "treeitem", "option"]);
|
|
20
22
|
const PAGINATION_NAME = /^(next|previous|prev|more|load more|show more|see more|view more|older|newer|page \d+|\d+|›|»|‹|«)( page| results| jobs)?$/i;
|
|
23
|
+
/**
|
|
24
|
+
* Every tool name the bridge can ever list (#952) — the exact set a CLI may be told to trust
|
|
25
|
+
* without asking. Write tools are not in it, so trusting this set cannot trust a write.
|
|
26
|
+
*/
|
|
27
|
+
export const BRIDGE_TOOL_NAMES = [...READ_TOOLS, ...RESEARCH_TOOL_NAMES];
|
|
21
28
|
const RESEARCH_TOOLS = [
|
|
22
29
|
{
|
|
23
30
|
name: "record_finding",
|
|
@@ -157,22 +164,35 @@ export class BrowserBridge {
|
|
|
157
164
|
const last = [...this.visited].pop();
|
|
158
165
|
return last ? { domain: last } : {};
|
|
159
166
|
}
|
|
160
|
-
|
|
161
|
-
|
|
167
|
+
/** Refuse, on the trace — naming the site and the owner's decision behind it when there is one. */
|
|
168
|
+
refuse(tool, reason, host) {
|
|
169
|
+
const consentId = host ? this.host.consentIdFor(host) : undefined;
|
|
170
|
+
this.host.emit({ type: "policy.decision", ...(host ? { domain: host } : {}), ...(consentId ? { consentId } : {}), detail: { tool, decision: "refused", reason } });
|
|
162
171
|
return text(reason, true);
|
|
163
172
|
}
|
|
173
|
+
/** Hosts already admitted on this run — a site is recorded as allowed once, not on every page. */
|
|
174
|
+
admitted = new Set();
|
|
175
|
+
allowed(host, basis) {
|
|
176
|
+
if (this.admitted.has(host))
|
|
177
|
+
return;
|
|
178
|
+
this.admitted.add(host);
|
|
179
|
+
const consentId = basis === "consent" ? this.host.consentIdFor(host) : undefined;
|
|
180
|
+
this.host.emit({ type: "policy.decision", domain: host, ...(consentId ? { consentId } : {}), detail: { decision: "allowed", basis } });
|
|
181
|
+
}
|
|
164
182
|
/** May the run be on this host — asking the owner, and waiting, when it is a new one. */
|
|
165
183
|
async admit(host, url) {
|
|
166
184
|
if (this.host.isDenied(host))
|
|
167
185
|
return `${host} is on this run's deny list.`;
|
|
168
|
-
if (this.host.isPermitted(host))
|
|
186
|
+
if (this.host.isPermitted(host)) {
|
|
187
|
+
this.allowed(host, this.host.consentIdFor(host) ? "consent" : "allow_list");
|
|
169
188
|
return null;
|
|
189
|
+
}
|
|
170
190
|
if (this.host.allowListOnly())
|
|
171
191
|
return `${host} is not on this run's list of allowed sites.`;
|
|
172
192
|
this.host.emit({ type: "consent.requested", url, domain: host, detail: { scope: "navigate" } });
|
|
173
193
|
const outcome = await this.host.pause("consent_required", { domain: host });
|
|
174
194
|
if (outcome === "resumed" && this.host.isPermitted(host) && !this.host.isDenied(host)) {
|
|
175
|
-
this.
|
|
195
|
+
this.allowed(host, "consent");
|
|
176
196
|
return null;
|
|
177
197
|
}
|
|
178
198
|
return `The owner did not allow ${host}. Skip it and record it with report_source_failure (access_denied).`;
|
|
@@ -185,7 +205,7 @@ export class BrowserBridge {
|
|
|
185
205
|
return this.refuse("browser_navigate", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call finish_research now.`);
|
|
186
206
|
const refusal = await this.admit(host, url);
|
|
187
207
|
if (refusal)
|
|
188
|
-
return this.refuse("browser_navigate", refusal);
|
|
208
|
+
return this.refuse("browser_navigate", refusal, host);
|
|
189
209
|
const res = await this.browser.callTool("browser_navigate", { url });
|
|
190
210
|
if (res.isError)
|
|
191
211
|
return { content: [{ type: "text", text: textOf(res) }], isError: true };
|
|
@@ -47,16 +47,54 @@ export const LOCAL_BROWSER_EVENT_TYPES = [
|
|
|
47
47
|
"run.resumed",
|
|
48
48
|
"note",
|
|
49
49
|
];
|
|
50
|
-
export const LOCAL_BROWSER_PLATFORM_EVENT_TYPES = ["run.requested", "runner.dispatched", "run.ended"];
|
|
50
|
+
export const LOCAL_BROWSER_PLATFORM_EVENT_TYPES = ["run.requested", "runner.dispatched", "run.ended", "review.decision"];
|
|
51
51
|
export const LOCAL_BROWSER_ENGINE_AUTHS = ["subscription", "machine-login", "api-key", "missing_login", "unknown"];
|
|
52
52
|
export const LOCAL_BROWSER_SOURCE_FAILURE_REASONS = ["login_required", "captcha", "paywall", "access_denied", "robots", "timeout", "error"];
|
|
53
53
|
export const LOCAL_BROWSER_CAPS = { findings: 200, sourceFailures: 200, text: 4000, fieldCount: 40 };
|
|
54
54
|
const SENSITIVE_KEY = /cookie|password|passwd|secret|token|authorization|api[_-]?key|otp|form[_-]?values?|credential/i;
|
|
55
|
+
/** An env var NAME whose value is a credential. */
|
|
56
|
+
const SECRET_ENV_NAME = /KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|COOKIE|SESSION|AUTH/i;
|
|
57
|
+
/** Credential-shaped text, wherever it appears — the patterns a CLI or a page tends to print. */
|
|
58
|
+
const SECRET_PATTERNS = [
|
|
59
|
+
// NAME=value for a credential-named variable: keep the name, drop the value.
|
|
60
|
+
[/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)[A-Z0-9_]*)\s*[=:]\s*["']?[^\s"']+["']?/g, "$1=[REDACTED]"],
|
|
61
|
+
[/\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{10,}/gi, "$1 [REDACTED]"],
|
|
62
|
+
[/\bsk-[A-Za-z0-9_-]{20,}/g, "[REDACTED]"],
|
|
63
|
+
[/\b(?:ghp|gho|ghs|ghu|github_pat|xox[abpr])_[A-Za-z0-9_]{20,}/g, "[REDACTED]"],
|
|
64
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "[REDACTED]"],
|
|
65
|
+
// Base64-ish runs over 40 characters: keys, tokens, session blobs.
|
|
66
|
+
[/[A-Za-z0-9+/_-]{41,}={0,2}/g, "[REDACTED]"],
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* The values of credential-named variables in an environment, longest first — what `redactText`
|
|
70
|
+
* must never let through even when the value has no recognisable shape.
|
|
71
|
+
*/
|
|
72
|
+
export function secretEnvValues(env) {
|
|
73
|
+
return Object.entries(env)
|
|
74
|
+
.filter(([k, v]) => SECRET_ENV_NAME.test(k) && typeof v === "string" && v.trim().length >= 8)
|
|
75
|
+
.map(([, v]) => v.trim())
|
|
76
|
+
.sort((a, b) => b.length - a.length);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Remove credentials from free text before it is stored (#947): first every known secret value,
|
|
80
|
+
* verbatim, then anything credential-shaped. Used on CLI output that becomes an error, on result
|
|
81
|
+
* text, and on every string in a trace detail.
|
|
82
|
+
*/
|
|
83
|
+
export function redactText(text, secrets = []) {
|
|
84
|
+
let out = text;
|
|
85
|
+
for (const s of secrets)
|
|
86
|
+
if (s.length >= 8)
|
|
87
|
+
out = out.split(s).join("[REDACTED]");
|
|
88
|
+
for (const [re, sub] of SECRET_PATTERNS)
|
|
89
|
+
out = out.replace(re, sub);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
55
92
|
/**
|
|
56
|
-
* Drop sensitive keys and bound sizes. A trace is for a supervisor
|
|
57
|
-
* carry a cookie, a password, a form value or a key — whatever the
|
|
93
|
+
* Drop sensitive keys, redact credential-shaped values and bound sizes. A trace is for a supervisor
|
|
94
|
+
* to read, so nothing in it may carry a cookie, a password, a form value or a key — whatever the
|
|
95
|
+
* runner sent.
|
|
58
96
|
*/
|
|
59
|
-
export function redactDetail(detail, depth = 0) {
|
|
97
|
+
export function redactDetail(detail, depth = 0, secrets = []) {
|
|
60
98
|
if (!detail || typeof detail !== "object" || Array.isArray(detail) || depth > 3)
|
|
61
99
|
return undefined;
|
|
62
100
|
const out = {};
|
|
@@ -64,13 +102,13 @@ export function redactDetail(detail, depth = 0) {
|
|
|
64
102
|
if (SENSITIVE_KEY.test(k))
|
|
65
103
|
continue;
|
|
66
104
|
if (typeof v === "string")
|
|
67
|
-
out[k] = v.slice(0, 1000);
|
|
105
|
+
out[k] = redactText(v.slice(0, 1000), secrets);
|
|
68
106
|
else if (typeof v === "number" || typeof v === "boolean" || v === null)
|
|
69
107
|
out[k] = v;
|
|
70
108
|
else if (Array.isArray(v))
|
|
71
|
-
out[k] = v.slice(0, 20).map((x) => (typeof x === "string" ? x.slice(0, 300) : typeof x === "number" || typeof x === "boolean" ? x : null));
|
|
109
|
+
out[k] = v.slice(0, 20).map((x) => (typeof x === "string" ? redactText(x.slice(0, 300), secrets) : typeof x === "number" || typeof x === "boolean" ? x : null));
|
|
72
110
|
else if (typeof v === "object") {
|
|
73
|
-
const nested = redactDetail(v, depth + 1);
|
|
111
|
+
const nested = redactDetail(v, depth + 1, secrets);
|
|
74
112
|
if (nested)
|
|
75
113
|
out[k] = nested;
|
|
76
114
|
}
|
|
@@ -167,8 +205,10 @@ export function parseLocalBrowserResult(raw) {
|
|
|
167
205
|
const detail = str(r.detail, 1000);
|
|
168
206
|
sourceFailures.push(detail ? { url, reason, detail } : { url, reason });
|
|
169
207
|
}
|
|
170
|
-
const out = { runId, outcome, findings, sourceFailures, summary: str(o.summary, LOCAL_BROWSER_CAPS.text) ?? "", traceId, engineAuth };
|
|
171
|
-
|
|
208
|
+
const out = { runId, outcome, findings, sourceFailures, summary: redactText(str(o.summary, LOCAL_BROWSER_CAPS.text) ?? ""), traceId, engineAuth };
|
|
209
|
+
// Redacted again here, whatever the runner did: an error is CLI output, and CLIs print keys.
|
|
210
|
+
const rawError = str(o.error, 1000);
|
|
211
|
+
const error = rawError === null ? null : redactText(rawError);
|
|
172
212
|
if (outcome === "failed")
|
|
173
213
|
out.error = error ?? "The run failed without a reason.";
|
|
174
214
|
return { result: out };
|
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* - Codex: `--sandbox read-only` (its shell cannot write, and per Codex's sandbox design has no
|
|
13
13
|
* network), `--ignore-user-config` (no MCP servers or providers from the user's own config — auth
|
|
14
14
|
* still comes from the login), web search switched off, and the bridge as its only MCP server.
|
|
15
|
+
* `codex exec` runs with approval policy `never`, so an MCP tool that needs approval is simply
|
|
16
|
+
* refused — a live run on 2026-10-07 opened no page for exactly that reason (#952). The bridge is
|
|
17
|
+
* therefore pre-approved, and ONLY it: `default_tools_approval_mode="approve"` on the
|
|
18
|
+
* `pags_browser` server, and `enabled_tools` pinned to the bridge's own tool names
|
|
19
|
+
* (`BRIDGE_TOOL_NAMES`). Nothing else is approved — the shell stays read-only with no network,
|
|
20
|
+
* there is no other server — and the bridge still enforces sites, consent, limits and the
|
|
21
|
+
* research-only tool set in the runner, whatever the CLI is allowed to call.
|
|
15
22
|
* NOT VERIFIED END TO END on a live machine: that read-only shell commands get no network, and
|
|
16
23
|
* which of the two web-search keys a given Codex version honours. #947 holds that test.
|
|
17
24
|
*
|
|
@@ -21,6 +28,7 @@
|
|
|
21
28
|
* turns a subscription run into per-token billing.
|
|
22
29
|
*/
|
|
23
30
|
import { mergeEnv } from "../coding/engine-env.js";
|
|
31
|
+
import { BRIDGE_TOOL_NAMES } from "./bridge.js";
|
|
24
32
|
import { resolveEngineAuth } from "../coding/engine-auth.js";
|
|
25
33
|
/** The bridge's MCP server name — and so the prefix of its tools inside the CLI. */
|
|
26
34
|
export const BRIDGE_SERVER_NAME = "pags_browser";
|
|
@@ -89,6 +97,11 @@ export function buildEngineSpec(input) {
|
|
|
89
97
|
`${key}.env=${envTable}`,
|
|
90
98
|
"-c",
|
|
91
99
|
`${key}.tool_timeout_sec=${Math.ceil(input.toolTimeoutMs / 1000)}`,
|
|
100
|
+
// Pre-approve the bridge, and only its own tools (#952); see the note atop this file.
|
|
101
|
+
"-c",
|
|
102
|
+
`${key}.default_tools_approval_mode="approve"`,
|
|
103
|
+
"-c",
|
|
104
|
+
`${key}.enabled_tools=[${BRIDGE_TOOL_NAMES.map(toml).join(", ")}]`,
|
|
92
105
|
// Two spellings across Codex versions; an unknown key is ignored, so both are set.
|
|
93
106
|
"-c",
|
|
94
107
|
'web_search="disabled"',
|
|
@@ -26,7 +26,7 @@ import { fileURLToPath } from "node:url";
|
|
|
26
26
|
import { RunnerInputError } from "../errors.js";
|
|
27
27
|
import { BrowserBridge, domainWithin } from "./bridge.js";
|
|
28
28
|
import { BRIDGE_ENV } from "./bridge-stdio.js";
|
|
29
|
-
import { LOCAL_BROWSER_AUTH_MODES, LOCAL_BROWSER_ENGINES, LOCAL_BROWSER_TASK_TYPE, redactDetail, } from "./contract.js";
|
|
29
|
+
import { LOCAL_BROWSER_AUTH_MODES, LOCAL_BROWSER_ENGINES, LOCAL_BROWSER_TASK_TYPE, redactDetail, redactText, secretEnvValues, } from "./contract.js";
|
|
30
30
|
import { buildEngineSpec, finalText, missingLogin, observedEngineAuth, researchPrompt, signInHelp } from "./engine.js";
|
|
31
31
|
const MAX_EVENTS = 2000;
|
|
32
32
|
const OUTPUT_LINES = 400;
|
|
@@ -76,11 +76,28 @@ export function parseEnvelope(raw) {
|
|
|
76
76
|
authMode: o.authMode,
|
|
77
77
|
workspace: ws.kind === "path" ? { kind: "path", path: ws.path } : { kind: "scratch" },
|
|
78
78
|
browserProfile: o.browserProfile,
|
|
79
|
-
policy: {
|
|
79
|
+
policy: {
|
|
80
|
+
mode: "research_only",
|
|
81
|
+
allowDomains: strList(p.allowDomains),
|
|
82
|
+
denyDomains: strList(p.denyDomains),
|
|
83
|
+
consentedDomains: strList(p.consentedDomains),
|
|
84
|
+
profileConsented: p.profileConsented === true,
|
|
85
|
+
consentIds: consentIdMap(p.consentIds),
|
|
86
|
+
},
|
|
80
87
|
limits,
|
|
81
88
|
resultSchema: { id: typeof schema.id === "string" ? schema.id : "findings", version: typeof schema.version === "number" ? schema.version : 1 },
|
|
82
89
|
};
|
|
83
90
|
}
|
|
91
|
+
/** Decision ids keyed by lowercase domain (or `*`), from an untrusted object. */
|
|
92
|
+
function consentIdMap(raw) {
|
|
93
|
+
const out = {};
|
|
94
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
95
|
+
for (const [k, v] of Object.entries(raw).slice(0, 200))
|
|
96
|
+
if (typeof v === "string" && SAFE_ID.test(v))
|
|
97
|
+
out[k.toLowerCase()] = v;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
84
101
|
/**
|
|
85
102
|
* A `~/…` workspace, resolved under the home directory — re-checked here against the real disk,
|
|
86
103
|
* because the API could only check the string. A symlink out of home is refused like `..` is.
|
|
@@ -142,6 +159,8 @@ export class LocalBrowserRuntime {
|
|
|
142
159
|
consented: new Set(envelope.policy.consentedDomains),
|
|
143
160
|
deny: new Set(envelope.policy.denyDomains),
|
|
144
161
|
profileConsented: envelope.policy.profileConsented,
|
|
162
|
+
consentIds: new Map(Object.entries(envelope.policy.consentIds ?? {})),
|
|
163
|
+
secrets: secretEnvValues(process.env),
|
|
145
164
|
activeSince: this.now(),
|
|
146
165
|
activeMs: 0,
|
|
147
166
|
waiters: [],
|
|
@@ -165,10 +184,13 @@ export class LocalBrowserRuntime {
|
|
|
165
184
|
if (outcome !== "resumed" || !run.profileConsented)
|
|
166
185
|
throw new Error("The owner did not allow research in their signed-in browser profile.");
|
|
167
186
|
}
|
|
187
|
+
if (e.browserProfile === "default")
|
|
188
|
+
this.emit(run, { type: "policy.decision", ...this.consentRef(run, "*"), detail: { scope: "signed_in_profile", decision: "consented" } });
|
|
168
189
|
run.browser = await this.deps.browserFor(e.browserProfile, run.dir);
|
|
169
190
|
run.bridge = new BrowserBridge(run.browser.tools, {
|
|
170
191
|
emit: (ev) => this.emit(run, ev),
|
|
171
192
|
pause: (reason, detail) => this.pause(run, reason, detail),
|
|
193
|
+
consentIdFor: (host) => this.consentRef(run, host).consentId,
|
|
172
194
|
isDenied: (host) => [...run.deny].some((d) => domainWithin(host, d)),
|
|
173
195
|
isPermitted: (host) => [...e.policy.allowDomains, ...run.consented].some((d) => domainWithin(host, d)),
|
|
174
196
|
allowListOnly: () => e.policy.allowDomains.length > 0,
|
|
@@ -186,6 +208,8 @@ export class LocalBrowserRuntime {
|
|
|
186
208
|
});
|
|
187
209
|
if (spec.mcpConfig)
|
|
188
210
|
writeFileSync(spec.mcpConfig.path, spec.mcpConfig.json, { mode: 0o600 });
|
|
211
|
+
// The env the engine actually gets — a key the owner chose to pass (api-key mode) included.
|
|
212
|
+
run.secrets = secretEnvValues({ ...process.env, ...spec.env });
|
|
189
213
|
run.engineAuth = observedEngineAuth(e.engine, spec.env);
|
|
190
214
|
this.emit(run, { type: "engine.auth_checked", detail: { engine: e.engine, authMode: e.authMode, engineAuth: run.engineAuth } });
|
|
191
215
|
const child = this.spawn(spec.command, spec.args, { cwd: run.workDir, env: spec.env, stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -231,7 +255,7 @@ export class LocalBrowserRuntime {
|
|
|
231
255
|
if (run.events.length >= MAX_EVENTS)
|
|
232
256
|
return;
|
|
233
257
|
const { detail: raw, ...rest } = ev;
|
|
234
|
-
const detail = redactDetail(raw);
|
|
258
|
+
const detail = redactDetail(raw, 0, run.secrets);
|
|
235
259
|
run.events.push({ ...rest, ...(detail && Object.keys(detail).length ? { detail } : {}), at: new Date(this.now()).toISOString(), seq: ++run.seq });
|
|
236
260
|
}
|
|
237
261
|
pause(run, reason, detail) {
|
|
@@ -262,6 +286,8 @@ export class LocalBrowserRuntime {
|
|
|
262
286
|
const run = this.get(String(o.runId ?? ""));
|
|
263
287
|
if (run.state === "ended")
|
|
264
288
|
throw new RunnerInputError("The run has ended", 409);
|
|
289
|
+
for (const [d, id] of Object.entries(consentIdMap(o.consentIds)))
|
|
290
|
+
run.consentIds.set(d, id);
|
|
265
291
|
for (const d of strList(o.consentedDomains))
|
|
266
292
|
run.consented.add(d);
|
|
267
293
|
for (const d of strList(o.denyDomains))
|
|
@@ -313,6 +339,16 @@ export class LocalBrowserRuntime {
|
|
|
313
339
|
return run.bridge.callTool(o.name, (o.args && typeof o.args === "object" ? o.args : {}));
|
|
314
340
|
throw new RunnerInputError("op must be list or call");
|
|
315
341
|
}
|
|
342
|
+
/** The decision that covers this host (or `*`), as an event field — empty when none does. */
|
|
343
|
+
consentRef(run, host) {
|
|
344
|
+
if (host === "*")
|
|
345
|
+
return run.consentIds.has("*") ? { consentId: run.consentIds.get("*") } : {};
|
|
346
|
+
let best = null;
|
|
347
|
+
for (const [d, id] of run.consentIds)
|
|
348
|
+
if (d !== "*" && domainWithin(host, d) && (!best || d.length > best[0].length))
|
|
349
|
+
best = [d, id];
|
|
350
|
+
return best ? { consentId: best[1] } : {};
|
|
351
|
+
}
|
|
316
352
|
get(runId) {
|
|
317
353
|
const run = this.runs.get(runId);
|
|
318
354
|
if (!run)
|
|
@@ -367,10 +403,11 @@ export class LocalBrowserRuntime {
|
|
|
367
403
|
outcome: r.outcome,
|
|
368
404
|
findings: run.bridge?.findings ?? [],
|
|
369
405
|
sourceFailures: run.bridge?.sourceFailures ?? [],
|
|
370
|
-
summary: (r.summary ?? "").slice(0, 4000),
|
|
406
|
+
summary: redactText((r.summary ?? "").slice(0, 4000), run.secrets),
|
|
371
407
|
traceId: run.envelope.runId,
|
|
372
408
|
engineAuth: r.engineAuth ?? run.engineAuth,
|
|
373
|
-
|
|
409
|
+
// CLI output becomes this text, and a CLI prints whatever it was given: redacted before it is kept.
|
|
410
|
+
...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
|
|
374
411
|
};
|
|
375
412
|
if (run.child && run.child.exitCode === null)
|
|
376
413
|
this.kill(run);
|