opencode-workflow-guard 1.13.3 → 1.13.5
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/docs/policies.md +2 -1
- package/package.json +1 -1
- package/src/lib/shell.ts +18 -0
- package/src/policies/boundary.ts +10 -3
- package/src/policies/tamper.ts +16 -8
- package/src/workflow-guard-ui.ts +119 -5
- package/test/test.mts +32 -1
package/docs/policies.md
CHANGED
|
@@ -60,7 +60,8 @@ It is a policy and enforcement layer, not an agent harness. Policies may constra
|
|
|
60
60
|
- Direct edits of the same paths through the `edit`/`write`/`apply_patch` tools
|
|
61
61
|
- Shell commands invoking `opencode auth`, `opencode config`, `opencode permission`, or `opencode run --auto`
|
|
62
62
|
- Evasion-normalized: quote-concatenation (`open''code.json`), escapes (`open\c\ode`), and glob wildcards (`opencode.jso?`) are stripped before matching.
|
|
63
|
-
- Collaboration commands (`gh issue`, `gh pr`, `glab issue`, `glab pr`, `az repos pr`) and document content mentioning guarded paths are not tampering: quoted arguments are command data, not shell syntax
|
|
63
|
+
- Collaboration commands (`gh issue`, `gh pr`, `glab issue`, `glab pr`, `az repos pr`) and document content mentioning guarded paths are not tampering: quoted arguments are command data, not shell syntax, and the redirect heuristic applies to shell commands only (write/edit content is guarded by the target path check instead).
|
|
64
|
+
- Quoted data spans are not shell syntax: pattern matching runs on the quote-stripped residue, where redirect targets are honored quoted or not and quote-concatenated paths keep normalizing. A quoted span containing a guarded path is never treated as a redirect or command verb; unquoted redirects into guarded paths stay blocked.
|
|
64
65
|
- Plan files under `.opencode/plans/` are exempt: opencode's plan mode writes agent-authored plan markdown there, and plans are content, not configuration. Everything else under `.opencode/` (including the directory itself) stays protected.
|
|
65
66
|
- **Read-only access is allowed** (`cat`, `less`, `grep`, `head`, `tail` on config files) - only modification attempts trigger the guard.
|
|
66
67
|
|
package/package.json
CHANGED
package/src/lib/shell.ts
CHANGED
|
@@ -75,6 +75,24 @@ export function decodeShellEscapes(text: string): string {
|
|
|
75
75
|
.replace(/\\([0-3][0-7]{2})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8)));
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// Reduce a shell segment to the syntax that actually executes: redirect
|
|
79
|
+
// targets are honored whether quoted or not (a quoted redirect target is
|
|
80
|
+
// still a real file), while SELF-CONTAINED quoted spans preceded by
|
|
81
|
+
// whitespace are command data and can never be shell redirects or
|
|
82
|
+
// operators. Quote spans glued to bare text are shell concatenation inside
|
|
83
|
+
// a single word (e.g. open''code.json) and are kept for the normalizer to
|
|
84
|
+
// flatten. A quoted span at segment start is NOT stripped: it can be a
|
|
85
|
+
// quoted command word ("opencode" auth) and must stay visible to verb
|
|
86
|
+
// patterns.
|
|
87
|
+
export function prepareRedirectResidue(segment: string): string {
|
|
88
|
+
const withTargets = segment
|
|
89
|
+
.replace(/(>>?)\s*'([^']*)'/g, "$1 $2")
|
|
90
|
+
.replace(/(>>?)\s*"([^"]*)"/g, '$1 $2');
|
|
91
|
+
return withTargets
|
|
92
|
+
.replace(/\s'[^']*'(?=$|[\s)>;&|])/g, " ")
|
|
93
|
+
.replace(/\s"[^"]*"(?=$|[\s)>;&|])/g, " ");
|
|
94
|
+
}
|
|
95
|
+
|
|
78
96
|
export function shellWords(command: string): string[] {
|
|
79
97
|
const words: string[] = [];
|
|
80
98
|
let word = "";
|
package/src/policies/boundary.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
getWorkspaceRootReal,
|
|
8
8
|
recordMutation,
|
|
9
9
|
} from "../lib/state.ts";
|
|
10
|
-
import { shellWords, unwrapShellCommand } from "../lib/shell.ts";
|
|
10
|
+
import { prepareRedirectResidue, shellWords, unwrapShellCommand } from "../lib/shell.ts";
|
|
11
11
|
import { isCollaborationInvocation, isProtectedPath, PROTECTED_PATH_REASON } from "./tamper.ts";
|
|
12
12
|
import { isSecretPath, secretIn } from "./secrets.ts";
|
|
13
13
|
import { onProtectedBranch, branchGuardReason } from "./git.ts";
|
|
@@ -129,10 +129,14 @@ export function teeTargetsIn(segment: string): string[] {
|
|
|
129
129
|
|
|
130
130
|
function redirectMutationsIn(segment: string): ShellMutation[] {
|
|
131
131
|
const mutations: ShellMutation[] = [];
|
|
132
|
+
// Redirect detection runs on the quote-stripped residue: quoted data
|
|
133
|
+
// spans are command data and their ">" characters are not redirects,
|
|
134
|
+
// while redirect targets keep their value whether quoted or not.
|
|
132
135
|
// The `(?!=)` lookahead after the op rejects comparison operators (`>=`,
|
|
133
136
|
// `==`) so they cannot match as a redirect op with `=` as its target.
|
|
137
|
+
const residue = prepareRedirectResidue(segment);
|
|
134
138
|
const redirectRe = /(?:^|[\s>]|(?<=[^\s"']))([0-9]*&?>>?&?(?!=))\s*["']?([^\s>&|;"']+)/g;
|
|
135
|
-
for (const redirectMatch of
|
|
139
|
+
for (const redirectMatch of residue.matchAll(redirectRe)) {
|
|
136
140
|
if (!redirectMatch[1] || !redirectMatch[2]) continue;
|
|
137
141
|
const op = redirectMatch[1];
|
|
138
142
|
const target = redirectMatch[2];
|
|
@@ -314,7 +318,10 @@ export function secretSourceInFilesystemCommand(segment: string): string | undef
|
|
|
314
318
|
// Remove single- and double-quoted spans from a shell segment. Used to
|
|
315
319
|
// analyze the residue of collaboration invocations: quoted arguments are
|
|
316
320
|
// command data and can never be shell redirects, while unquoted redirects
|
|
317
|
-
// keep receiving full validation.
|
|
321
|
+
// keep receiving full validation. NOTE: unlike shell.ts's
|
|
322
|
+
// prepareRedirectResidue, this strips EVERY span including redirect
|
|
323
|
+
// targets and glued concatenations - only use it for collaboration
|
|
324
|
+
// segments where all quoted content is command data.
|
|
318
325
|
function stripQuotedSpans(segment: string): string {
|
|
319
326
|
return segment.replace(/'[^'\n]*'/g, " ").replace(/"[^"\n]*"/g, " ");
|
|
320
327
|
}
|
package/src/policies/tamper.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import { basename, relative, resolve } from "node:path";
|
|
3
3
|
import { getWorkspaceRoot } from "../lib/state.ts";
|
|
4
|
-
import { decodeShellEscapes } from "../lib/shell.ts";
|
|
4
|
+
import { decodeShellEscapes, prepareRedirectResidue } from "../lib/shell.ts";
|
|
5
5
|
|
|
6
6
|
export const PROTECTED_PATH_REASON =
|
|
7
7
|
"Blocked: modifying Open" +
|
|
@@ -23,6 +23,15 @@ export const SETTINGS_TAMPER_PATTERNS: RegExp[] = [
|
|
|
23
23
|
new RegExp("(?:" + "^|\\s)(?:op" + "encode)\\s+(?:run\\s+)?--auto\\b", "i"),
|
|
24
24
|
];
|
|
25
25
|
|
|
26
|
+
// Patterns 1-4 match redirect operators and mutation verbs writing into
|
|
27
|
+
// guarded paths: they run on the quote-stripped residue (quoted data spans
|
|
28
|
+
// are command data and their ">" characters are not redirects). Patterns
|
|
29
|
+
// 5-6 match the opencode CLI verbs themselves: those run on the
|
|
30
|
+
// quote-FLATTENED text, because a quoted command word or an eval payload
|
|
31
|
+
// still executes the verb.
|
|
32
|
+
const PATH_PATTERNS = SETTINGS_TAMPER_PATTERNS.slice(0, 4);
|
|
33
|
+
const VERB_PATTERNS = SETTINGS_TAMPER_PATTERNS.slice(4);
|
|
34
|
+
|
|
26
35
|
export function normalizeShellEvasion(text: string): string {
|
|
27
36
|
return decodeShellEscapes(text)
|
|
28
37
|
.replace(/'([^']*)'/g, "$1")
|
|
@@ -48,13 +57,12 @@ export function isCollaborationInvocation(segment: string): boolean {
|
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
export function isSettingsTamper(command: string): boolean {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
);
|
|
60
|
+
return command.split(/[\n|;&]+/).some((s) => {
|
|
61
|
+
if (isCollaborationInvocation(s)) return false;
|
|
62
|
+
const residue = normalizeGlobPathEvasion(normalizeShellEvasion(prepareRedirectResidue(s)));
|
|
63
|
+
const flattened = normalizeGlobPathEvasion(normalizeShellEvasion(s));
|
|
64
|
+
return PATH_PATTERNS.some((re) => re.test(residue)) || VERB_PATTERNS.some((re) => re.test(flattened));
|
|
65
|
+
});
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
export function isProtectedPath(targetPath: string): boolean {
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -8,15 +8,106 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui";
|
|
11
|
-
import { Plugin } from "@opencode/plugin/tui";
|
|
11
|
+
import { Plugin, usePlugin } from "@opencode/plugin/tui";
|
|
12
12
|
import type { JSX } from "@opentui/solid";
|
|
13
|
-
import { createElement, insert, setProp } from "@opentui/solid";
|
|
14
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { createElement, insert, setProp, RendererContext } from "@opentui/solid";
|
|
14
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { getOwner, useContext } from "solid-js";
|
|
16
18
|
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser";
|
|
17
19
|
import { projectConfigPath } from "./lib/project-config.ts";
|
|
18
20
|
import { canonicalPath } from "./policies/file-claims.ts";
|
|
19
21
|
|
|
22
|
+
// ── V2 render diagnostics (bounded; never throws) ────────────────────────────
|
|
23
|
+
// Slot renders run inside the TUI provider tree, but recurring failures have
|
|
24
|
+
// been observed in long-running sessions after plugin versions were swapped
|
|
25
|
+
// (opencode 2.0.10, "No renderer found" from @opentui/solid createElement).
|
|
26
|
+
// Instead of rethrowing — which makes the host error boundary toast on every
|
|
27
|
+
// retry for a cosmetic badge — a failing slot renders null, and failures are
|
|
28
|
+
// logged with owner/renderer context to a state file, bounded to the first
|
|
29
|
+
// DIAG_MAX_FAILURES_PER_SLOT failures per slot between successes. The next
|
|
30
|
+
// render attempt retries naturally, and a later success is logged again.
|
|
31
|
+
const DIAG_DIR = join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "opencode");
|
|
32
|
+
const DIAG_LOG = join(DIAG_DIR, "workflow-guard-ui.log");
|
|
33
|
+
const DIAG_MAX_FAILURES_PER_SLOT = 3;
|
|
34
|
+
const DIAG_MAX_LOG_BYTES = 2_000_000;
|
|
35
|
+
|
|
36
|
+
function diag(line: string): void {
|
|
37
|
+
try {
|
|
38
|
+
mkdirSync(DIAG_DIR, { recursive: true });
|
|
39
|
+
if (existsSync(DIAG_LOG) && statSync(DIAG_LOG).size > DIAG_MAX_LOG_BYTES) {
|
|
40
|
+
writeFileSync(DIAG_LOG, "", { mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
appendFileSync(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, { mode: 0o600 });
|
|
43
|
+
} catch {
|
|
44
|
+
// diagnostics must never crash the plugin
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
diag(`module load: ${import.meta.url}; @opentui/solid -> ${import.meta.resolve("@opentui/solid")}; solid-js -> ${import.meta.resolve("solid-js")}`);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
diag(`module load: ${import.meta.url}; resolve probe failed: ${error}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const diagOkSlots = new Set<string>();
|
|
55
|
+
const diagFailureCount = new Map<string, number>();
|
|
56
|
+
|
|
57
|
+
function diagRender(slot: string): void {
|
|
58
|
+
try {
|
|
59
|
+
const owner = getOwner() as { context?: Record<string, unknown> | null } | null;
|
|
60
|
+
const contextKeys = owner ? (owner.context ? Object.keys(owner.context).join(",") : "null") : "no-owner";
|
|
61
|
+
const renderer = useContext(RendererContext);
|
|
62
|
+
let plugin = "ok";
|
|
63
|
+
try {
|
|
64
|
+
usePlugin();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
plugin = `FAILED: ${error}`;
|
|
67
|
+
}
|
|
68
|
+
if (!diagOkSlots.has(slot)) {
|
|
69
|
+
diagOkSlots.add(slot);
|
|
70
|
+
diag(`render ${slot} ok; owner=${owner ? "present" : "null"}; contextKeys=[${contextKeys}]; rendererContext=${renderer ? "found" : "MISSING"}; usePlugin=${plugin}`);
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
diag(`render ${slot} probe FAILED: ${error instanceof Error ? `${error.message}\n${error.stack}` : String(error)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function diagFailure(slot: string, error: unknown): void {
|
|
78
|
+
const count = (diagFailureCount.get(slot) ?? 0) + 1;
|
|
79
|
+
diagFailureCount.set(slot, count);
|
|
80
|
+
if (count > DIAG_MAX_FAILURES_PER_SLOT) {
|
|
81
|
+
if (count === DIAG_MAX_FAILURES_PER_SLOT + 1) {
|
|
82
|
+
diag(`render ${slot} still failing after ${DIAG_MAX_FAILURES_PER_SLOT} logged failures; further failures are suppressed until a render succeeds`);
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const lines = [
|
|
87
|
+
`render ${slot} FAILED (${count}/${DIAG_MAX_FAILURES_PER_SLOT}): ${error instanceof Error ? error.message : String(error)}`,
|
|
88
|
+
` stack: ${error instanceof Error ? error.stack : "(none)"}`,
|
|
89
|
+
];
|
|
90
|
+
try {
|
|
91
|
+
const owner = getOwner() as { context?: Record<string, unknown> | null } | null;
|
|
92
|
+
lines.push(` owner=${owner ? "present" : "null"}; contextKeys=[${owner ? (owner.context ? Object.keys(owner.context).join(",") : "null") : "none"}]`);
|
|
93
|
+
} catch (probeError) {
|
|
94
|
+
lines.push(` owner probe failed: ${probeError}`);
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
lines.push(` useContext(RendererContext): ${useContext(RendererContext) ? "found" : "MISSING"}`);
|
|
98
|
+
} catch (probeError) {
|
|
99
|
+
lines.push(` renderer probe failed: ${probeError}`);
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
usePlugin();
|
|
103
|
+
lines.push(` usePlugin: ok`);
|
|
104
|
+
} catch (probeError) {
|
|
105
|
+
lines.push(` usePlugin: FAILED ${probeError}`);
|
|
106
|
+
}
|
|
107
|
+
diag(lines.join("\n"));
|
|
108
|
+
}
|
|
109
|
+
// ── end render diagnostics ───────────────────────────────────────────────────
|
|
110
|
+
|
|
20
111
|
type Child = JSX.Element | string | number | null | undefined | false;
|
|
21
112
|
|
|
22
113
|
function element(
|
|
@@ -98,6 +189,8 @@ const TOGGLE_OPTIONS: Array<{ key: ProjectToggle; label: string; description: st
|
|
|
98
189
|
];
|
|
99
190
|
|
|
100
191
|
export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
|
|
192
|
+
diag(`setup: app=${ctx.app?.version ?? "?"}; location=${ctx.location?.directory ?? "?"}`);
|
|
193
|
+
|
|
101
194
|
const badge = () => {
|
|
102
195
|
const formatted = formatBadge();
|
|
103
196
|
return text({ fg: ctx.theme.text.feedback.success.base }, [formatted.text]);
|
|
@@ -164,8 +257,29 @@ export const WorkflowGuardTuiV2 = (ctx: TuiContext) => {
|
|
|
164
257
|
},
|
|
165
258
|
});
|
|
166
259
|
|
|
167
|
-
|
|
168
|
-
|
|
260
|
+
const registerBadgeSlot = (slot: NonNullable<Parameters<TuiContext["ui"]["slot"]>[0]["append"]>) => {
|
|
261
|
+
ctx.ui.slot({
|
|
262
|
+
append: slot,
|
|
263
|
+
render: () => {
|
|
264
|
+
try {
|
|
265
|
+
const element = badge();
|
|
266
|
+
if (!diagOkSlots.has(slot)) {
|
|
267
|
+
diagOkSlots.add(slot);
|
|
268
|
+
diagFailureCount.delete(slot);
|
|
269
|
+
diag(`render ${slot} ok`);
|
|
270
|
+
}
|
|
271
|
+
return element;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
diagOkSlots.delete(slot);
|
|
274
|
+
diagFailure(slot, error);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
registerBadgeSlot("home.footer.status");
|
|
281
|
+
registerBadgeSlot("prompt.footer.status");
|
|
282
|
+
diagRender("probe-after-setup");
|
|
169
283
|
|
|
170
284
|
return () => {
|
|
171
285
|
// Slot claims and keymap layers are disposed automatically on unload.
|
package/test/test.mts
CHANGED
|
@@ -114,7 +114,7 @@ import { audit } from "../src/lib/audit.ts";
|
|
|
114
114
|
import { isProjectMemoryFreshAsync } from "../src/lib/project-memory.ts";
|
|
115
115
|
import { ToolOutcomeTracker } from "../src/lib/tool-outcomes.ts";
|
|
116
116
|
import { createRecoveryCheckpoint, finalizeRecoveryCheckpoint, listRecoveryCheckpoints, restoreRecoveryCheckpoint, setCheckpointGitForTesting } from "../src/lib/checkpoint.ts";
|
|
117
|
-
import { WorkflowGuardTui, formatBadge, readProjectOption, readRecoveryCheckpointsOption, writeRecoveryCheckpointsOption } from "../src/workflow-guard-ui.ts";
|
|
117
|
+
import { WorkflowGuardTui, WorkflowGuardTuiV2, formatBadge, readProjectOption, readRecoveryCheckpointsOption, writeRecoveryCheckpointsOption } from "../src/workflow-guard-ui.ts";
|
|
118
118
|
import { scanSessionTodos, toolPartName } from "../src/lib/v2-todo.ts";
|
|
119
119
|
import { effectiveTodosWithOwner } from "../src/policies/todo.ts";
|
|
120
120
|
|
|
@@ -539,6 +539,11 @@ check("allow gh issue create mentioning .opencode paths (not tamper)", !(await s
|
|
|
539
539
|
check("compound collaboration + tamper still blocked", blocked(await shell(`gh issue create --repo example/proj --title t && echo x > open${"code.json"}`)));
|
|
540
540
|
check("collaboration command with real redirect to global config still blocked", blocked(await shell(`gh issue list > /var/home/x/.config/open${"code"}/opencode.json`)));
|
|
541
541
|
check("collaboration command with redirect outside workspace still blocked", blocked(await shell(`gh pr create --title t > /tmp/wg-escape-probe`)));
|
|
542
|
+
check("allow command with guarded-path text in quoted data (not tamper)", !(await shell(`echo "flow: x > ${ocDir}" > notes.md`)));
|
|
543
|
+
check("block unquoted redirect into .opencode", blocked(await shell(`echo x > .open${"code"}/y`)));
|
|
544
|
+
check("block redirect with quoted real target", blocked(await shell(`echo x > ".open${"code"}/opencode.json"`)));
|
|
545
|
+
check("block quoted command word running the CLI auth flow", blocked(await shell(`"open${"code"}" auth login`)));
|
|
546
|
+
check("block eval of a quoted CLI auth payload", blocked(await shell(`eval 'open${"code"} auth login'`)));
|
|
542
547
|
|
|
543
548
|
console.log("- Policy 6: tamper via edit tools (path protection) -");
|
|
544
549
|
check("block edit of project opencode.json", blocked(await call("edit", { filePath: join(root, "opencode.json"), oldString: "a", newString: "b" }, { sessionID: "s-active" })));
|
|
@@ -1340,6 +1345,32 @@ check("recovery checkpoint project option refuses symlink escape", tuiSymlinkRej
|
|
|
1340
1345
|
rmSync(outsideTuiOptions, { recursive: true, force: true });
|
|
1341
1346
|
rmSync(tuiOptionsDir, { recursive: true, force: true });
|
|
1342
1347
|
|
|
1348
|
+
// V2 TUI companion degrades gracefully when slot renders fail (bounded diagnostics)
|
|
1349
|
+
const v2SlotClaims: Array<{ append: string; render: () => unknown }> = [];
|
|
1350
|
+
const fakeV2TuiCtx = {
|
|
1351
|
+
app: { version: "9.9.9-test", channel: "test" },
|
|
1352
|
+
location: { directory: tuiCommandOptionsDir },
|
|
1353
|
+
theme: { text: { feedback: { success: { base: "#00ff00" } } } },
|
|
1354
|
+
ui: { slot: (claim: { append: string; render: () => unknown }) => { v2SlotClaims.push(claim); } },
|
|
1355
|
+
};
|
|
1356
|
+
await WorkflowGuardTuiV2(fakeV2TuiCtx as any);
|
|
1357
|
+
check("v2 tui companion registers home and prompt status slots", v2SlotClaims.filter((claim) => claim.append === "home.footer.status" || claim.append === "prompt.footer.status").length === 2);
|
|
1358
|
+
const diagLogPath = join(process.env.XDG_STATE_HOME!, "opencode", "workflow-guard-ui.log");
|
|
1359
|
+
const diagLogText = () => readFileSync(diagLogPath, "utf8");
|
|
1360
|
+
check("v2 tui companion logs module load and setup with app version", diagLogText().includes("module load:") && diagLogText().includes("setup: app=9.9.9-test"));
|
|
1361
|
+
const v2HomeRender = v2SlotClaims.find((claim) => claim.append === "home.footer.status")!.render;
|
|
1362
|
+
// In a plain Node process there is no OpenTUI renderer, so badge() throws the
|
|
1363
|
+
// same "No renderer found" the long-running-session bug produced in the TUI.
|
|
1364
|
+
check("v2 slot render returns null instead of throwing when the render context is unavailable", v2HomeRender() === null);
|
|
1365
|
+
check("v2 slot failure log records probes and stack", diagLogText().includes("render home.footer.status FAILED (1/3)") && diagLogText().includes("owner=null") && diagLogText().includes("useContext(RendererContext): MISSING") && diagLogText().includes(" stack: "));
|
|
1366
|
+
for (let i = 0; i < 6; i++) v2HomeRender();
|
|
1367
|
+
const v2FailuresLogged = diagLogText().split("\n").filter((line) => line.includes("render home.footer.status FAILED (")).length;
|
|
1368
|
+
check("v2 slot failure logging is bounded to 3 per success epoch", v2FailuresLogged === 3);
|
|
1369
|
+
check("v2 slot failure suppression notice is logged once", diagLogText().split("still failing after 3 logged failures").length === 2);
|
|
1370
|
+
const v2PromptRender = v2SlotClaims.find((claim) => claim.append === "prompt.footer.status")!.render;
|
|
1371
|
+
check("v2 prompt status slot also degrades to null on render failure", v2PromptRender() === null);
|
|
1372
|
+
|
|
1373
|
+
|
|
1343
1374
|
// ── Adversarial tests & hardened invariants ──
|
|
1344
1375
|
console.log("- Adversarial tests & hardened invariants -");
|
|
1345
1376
|
|