From d1bbf2d4a4b6f7adec83a118b836fcf24f380829 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 23:09:47 +0000 Subject: [PATCH 1/6] test(web): tests reproduce issue 16443, archived thread links redirect home An archived thread opened by its /$environmentId/$threadId link resolves to "missing" once its detail loads, so the route leaves the thread. Deleted and absent threads still redirect. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/threadRoutes.test.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index 3a028503015d..34f514e5d33c 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -141,7 +141,7 @@ describe("threadRoutes", () => { ).toBe("ready"); }); - it.each(["missing", "deleted", "archived"] as const)( + it.each(["missing", "deleted"] as const)( "redirects an unlisted thread only after detail confirms it is %s", (outcome) => { const route = { @@ -175,8 +175,7 @@ describe("threadRoutes", () => { ...projection, thread: { ...projection.thread, - deletedAt: outcome === "deleted" ? removedAt : null, - archivedAt: outcome === "archived" ? removedAt : null, + deletedAt: removedAt, }, }), }, @@ -185,6 +184,30 @@ describe("threadRoutes", () => { }, ); + it("opens an archived thread by its link once its detail arrives", () => { + const projection = makeThreadProjectionFixture(); + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: isThreadRouteSnapshotAuthoritative("live"), + // The active shell snapshot does not list archived threads. + serverThreadExists: false, + serverThreadDeleted: false, + unlistedThreadDetail: { + ...EMPTY_ENVIRONMENT_THREAD_STATE, + status: "live", + data: Option.some({ + ...projection, + thread: { + ...projection.thread, + archivedAt: DateTime.makeUnsafe("2026-01-02T00:00:00Z"), + }, + }), + }, + draftThreadExists: false, + }), + ).toBe("ready"); + }); + it("waits for the live shell before redirecting a deleted thread", () => { expect( resolveThreadRouteRenderState({ From d7427515fe01ec7a908a25b4dad1981c48f7b67d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 00:32:05 +0000 Subject: [PATCH 2/6] fix(web): archived thread links open the conversation --- apps/web/src/threadRoutes.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index 9a1cbef5575b..cf4de49999d3 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -45,14 +45,12 @@ export function resolveThreadRouteRenderState(input: { if (input.serverThreadExists || input.unlistedThreadDetail.status === "deleted") { return "missing"; } - // Shell membership can lag a new thread; its detail decides. + // The shell omits new and archived threads; the thread's detail decides. const projection = Option.getOrNull(input.unlistedThreadDetail.data); if (projection === null) { return "loading"; } - return projection.thread.deletedAt !== null || projection.thread.archivedAt !== null - ? "missing" - : "ready"; + return projection.thread.deletedAt !== null ? "missing" : "ready"; } export function buildThreadRouteParams(ref: ScopedThreadRef): { From f4f56588d35c71aecb3978b6dc8d96d76791a134 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 01:35:19 +0000 Subject: [PATCH 3/6] test: add synthetic archived thread link fixture --- scripts/seed-archived-thread-links.mjs | 282 +++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 scripts/seed-archived-thread-links.mjs diff --git a/scripts/seed-archived-thread-links.mjs b/scripts/seed-archived-thread-links.mjs new file mode 100644 index 000000000000..d58647d44263 --- /dev/null +++ b/scripts/seed-archived-thread-links.mjs @@ -0,0 +1,282 @@ +#!/usr/bin/env node +// Visual fixture only: projection rows do not replace orchestration business-behavior tests. +import * as NodeSqlite from "node:sqlite"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeUtil from "node:util"; + +const { values } = NodeUtil.parseArgs({ + options: { + "base-dir": { type: "string" }, + "workspace-root": { type: "string" }, + }, +}); +if ( + !values["base-dir"] || + !values["workspace-root"] || + !NodePath.isAbsolute(values["workspace-root"]) +) { + throw new Error( + "Usage: node scripts/seed-archived-thread-links.mjs --base-dir --workspace-root ", + ); +} +const base = NodeFS.realpathSync(values["base-dir"]); +const shared = NodePath.resolve(NodeOS.homedir(), ".t3"); +if (base === shared || base.startsWith(`${shared}/`)) throw new Error("Use an isolated T3 home."); +const runtimePath = NodePath.join(base, "userdata/server-runtime.json"); +if (NodeFS.existsSync(runtimePath)) { + const { pid } = JSON.parse(NodeFS.readFileSync(runtimePath, "utf8")); + if (Number.isInteger(pid)) { + let running = true; + try { + process.kill(pid, 0); + } catch (error) { + if (error.code === "ESRCH") running = false; + else throw error; + } + if (running) throw new Error("Stop this sandbox's server before seeding projections."); + } +} +const databasePath = NodePath.join(base, "userdata/statev2.sqlite"); +if (!NodeFS.existsSync(databasePath)) + throw new Error("Start the isolated server once to run migrations, then stop it."); +const db = new NodeSqlite.DatabaseSync(databasePath); +const backupPath = `${databasePath}.archived-links-${Date.now()}.backup`; +await NodeSqlite.backup(db, backupPath); +const at = "2026-10-20T12:00:00.000Z"; +const projectId = "fixture-16443-project"; +const archivedId = "fixture-16443-archived"; +const activeId = "fixture-16443-active"; +const runId = "fixture-16443-run"; +const rootId = "fixture-16443-root"; +const modelSelection = { instanceId: "codex", model: "gpt-6-astra" }; + +function write(table, columns, payload) { + const row = + payload === undefined ? columns : { ...columns, payload_json: JSON.stringify(payload) }; + const names = Object.keys(row); + db.prepare( + `INSERT OR REPLACE INTO ${table} (${names.join(",")}) VALUES (${names.map(() => "?").join(",")})`, + ).run(...Object.values(row)); +} +function thread(id, title, archivedAt) { + const payload = { + createdBy: "system", + creationSource: "server", + id, + projectId, + title, + providerInstanceId: "codex", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + linkedPullRequest: null, + pullRequests: [], + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: id }, + forkedFrom: null, + createdAt: at, + updatedAt: at, + archivedAt, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + lastVisitedAt: null, + titleRegeneration: null, + deletedAt: null, + }; + write( + "orchestration_v2_projection_threads", + { + thread_id: id, + project_id: projectId, + title, + default_provider: "codex", + provider_instance_id: "codex", + runtime_mode: "full-access", + interaction_mode: "default", + active_provider_thread_id: null, + created_at: at, + updated_at: at, + archived_at: archivedAt, + deleted_at: null, + }, + payload, + ); +} + +db.exec("BEGIN IMMEDIATE"); +try { + write("projection_projects", { + project_id: projectId, + title: "Archived thread links fixture", + workspace_root: NodeFS.realpathSync(values["workspace-root"]), + scripts_json: "[]", + created_at: at, + updated_at: at, + deleted_at: null, + }); + thread(archivedId, "Archive link reproduction fixture", at); + thread(activeId, "Active link control fixture", null); + const run = { + id: runId, + threadId: archivedId, + ordinal: 1, + providerInstanceId: "codex", + modelSelection, + providerThreadId: null, + userMessageId: "fixture-16443-user", + rootNodeId: rootId, + activeAttemptId: null, + status: "completed", + queuePosition: null, + requestedAt: at, + startedAt: at, + completedAt: at, + checkpointId: null, + contextHandoffId: null, + }; + write( + "orchestration_v2_projection_runs", + { + run_id: runId, + thread_id: archivedId, + ordinal: 1, + provider: "codex", + provider_instance_id: "codex", + provider_thread_id: null, + status: "completed", + requested_at: at, + completed_at: at, + }, + run, + ); + for (const [index, role, text] of [ + [ + 0, + "user", + "Reply with exactly: Archive link reproduction fixture. Do not use tools or change files.", + ], + [1, "assistant", "Archive link reproduction fixture."], + ]) { + const id = index === 0 ? "fixture-16443-user" : "fixture-16443-assistant"; + const nodeId = index === 0 ? rootId : "fixture-16443-response"; + const node = { + id: nodeId, + threadId: archivedId, + runId, + parentNodeId: index === 0 ? null : rootId, + rootNodeId: rootId, + kind: index === 0 ? "root_turn" : "assistant_message", + status: "completed", + countsForRun: index === 0, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: at, + completedAt: at, + }; + write( + "orchestration_v2_projection_nodes", + { + node_id: nodeId, + thread_id: archivedId, + run_id: runId, + parent_node_id: node.parentNodeId, + root_node_id: rootId, + kind: node.kind, + status: "completed", + started_at: at, + completed_at: at, + }, + node, + ); + const creation = { + createdBy: index === 0 ? "user" : "agent", + creationSource: index === 0 ? "web" : "provider", + }; + const message = { + ...creation, + id, + threadId: archivedId, + runId, + nodeId, + role, + text, + attachments: [], + streaming: false, + createdAt: at, + updatedAt: at, + }; + write( + "orchestration_v2_projection_messages", + { + message_id: id, + thread_id: archivedId, + run_id: runId, + node_id: nodeId, + role, + streaming: 0, + created_at: at, + updated_at: at, + }, + message, + ); + const item = { + id: `fixture-16443-item-${index}`, + threadId: archivedId, + runId, + nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1000001 + index, + status: "completed", + title: null, + startedAt: at, + completedAt: at, + updatedAt: at, + type: `${role === "user" ? "user" : "assistant"}_message`, + messageId: id, + text, + ...(index === 0 + ? { ...creation, inputIntent: "turn_start", attachments: [] } + : { streaming: false }), + }; + write( + "orchestration_v2_projection_turn_items", + { + turn_item_id: item.id, + thread_id: archivedId, + run_id: runId, + node_id: nodeId, + ordinal: item.ordinal, + type: item.type, + status: "completed", + updated_at: at, + }, + item, + ); + } + db.exec("COMMIT"); +} catch (error) { + db.exec("ROLLBACK"); + throw error; +} finally { + db.close(); +} +console.log( + JSON.stringify({ + backupPath, + archivedId, + activeId, + route: "//fixture-16443-archived", + }), +); From a50cd989aee06ed0762e26027eadf35933eb52ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 21 Oct 2026 18:40:54 -0700 Subject: [PATCH 4/6] feat(server): webhook filters fan out over arrays with a [] path wildcard A filter path segment ending in [] resolves to the list of every element's value, so a task can run only when, say, an issue carries a given label: {path: 'body.issue.labels[].name', op: 'equals', value: 'needs more info'}. equals, in and startsWith match when any element does, notEquals only when none equals the value, exists when the list is not empty. Placeholders accept the same syntax and render the list. Before this, filters could only compare scalars and arrays satisfied exists alone, so a GitHub issue_comment hook had to wake the bound thread on every comment in the repository and let the agent discard the rest. Co-Authored-By: Claude Fable 5.1 --- .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/scheduledTasks/webhookFilter.test.ts | 95 +++++++++++++++++++ .../src/scheduledTasks/webhookFilter.ts | 26 ++++- .../scheduledTasks/webhookTemplate.test.ts | 17 ++++ .../src/scheduledTasks/webhookTemplate.ts | 24 ++++- packages/contracts/src/scheduledTask.ts | 4 +- 6 files changed, 157 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/scheduledTasks/webhookFilter.test.ts diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 111870be9bbd..567cb9272732 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -118,7 +118,7 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", { const ScheduleTaskTool = Tool.make("schedule_task", { description: - "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", + "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. A path segment ending in [] fans out over an array: {path:'body.issue.labels[].name', op:'equals', value:'needs more info'} matches when any label has that name, and notEquals only when none does. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", parameters: OrchestratorMcpScheduleTaskInput, success: OrchestratorMcpScheduleTaskResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/scheduledTasks/webhookFilter.test.ts b/apps/server/src/scheduledTasks/webhookFilter.test.ts new file mode 100644 index 000000000000..1194a1aed517 --- /dev/null +++ b/apps/server/src/scheduledTasks/webhookFilter.test.ts @@ -0,0 +1,95 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { findFailedWebhookFilter } from "./webhookFilter.ts"; +import type { WebhookRequest } from "./webhookTemplate.ts"; + +const issueComment: WebhookRequest = { + method: "POST", + path: "/api/hooks/task-1", + query: "", + headers: { "content-type": "application/json", "x-github-event": "issue_comment" }, + bodyText: JSON.stringify({ + action: "created", + issue: { number: 7, labels: [{ name: "bug" }, { name: "needs more info" }], assignees: [] }, + comment: { user: { login: "reporter", type: "User" } }, + }), +}; + +describe("findFailedWebhookFilter", () => { + it("matches a wildcard path when any element matches", () => { + assert.isNull( + findFailedWebhookFilter( + [ + { path: "body.issue.labels[].name", op: "equals", value: "needs more info" }, + { path: "body.issue.labels[].name", op: "in", value: ["needs more info", "triage"] }, + { path: "body.issue.labels[].name", op: "startsWith", value: "needs" }, + { path: "body.issue.labels[].name", op: "exists" }, + ], + issueComment, + ), + ); + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.labels[].name", op: "equals", value: "wontfix" }], + issueComment, + ), + { index: 0, path: "body.issue.labels[].name" }, + ); + }); + + it("fails notEquals on a wildcard path when any element equals the value", () => { + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.labels[].name", op: "notEquals", value: "bug" }], + issueComment, + ), + { index: 0, path: "body.issue.labels[].name" }, + ); + assert.isNull( + findFailedWebhookFilter( + [{ path: "body.issue.labels[].name", op: "notEquals", value: "wontfix" }], + issueComment, + ), + ); + }); + + it("treats an empty or missing wildcard list as nothing to match", () => { + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.assignees[].login", op: "exists" }], + issueComment, + ), + { index: 0, path: "body.issue.assignees[].login" }, + ); + assert.isNull( + findFailedWebhookFilter( + [{ path: "body.issue.assignees[].login", op: "notEquals", value: "anyone" }], + issueComment, + ), + ); + assert.deepEqual( + findFailedWebhookFilter([{ path: "body.issue.number[].x", op: "exists" }], issueComment), + { index: 0, path: "body.issue.number[].x" }, + ); + }); + + it("still compares scalars without a wildcard", () => { + assert.isNull( + findFailedWebhookFilter( + [ + { path: "body.action", op: "equals", value: "created" }, + { path: "body.comment.user.type", op: "notEquals", value: "Bot" }, + ], + issueComment, + ), + ); + // A list without a wildcard in the path only satisfies exists. + assert.deepEqual( + findFailedWebhookFilter( + [{ path: "body.issue.labels", op: "equals", value: "bug" }], + issueComment, + ), + { index: 0, path: "body.issue.labels" }, + ); + }); +}); diff --git a/apps/server/src/scheduledTasks/webhookFilter.ts b/apps/server/src/scheduledTasks/webhookFilter.ts index 6f880ef7cebe..882f30036428 100644 --- a/apps/server/src/scheduledTasks/webhookFilter.ts +++ b/apps/server/src/scheduledTasks/webhookFilter.ts @@ -13,11 +13,7 @@ function scalarText(value: unknown): string | undefined { return undefined; } -/** Placeholders render null as missing, so filters treat it the same way. */ -function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown): boolean { - if (filter.op === "exists") return value !== undefined && value !== null; - const text = scalarText(value); - if (text === undefined) return false; +function matchesScalar(filter: ScheduledTaskWebhookFilter, text: string): boolean { switch (filter.op) { case "equals": return text === filter.value; @@ -27,9 +23,29 @@ function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown return filter.value.includes(text); case "startsWith": return text.startsWith(filter.value); + case "exists": + return true; } } +/** + * Placeholders render null as missing, so filters treat it the same way. A + * path with a `[]` wildcard resolves to a list: `equals`, `in` and + * `startsWith` match when any element does, `notEquals` only when none equals + * the value, and `exists` when the list has an element. + */ +function matchesWebhookFilter(filter: ScheduledTaskWebhookFilter, value: unknown): boolean { + if (Array.isArray(value) && /\[\]/.test(filter.path)) { + const texts = value.map(scalarText).filter((text): text is string => text !== undefined); + if (filter.op === "notEquals") return texts.every((text) => text !== filter.value); + return texts.some((text) => matchesScalar(filter, text)); + } + if (filter.op === "exists") return value !== undefined && value !== null; + const text = scalarText(value); + if (text === undefined) return false; + return matchesScalar(filter, text); +} + /** The first filter the request fails, or null when every filter matches. */ export function findFailedWebhookFilter( filters: ReadonlyArray, diff --git a/apps/server/src/scheduledTasks/webhookTemplate.test.ts b/apps/server/src/scheduledTasks/webhookTemplate.test.ts index b5ac2e69aa38..6aa23e12b036 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.test.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.test.ts @@ -165,6 +165,23 @@ describe("renderWebhookPrompt", () => { ); }); + it("renders a [] wildcard as the list of matching values", () => { + const rendered = renderWebhookPrompt( + "Labels: {{body.pull_request.labels[]}} Names: {{body.reviewers[].login}}", + { + ...githubPullRequest, + bodyText: JSON.stringify({ + pull_request: { labels: ["bug", "ui"] }, + reviewers: [{ login: "ada" }, { name: "no login" }, { login: "bob" }], + }), + }, + "id", + true, + ); + assert.equal(rendered.prompt, 'Labels: [\n "bug",\n "ui"\n] Names: [\n "ada",\n "bob"\n]'); + assert.deepEqual(rendered.missing, []); + }); + it("does not resolve inherited object properties", () => { const rendered = renderWebhookPrompt( "{{body.constructor}}{{body.__proto__}}{{headers.constructor}}{{headers.__proto__}}", diff --git a/apps/server/src/scheduledTasks/webhookTemplate.ts b/apps/server/src/scheduledTasks/webhookTemplate.ts index 063d41a7ec46..84e2599dc0d7 100644 --- a/apps/server/src/scheduledTasks/webhookTemplate.ts +++ b/apps/server/src/scheduledTasks/webhookTemplate.ts @@ -83,12 +83,30 @@ function parseBody(request: WebhookRequest): unknown { } } +const WILDCARD = /^(.*)\[\]$/; + +/** + * Walks `segments` from `root`. A segment ending in `[]` names an array and + * the rest of the path applies to each element, so `labels[].name` resolves + * to the list of every label's name. Elements without the rest of the path + * are dropped; a wildcard on a non-array resolves to undefined. + */ function lookup(root: unknown, segments: ReadonlyArray): unknown { let current = root; - for (const segment of segments) { + for (const [index, segment] of segments.entries()) { if (current === null || typeof current !== "object") return undefined; - if (!Object.hasOwn(current, segment)) return undefined; - current = (current as Record)[segment]; + const wildcard = WILDCARD.exec(segment); + const key = wildcard ? wildcard[1]! : segment; + if (!Object.hasOwn(current, key)) return undefined; + current = (current as Record)[key]; + if (!wildcard) continue; + if (!Array.isArray(current)) return undefined; + const rest = segments.slice(index + 1); + return current.flatMap((element) => { + const value = rest.length === 0 ? element : lookup(element, rest); + if (value === undefined) return []; + return rest.some((part) => WILDCARD.test(part)) && Array.isArray(value) ? value : [value]; + }); } return current; } diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 67e2314a8b01..3e1ad5fbdf35 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -109,7 +109,7 @@ const WebhookFilterPath = TrimmedNonEmptyString.check( Schema.isPattern(/^(body|headers|query)(\.|$)/), ).annotate({ description: - "Request value to test, in placeholder syntax without braces: headers.x-github-event, body.action, query.source.", + "Request value to test, in placeholder syntax without braces: headers.x-github-event, body.action, query.source. A segment ending in [] fans out over an array, so body.issue.labels[].name tests every label's name.", }); /** @@ -134,7 +134,7 @@ export const ScheduledTaskWebhookFilter = Schema.Union([ Schema.Struct({ path: WebhookFilterPath, op: Schema.Literal("exists") }), ]).annotate({ description: - "Scalars compare as strings; objects and arrays only satisfy 'exists'. Every op but 'exists' fails when the path is missing.", + "Scalars compare as strings; objects and arrays only satisfy 'exists'. Every op but 'exists' fails when the path is missing. A [] wildcard path yields a list: equals, in and startsWith match when any element does, notEquals when no element equals the value, exists when the list is not empty.", }); export type ScheduledTaskWebhookFilter = typeof ScheduledTaskWebhookFilter.Type; export type ScheduledTaskWebhookFilterOp = ScheduledTaskWebhookFilter["op"]; From f482ec9fc114c46eacf0b0600396a781cf7d32b0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 01:56:45 +0000 Subject: [PATCH 5/6] test(web): move archived thread link seed into web fixtures Knip already treats apps/web/test/fixtures/**/*.mjs as entries, so the seed script no longer shows up as an unused root file. Co-Authored-By: Claude Opus 5.5 --- .../web/test/fixtures/archived-thread-links/seed.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename scripts/seed-archived-thread-links.mjs => apps/web/test/fixtures/archived-thread-links/seed.mjs (98%) diff --git a/scripts/seed-archived-thread-links.mjs b/apps/web/test/fixtures/archived-thread-links/seed.mjs similarity index 98% rename from scripts/seed-archived-thread-links.mjs rename to apps/web/test/fixtures/archived-thread-links/seed.mjs index d58647d44263..551a1da3a870 100644 --- a/scripts/seed-archived-thread-links.mjs +++ b/apps/web/test/fixtures/archived-thread-links/seed.mjs @@ -18,7 +18,7 @@ if ( !NodePath.isAbsolute(values["workspace-root"]) ) { throw new Error( - "Usage: node scripts/seed-archived-thread-links.mjs --base-dir --workspace-root ", + "Usage: node apps/web/test/fixtures/archived-thread-links/seed.mjs --base-dir --workspace-root ", ); } const base = NodeFS.realpathSync(values["base-dir"]); From 5e9cd235ad25e740416b3fa5173b69a9ac2a1972 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 07:09:28 +0000 Subject: [PATCH 6/6] test(web): fixture guard canonicalizes the live T3 home Co-Authored-By: Claude Opus 5.5 --- .../test/fixtures/archived-thread-links/seed.mjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/web/test/fixtures/archived-thread-links/seed.mjs b/apps/web/test/fixtures/archived-thread-links/seed.mjs index 551a1da3a870..df7fc37e4355 100644 --- a/apps/web/test/fixtures/archived-thread-links/seed.mjs +++ b/apps/web/test/fixtures/archived-thread-links/seed.mjs @@ -22,8 +22,17 @@ if ( ); } const base = NodeFS.realpathSync(values["base-dir"]); -const shared = NodePath.resolve(NodeOS.homedir(), ".t3"); -if (base === shared || base.startsWith(`${shared}/`)) throw new Error("Use an isolated T3 home."); +// Canonicalize both sides so a symlinked live home cannot slip past the lexical check. +const sharedPath = NodePath.resolve(NodeOS.homedir(), ".t3"); +const shared = NodeFS.existsSync(sharedPath) ? NodeFS.realpathSync(sharedPath) : sharedPath; +const fromShared = NodePath.relative(shared, base); +if ( + fromShared === "" || + (fromShared !== ".." && + !fromShared.startsWith(`..${NodePath.sep}`) && + !NodePath.isAbsolute(fromShared)) +) + throw new Error("Use an isolated T3 home."); const runtimePath = NodePath.join(base, "userdata/server-runtime.json"); if (NodeFS.existsSync(runtimePath)) { const { pid } = JSON.parse(NodeFS.readFileSync(runtimePath, "utf8"));