Skip to content

Commit 76560bb

Browse files
committed
chore: merge fix/watch-mode-keepalive-tri-13065 (review fixes round 2)
2 parents dd22919 + ae1a936 commit 76560bb

22 files changed

Lines changed: 385 additions & 42 deletions

.changeset/chat-stream-mid-turn-reconnect.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
"trigger.dev": patch
55
---
66

7-
Chat in the browser now reconnects when the connection drops mid-turn, instead of leaving the reply stuck as if it were still generating. Reports can be fetched as structured data with the `json` format, and the shortest report period is now one minute (`30m`, `1h`, `7d`). The `mint-token` command's help is clearer too: a token minted without `--cap` is read-only, and `--ttl` shows the correct maximum lifetime of 7 days.
7+
Chat in the browser now reconnects when the connection drops mid-turn, instead of leaving the reply stuck as if it were still generating. Reports can be fetched as structured data with the `json` format, and the shortest report period is now one minute (`1m`, `30m`, `1h`, `7d`). The `mint-token` command's help is clearer too: a token minted without `--cap` is read-only, and `--ttl` shows the correct maximum lifetime of 7 days.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Fixed a race where quickly restarting a chat stream could break stop and reconnect for the new stream.

.claude/agents/code-reviewer.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
name: code-reviewer
3+
description: Adversarially verifies one landed packet against its requirement; read-only.
4+
model: opus
5+
---
6+
7+
You are an adversarial code reviewer for one landed packet. READ-ONLY: never modify code, never commit, never push, never post to GitHub.
8+
9+
- Try to refute that the change answers its stated requirement; look for the failure scenario, not confirmation.
10+
- Check the diff for unrelated drift, dead code, broken semantics of neighbors, and whether tests prove the actual invariant (would the test fail if the fix were subtly wrong?).
11+
- Check the change landed in the correct PR/branch of the stack.
12+
- Distinguish fact from inference; cite exact file:line evidence.
13+
- Return: verdict (approve / needs-changes) with evidence per concern, and the exact minimal correction when needs-changes.

.claude/agents/code-writer.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
name: code-writer
3+
description: Implements exactly one work packet — minimal diff, targeted checks, own-paths-only commits.
4+
model: opus
5+
---
6+
7+
You are a code writer. Implement exactly the one work packet in your prompt.
8+
9+
- Minimal diff; match surrounding style and idiom.
10+
- Prefer no comment at all; comment only a non-obvious constraint, max 2 short lines. All texts (comments, commit messages) short, clear, simple.
11+
- Verify the packet's own diagnosis against the code before applying; if it is wrong, STOP without committing and report why.
12+
- Run only the targeted checks for your packet: the relevant vitest files, `pnpm run typecheck --filter <pkg>` when the change warrants it. Never full suites unless asked.
13+
- `pnpm run format` on touched files before committing.
14+
- Stage and commit ONLY your packet's files. Conventional commit message. NO Claude attribution, no Co-Authored-By.
15+
- Push only if the packet explicitly says to.
16+
- Return: what changed, evidence (test output), commit SHA, and anything contradicting the diagnosis.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
name: software-architect
3+
description: Resolves contested design questions against the specs; decision + rationale, never code.
4+
model: opus
5+
---
6+
7+
You are a software architect. Resolve exactly the contested design question in your prompt against the given specs/contracts. READ-ONLY.
8+
9+
- Ground the decision in the actual code and the project's design contracts (GUIDEBOOK, Linear specs) — not in generic best practice.
10+
- Weigh stack boundaries: which PR owns the change, what merges independently.
11+
- Prefer the smallest decision that unblocks the packet; flag speculative architecture rather than endorsing it.
12+
- Return: the decision, its rationale, rejected alternatives (one line each), and exactly what the dependent packet should do.

apps/webapp/app/components/code/StreamdownRenderer.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ describe("restrictModelUrls (image src)", () => {
2020
expect(restrictModelUrls("//evil.tld/pixel.gif", "src", img)).toBeUndefined();
2121
});
2222

23+
it("drops a backslash-authority image, which the browser reads as protocol-relative", () => {
24+
expect(restrictModelUrls("\\\\evil.example/pixel.gif", "src", img)).toBeUndefined();
25+
expect(restrictModelUrls("/\\evil.example/pixel.gif", "src", img)).toBeUndefined();
26+
});
27+
28+
it("drops an image hidden behind a leading C0 control, which the URL parser discards", () => {
29+
expect(restrictModelUrls("\u0001//evil.tld/p.gif", "src", img)).toBeUndefined();
30+
expect(restrictModelUrls("\u0000https://evil.tld/p.gif", "src", img)).toBeUndefined();
31+
});
32+
2333
it("keeps inline and same-origin images", () => {
2434
expect(restrictModelUrls("data:image/png;base64,AAAA", "src", img)).toBe(
2535
"data:image/png;base64,AAAA"

apps/webapp/app/components/code/StreamdownRenderer.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,25 @@ const SAFE_LINK_SCHEMES = new Set(["http:", "https:", "mailto:"]);
1212
export const restrictModelUrls: UrlTransform = (url, key, node) => {
1313
const value = url.trim();
1414
const isImage = node.tagName === "img" || key === "src" || key === "srcset";
15+
// What the browser will actually resolve, which is not what `trim()` leaves: the URL parser
16+
// drops C0 controls (`trim()` keeps them) and reads `\` as `/` for special schemes, so
17+
// a leading-control `//evil.tld` and `\\evil.tld` both name a remote host. Classify on this; return the
18+
// original `url` untouched whenever it is allowed.
19+
const normalized = value
20+
.replace(/[\u0000-\u001f]/g, "")
21+
.replace(/^[/\\]+/, (run) => "/".repeat(run.length));
1522

1623
if (isImage) {
1724
// Inline images carry their own bytes; a relative path resolves to our own origin.
18-
if (/^data:/i.test(value) || /^blob:/i.test(value)) return url;
25+
if (/^data:/i.test(normalized) || /^blob:/i.test(normalized)) return url;
1926
// Absolute or protocol-relative means a remote host — strip it so nothing is fetched.
20-
if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith("//")) return undefined;
27+
if (/^[a-z][a-z0-9+.-]*:/i.test(normalized) || normalized.startsWith("//")) return undefined;
2128
return url;
2229
}
2330

2431
// Links: relative and protocol-relative are fine; otherwise require a safe scheme.
25-
if (value.startsWith("//")) return url;
26-
const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(value);
32+
if (normalized.startsWith("//")) return url;
33+
const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(normalized);
2734
if (!schemeMatch) return url;
2835
return SAFE_LINK_SCHEMES.has(`${schemeMatch[1].toLowerCase()}:`) ? url : undefined;
2936
};

apps/webapp/app/components/dashboard-agent/DashboardAgentMessages.render.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,13 @@ function markup(parts: unknown[]) {
6464
);
6565
}
6666

67+
/** Every needle must be present: a missing one is -1, and -1 comparisons pass vacuously. */
6768
function order(html: string, ...needles: string[]) {
68-
return needles.map((needle) => html.indexOf(needle));
69+
return needles.map((needle) => {
70+
const at = html.indexOf(needle);
71+
expect(at, `missing "${needle}"`).toBeGreaterThan(-1);
72+
return at;
73+
});
6974
}
7075

7176
describe("action rows render at the end of the turn", () => {

apps/webapp/app/components/dashboard-agent/model-markdown.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,29 @@ describe("stripModelImages", () => {
3131
expect(html).not.toContain("attacker.example");
3232
});
3333

34+
it("strips a nested tag, which one pass would splice back into a whole one", () => {
35+
expect(stripModelImages(`<im<img src="${BEACON}">g src="${BEACON}">`)).not.toContain(
36+
"attacker.example"
37+
);
38+
expect(stripModelImages("<scr<script>ipt>")).toBe("");
39+
});
40+
41+
it("bails out safely on nesting too deep to unwrap, instead of looping on it", () => {
42+
// Each layer needs one more pass, so this is the shape that makes the strip quadratic.
43+
let nested = `<script src="${BEACON}">`;
44+
for (let i = 0; i < 20_000; i++) nested = `<scr${nested}ipt>`;
45+
46+
const started = performance.now();
47+
const stripped = stripModelImages(nested);
48+
const elapsed = performance.now() - started;
49+
50+
// No bracket survives, so nothing can parse as an element — the URL is left as inert prose.
51+
expect(stripped).not.toContain("<script");
52+
expect(stripped).not.toContain("<");
53+
expect(render(stripped)).not.toMatch(/<script\b/i);
54+
expect(elapsed).toBeLessThan(1_000);
55+
});
56+
3457
it("strips an image hidden inside a code fence", () => {
3558
const stripped = stripModelImages(`\`\`\`\n![](${BEACON})\n\`\`\``);
3659
expect(stripped).not.toContain("attacker.example");

apps/webapp/app/components/dashboard-agent/model-markdown.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,33 @@ function plainAlt(alt: string): string {
1313
return alt.replace(/[![\]<>`]/g, "").trim();
1414
}
1515

16+
/**
17+
* One pass isn't enough: removing a match can splice the surrounding text into a fresh one
18+
* (`<scr<script>ipt>`), so repeat until nothing changes. Nesting deep enough to need more than
19+
* `MAX_PASSES` is adversarial, not real model output, and looping it out is quadratic on the
20+
* render thread — so past the bound we stop and blunt every character the strips key on. The
21+
* result is over-stripped, never half-stripped.
22+
*/
23+
const MAX_PASSES = 25;
24+
const STRIP_CHARS = /[![\]<>]/g;
25+
26+
function replaceUntilStable(
27+
text: string,
28+
pattern: RegExp,
29+
replacer: (whole: string, ...groups: string[]) => string
30+
): string {
31+
let current = text;
32+
for (let pass = 0; pass < MAX_PASSES; pass++) {
33+
const next = current.replace(pattern, replacer);
34+
if (next === current) return current;
35+
current = next;
36+
}
37+
return current.replace(STRIP_CHARS, "");
38+
}
39+
1640
// Strips inside code fences too: a fence-aware pass is bypassable with a half-fence.
1741
export function stripModelImages(text: string): string {
18-
return text
19-
.replace(MARKDOWN_IMAGE, (_whole, alt: string) => plainAlt(alt))
20-
.replace(MARKDOWN_SHORTCUT_IMAGE, (_whole, alt: string) => plainAlt(alt))
21-
.replace(FETCHING_TAG, "");
42+
let out = replaceUntilStable(text, MARKDOWN_IMAGE, (_whole, alt: string) => plainAlt(alt));
43+
out = replaceUntilStable(out, MARKDOWN_SHORTCUT_IMAGE, (_whole, alt: string) => plainAlt(alt));
44+
return replaceUntilStable(out, FETCHING_TAG, () => "");
2245
}

0 commit comments

Comments
 (0)