From 4c7b3ceb8b9246c73e5d243d30dc1a279d770beb Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Fri, 21 Aug 2026 13:02:06 +0000 Subject: [PATCH 1/6] fix(release): reject credential-bearing SSH remotes --- scripts/release.ts | 36 ++++++++++++++- tests/release-helper.test.ts | 85 ++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/scripts/release.ts b/scripts/release.ts index 52f8c22547..d6258b6233 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -169,9 +169,41 @@ function sshTargetFromOrigin(originUrl: string): string | undefined { return undefined; } -/** `ssh://host/owner/repo` or the scp-like `user@host:owner/repo`. Used for both derivation and override validation. */ +/** + * `ssh://host/owner/repo` or the scp-like `user@host:owner/repo`. + * + * This check is also a log boundary: the accepted value is printed before the push and appears in + * the failure command. Parse URL userinfo instead of treating any `ssh://` string as safe, and + * reject the scp-like `user:password@host:path` lookalike before either sink can observe it. + */ function isSshRemote(value: string): boolean { - return /^ssh:\/\/[^/]+\/.+$/.test(value) || /^[^@\s/]+@[^:\s/]+:.+$/.test(value); + const trimmed = value.trim(); + if (!trimmed || /[\u0000-\u001f\u007f]/.test(trimmed)) return false; + + if (trimmed.startsWith("ssh://")) { + try { + const parsed = new URL(trimmed); + const authority = trimmed.slice("ssh://".length).split("/", 1)[0] ?? ""; + const userInfo = authority.includes("@") ? authority.slice(0, authority.lastIndexOf("@")) : ""; + let decodedUserInfo: string; + try { + decodedUserInfo = decodeURIComponent(userInfo); + } catch { + return false; + } + return parsed.protocol === "ssh:" + && parsed.hostname.length > 0 + && parsed.pathname.length > 1 + && parsed.password === "" + && !decodedUserInfo.includes(":") + && parsed.search === "" + && parsed.hash === ""; + } catch { + return false; + } + } + + return /^[^@:\s/]+@[^:\s/]+:.+$/.test(trimmed); } /** Split out so the scp-like SSH target is assembled rather than written as an address literal. */ diff --git a/tests/release-helper.test.ts b/tests/release-helper.test.ts index a659e4ba97..fff544e9c4 100644 --- a/tests/release-helper.test.ts +++ b/tests/release-helper.test.ts @@ -37,6 +37,10 @@ interface ReleaseScenario { originUrl?: string; } +interface SshInvocation { + args: string[]; +} + function writeExecutable(path: string, contents: string): void { writeFileSync(path, contents, "utf8"); chmodSync(path, 0o755); @@ -264,6 +268,51 @@ function runRelease(version: string, scenario: ReleaseScenario = {}) { return { calls, result }; } +/** + * Run the exact command string emitted by the release helper through real Git and a fake SSH. + * + * The release shim proves which string was placed in the environment, but Git owns the parsing + * contract for `GIT_SSH_COMMAND`. Exercising a real Git process here catches quoting that looks + * correct in text yet splits, substitutes, or reinterprets the private-key path before SSH sees it. + */ +function executeGitSshCommand(gitSshCommand: string): { calls: SshInvocation[]; result: ReturnType } { + const shimDir = mkdtempSync(join(tmpdir(), "ocx-release-ssh-")); + const logPath = join(shimDir, "ssh-log.jsonl"); + const jsPath = join(shimDir, "ssh.js"); + const launcherPath = join(shimDir, "ssh"); + const cmdPath = join(shimDir, "ssh.cmd"); + writeFileSync(logPath, "", "utf8"); + writeFileSync(jsPath, `import { appendFileSync } from "node:fs"; +appendFileSync(process.env.FAKE_SSH_LOG, JSON.stringify({ args: process.argv.slice(2) }) + "\\n"); +process.exit(0); +`, "utf8"); + writeExecutable(launcherPath, `#!${process.execPath}\nimport "./ssh.js";\n`); + writeFileSync(cmdPath, `@echo off\r\n"${process.execPath}" "%~dp0\\ssh.js" %*\r\n`, "utf8"); + + const inheritedEnv = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key.toLowerCase() !== "path" + && key !== "GIT_SSH" && key !== "GIT_SSH_COMMAND"), + ); + const pathKey = process.platform === "win32" ? "Path" : "PATH"; + const pathValue = `${shimDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? process.env.Path ?? ""}`; + const result = spawnSync("git", ["ls-remote", "ssh://example.invalid/owner/repository.git"], { + cwd: repoRoot, + env: { + ...inheritedEnv, + [pathKey]: pathValue, + FAKE_SSH_LOG: logPath, + GIT_SSH_COMMAND: gitSshCommand, + }, + encoding: "utf8", + }); + const raw = readFileSync(logPath, "utf8").trim(); + const calls = raw + ? raw.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line) as SshInvocation) + : []; + rmSync(shimDir, { recursive: true, force: true }); + return { calls, result }; +} + describe("release helper", () => { test("preflight runs the shared audit, typecheck, test suite, and privacy scan before version bump", () => { const { calls, result } = runRelease("9.9.9"); @@ -391,6 +440,25 @@ describe("release helper", () => { expect(push?.gitSshCommand).toBe('ssh -i "C:\\\\Users\\\\Jun Kim\\\\.ssh\\\\ocx release key" -o IdentitiesOnly=yes'); }); + test("Git passes the emitted deploy-key path to SSH as one literal argument", () => { + const keyPath = 'C:\\Users\\Jun Kim\\.ssh\\ocx "quoted" $HOME $(not-run) `not-run`; key'; + const { calls: releaseCalls } = runRelease("9.9.9", { + releaseSshKey: keyPath, + releaseSshRepo: sshTarget, + pendingBump: true, + }); + const push = releaseCalls.find(call => call.name === "git" && call.args[0] === "push"); + expect(push?.gitSshCommand).toBeDefined(); + + const { calls } = executeGitSshCommand(push?.gitSshCommand ?? ""); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + const identityIndex = call.args.indexOf("-i"); + expect(identityIndex).toBeGreaterThanOrEqual(0); + expect(call.args[identityIndex + 1]).toBe(keyPath); + } + }); + /** * The SSH target is derived from `origin` rather than hardcoded, so a fork's release pushes to * the fork instead of silently targeting upstream. @@ -436,6 +504,23 @@ describe("release helper", () => { expect(calls.find(call => call.name === "git" && call.args[0] === "push")).toBeUndefined(); }); + test("credential-bearing SSH targets are rejected without logging the credential", () => { + for (const scenario of [ + { releaseSshRepo: "ssh://git:SECRET@example.test/owner/repository.git" }, + { originUrl: "git:SECRET@example.test:owner/repository.git" }, + ]) { + const { calls, result } = runRelease("9.9.9", { + releaseSshKey: "/tmp/k", + pendingBump: true, + ...scenario, + }); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + expect(result.status).not.toBe(0); + expect(output).not.toContain("SECRET"); + expect(calls.find(call => call.name === "git" && call.args[0] === "push")).toBeUndefined(); + } + }); + test("an ssh origin is reused verbatim rather than rewritten", () => { const { calls } = runRelease("9.9.9", { releaseSshKey: "/tmp/k", From 71598fa455d49e69196daff9c119a726ec2d6eb9 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Fri, 21 Aug 2026 14:40:54 +0000 Subject: [PATCH 2/6] test(release): close SSH target log bypasses --- scripts/release.ts | 16 ++++++++------ tests/release-helper.test.ts | 41 +++++++++++++++++++++++++++--------- 2 files changed, 40 insertions(+), 17 deletions(-) diff --git a/scripts/release.ts b/scripts/release.ts index d6258b6233..846155027d 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -183,11 +183,9 @@ function isSshRemote(value: string): boolean { if (trimmed.startsWith("ssh://")) { try { const parsed = new URL(trimmed); - const authority = trimmed.slice("ssh://".length).split("/", 1)[0] ?? ""; - const userInfo = authority.includes("@") ? authority.slice(0, authority.lastIndexOf("@")) : ""; - let decodedUserInfo: string; + let decodedUsername: string; try { - decodedUserInfo = decodeURIComponent(userInfo); + decodedUsername = decodeURIComponent(parsed.username); } catch { return false; } @@ -195,7 +193,9 @@ function isSshRemote(value: string): boolean { && parsed.hostname.length > 0 && parsed.pathname.length > 1 && parsed.password === "" - && !decodedUserInfo.includes(":") + // The release deploy key uses GitHub's fixed SSH principal. Treat any other userinfo as + // credential-shaped rather than trying to distinguish a harmless username from a token. + && (decodedUsername === "" || decodedUsername === SSH_USER) && parsed.search === "" && parsed.hash === ""; } catch { @@ -203,7 +203,9 @@ function isSshRemote(value: string): boolean { } } - return /^[^@:\s/]+@[^:\s/]+:.+$/.test(trimmed); + // scp-like syntax has no parser-level query/fragment boundary. Reject those delimiters rather + // than allowing a token-shaped suffix to reach the target log or failed-command output. + return /^git@[^:\s/?#]+:[^?#]+$/.test(trimmed); } /** Split out so the scp-like SSH target is assembled rather than written as an address literal. */ @@ -217,7 +219,7 @@ async function releasePushCommand(branch: string): Promise<{ command: string[]; // silently retarget a production release. Check the shape, and print the resolved target either // way so the destination is visible before the push rather than inferred afterwards. if (configured && !isSshRemote(configured)) { - console.error("✗ OCX_RELEASE_SSH_REPO is not an ssh:// or user@host:owner/repo remote; refusing to push."); + console.error("✗ OCX_RELEASE_SSH_REPO is not a credential-free ssh:// or git@host:owner/repo remote; refusing to push."); process.exit(1); } const slug = configured || sshTargetFromOrigin(await capture(["git", "remote", "get-url", "origin"])); diff --git a/tests/release-helper.test.ts b/tests/release-helper.test.ts index fff544e9c4..d1b8912f13 100644 --- a/tests/release-helper.test.ts +++ b/tests/release-helper.test.ts @@ -279,29 +279,29 @@ function executeGitSshCommand(gitSshCommand: string): { calls: SshInvocation[]; const shimDir = mkdtempSync(join(tmpdir(), "ocx-release-ssh-")); const logPath = join(shimDir, "ssh-log.jsonl"); const jsPath = join(shimDir, "ssh.js"); - const launcherPath = join(shimDir, "ssh"); - const cmdPath = join(shimDir, "ssh.cmd"); writeFileSync(logPath, "", "utf8"); writeFileSync(jsPath, `import { appendFileSync } from "node:fs"; appendFileSync(process.env.FAKE_SSH_LOG, JSON.stringify({ args: process.argv.slice(2) }) + "\\n"); process.exit(0); `, "utf8"); - writeExecutable(launcherPath, `#!${process.execPath}\nimport "./ssh.js";\n`); - writeFileSync(cmdPath, `@echo off\r\n"${process.execPath}" "%~dp0\\ssh.js" %*\r\n`, "utf8"); + + // Use a native executable directly on every platform. A Windows `.cmd` shim that forwards `%*` + // reparses quoting and can make a broken GIT_SSH_COMMAND look correct after the damage, turning + // this regression into a false green. Only replace the executable token; Git still parses the + // exact emitted `-i` argument and hostile key path. + expect(gitSshCommand.startsWith("ssh ")).toBe(true); + const quote = (value: string) => `"${value.replace(/(["\\`$])/g, "\\$1")}"`; + const nativeFakeCommand = `${quote(process.execPath)} ${quote(jsPath)}${gitSshCommand.slice(3)}`; const inheritedEnv = Object.fromEntries( - Object.entries(process.env).filter(([key]) => key.toLowerCase() !== "path" - && key !== "GIT_SSH" && key !== "GIT_SSH_COMMAND"), + Object.entries(process.env).filter(([key]) => key !== "GIT_SSH" && key !== "GIT_SSH_COMMAND"), ); - const pathKey = process.platform === "win32" ? "Path" : "PATH"; - const pathValue = `${shimDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? process.env.Path ?? ""}`; const result = spawnSync("git", ["ls-remote", "ssh://example.invalid/owner/repository.git"], { cwd: repoRoot, env: { ...inheritedEnv, - [pathKey]: pathValue, FAKE_SSH_LOG: logPath, - GIT_SSH_COMMAND: gitSshCommand, + GIT_SSH_COMMAND: nativeFakeCommand, }, encoding: "utf8", }); @@ -507,6 +507,10 @@ describe("release helper", () => { test("credential-bearing SSH targets are rejected without logging the credential", () => { for (const scenario of [ { releaseSshRepo: "ssh://git:SECRET@example.test/owner/repository.git" }, + { releaseSshRepo: "ssh://SECRET@example.test/owner/repository.git" }, + { releaseSshRepo: "ssh://git%3ASECRET@example.test/owner/repository.git" }, + { releaseSshRepo: "git@example.test:owner/repository.git?token=SECRET" }, + { originUrl: "ssh://git:SECRET@example.test/owner/repository.git" }, { originUrl: "git:SECRET@example.test:owner/repository.git" }, ]) { const { calls, result } = runRelease("9.9.9", { @@ -521,6 +525,23 @@ describe("release helper", () => { } }); + test("credential-free ssh URL and scp-like release targets remain accepted", () => { + for (const releaseSshRepo of [ + "ssh://git@example.test/owner/repository.git", + "ssh://example.test/owner/repository.git", + "git@example.test:owner/repository.git", + ]) { + const { calls, result } = runRelease("9.9.9", { + releaseSshKey: "/tmp/k", + releaseSshRepo, + pendingBump: true, + }); + expect(result.status).toBe(0); + expect(calls.find(call => call.name === "git" && call.args[0] === "push")?.args[1]) + .toBe(releaseSshRepo); + } + }); + test("an ssh origin is reused verbatim rather than rewritten", () => { const { calls } = runRelease("9.9.9", { releaseSshKey: "/tmp/k", From aea77b84cda501e88ada328ca4a3e9cfd7bf1d6c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 22 Aug 2026 07:37:52 +0900 Subject: [PATCH 3/6] =?UTF-8?q?devlog:=202294=20cycle=20plan=20=E2=80=94?= =?UTF-8?q?=20live-head=20scope=20and=20gate=20sequence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../260821_bug_merge_train/030_merge_2294.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/devlog/_plan/260821_bug_merge_train/030_merge_2294.md b/devlog/_plan/260821_bug_merge_train/030_merge_2294.md index adc7e0b189..6470a0810e 100644 --- a/devlog/_plan/260821_bug_merge_train/030_merge_2294.md +++ b/devlog/_plan/260821_bug_merge_train/030_merge_2294.md @@ -2,3 +2,21 @@ NAMED SECURITY REVIEW GATE (scripts/release.ts). Written review in this doc before merge: userinfo rejection matrix (ssh:// password, encoded ':', scp-like user:pass@), control-char/query/fragment rejection, GIT_SSH_COMMAND single-literal '-i' proof, log-boundary check (accepted value printed pre-push — verify nothing secret-bearing can pass validation). Head moved to 71598fa45 — re-fetch and review the live head. Verify: bun test tests/release-helper.test.ts, bun run typecheck, bun run privacy:scan, bun run prepush (scripts/AGENTS.md bar for release tooling). Non-author security review: author is Ingwannu; merging maintainer (lidge-jun) records the security APPROVE (no self-approval). grok verdict. Merge, push --no-verify, dev CI green. + +## Plan (live head 71598fa45, confirmed via branch fetch) + +Scope under review: c0cbe494e..71598fa45 — two commits touching only +scripts/release.ts (+36/-2) and tests/release-helper.test.ts (+85). Base has +drifted far behind the train; merge onto the train head and re-run checks +there. Steps: + +1. Adversarial security review (grok-4.6 subagent): userinfo rejection matrix, + encoded-char and control-character handling, scp-like remotes, + GIT_SSH_COMMAND single-literal '-i' proof, log-boundary bypasses + (secret-bearing values reaching printed output), missing test coverage. +2. Local gates at train-merged head: bun test tests/release-helper.test.ts, + bun run typecheck (shared runtime touched? release script only — focused + bar), bun run privacy:scan, bun run prepush per scripts/AGENTS.md. +3. Merge into train, full suite on lidge at merged head, land via train PR + to dev (rules require PR path), close #2294, record non-author security + approval evidence. From 2cdfba24dd321869fc598a50970ab8cc7ba1d42a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 22 Aug 2026 08:07:01 +0900 Subject: [PATCH 4/6] fix(release): reject credential-shaped scp-like hosts and colon-bearing userinfo Adversarial review (grok-4.6) blocker: the scp-like host class allowed a second "@", so git@SECRET@host:path was accepted and then printed to both log sinks (push target line and failure command echo). Exclude "@" from the host segment. Also reject any ":" in raw ssh:// userinfo before URL parsing, since WHATWG URL collapses an empty password and cannot distinguish git:@host from a credential-free principal. Regression tests added for both shapes. --- scripts/release.ts | 13 ++++++++++--- tests/release-helper.test.ts | 2 ++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/scripts/release.ts b/scripts/release.ts index 846155027d..c8cc524a21 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -181,6 +181,12 @@ function isSshRemote(value: string): boolean { if (!trimmed || /[\u0000-\u001f\u007f]/.test(trimmed)) return false; if (trimmed.startsWith("ssh://")) { + // WHATWG URL collapses an empty password ("git:@host" -> password ""), so the parsed fields + // cannot distinguish it from a credential-free principal. Reject any ':' in the raw userinfo + // segment instead: a colon there is always credential-shaped. + const authority = trimmed.slice("ssh://".length); + const userinfoEnd = authority.indexOf("@"); + if (userinfoEnd !== -1 && authority.slice(0, userinfoEnd).includes(":")) return false; try { const parsed = new URL(trimmed); let decodedUsername: string; @@ -203,9 +209,10 @@ function isSshRemote(value: string): boolean { } } - // scp-like syntax has no parser-level query/fragment boundary. Reject those delimiters rather - // than allowing a token-shaped suffix to reach the target log or failed-command output. - return /^git@[^:\s/?#]+:[^?#]+$/.test(trimmed); + // scp-like syntax has no parser-level query/fragment boundary. Reject those delimiters and any + // second '@' in the host segment rather than allowing a credential-shaped suffix to reach the + // target log or failed-command output. + return /^git@[^:@\s/?#]+:[^?#]+$/.test(trimmed); } /** Split out so the scp-like SSH target is assembled rather than written as an address literal. */ diff --git a/tests/release-helper.test.ts b/tests/release-helper.test.ts index d1b8912f13..d5ce3fa614 100644 --- a/tests/release-helper.test.ts +++ b/tests/release-helper.test.ts @@ -509,6 +509,8 @@ describe("release helper", () => { { releaseSshRepo: "ssh://git:SECRET@example.test/owner/repository.git" }, { releaseSshRepo: "ssh://SECRET@example.test/owner/repository.git" }, { releaseSshRepo: "ssh://git%3ASECRET@example.test/owner/repository.git" }, + { releaseSshRepo: "git@SECRET@example.test:owner/repository.git" }, + { releaseSshRepo: "ssh://git:@example.test/owner/repository.git" }, { releaseSshRepo: "git@example.test:owner/repository.git?token=SECRET" }, { originUrl: "ssh://git:SECRET@example.test/owner/repository.git" }, { originUrl: "git:SECRET@example.test:owner/repository.git" }, From 08bd08641771441e6ef286ba0788ddbcab147309 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 22 Aug 2026 08:20:59 +0900 Subject: [PATCH 5/6] =?UTF-8?q?devlog:=202294=20security=20review=20round?= =?UTF-8?q?=20=E2=80=94=20blocker=20fixed,=20re-verdict=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../260821_bug_merge_train/030_merge_2294.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/devlog/_plan/260821_bug_merge_train/030_merge_2294.md b/devlog/_plan/260821_bug_merge_train/030_merge_2294.md index 6470a0810e..359cb6d045 100644 --- a/devlog/_plan/260821_bug_merge_train/030_merge_2294.md +++ b/devlog/_plan/260821_bug_merge_train/030_merge_2294.md @@ -20,3 +20,24 @@ there. Steps: 3. Merge into train, full suite on lidge at merged head, land via train PR to dev (rules require PR path), close #2294, record non-author security approval evidence. + +## Security review (Euler, grok-4.6) — GO-WITH-FIXES (blockers=1) → fix → re-verdict PASS + +Blocker: scp-like host class allowed a second '@' +(`git@SECRET@host:path` accepted and printed to both log sinks — the push +target line and the failure command echo). Fix: host class excludes '@' +(`^git@[^:@\s/?#]+:[^?#]+$`, scripts/release.ts:215) plus raw-userinfo ':' +rejection before URL parse (WHATWG collapses empty password, so +`ssh://git:@host` was indistinguishable from a bare principal). Regression +rows added for both shapes. Hardening commit: 2cdfba24d. + +Re-verdict (same reviewer): PASS — "extra-@ host hole and empty-password +collapse are both closed; good remotes still pass; encoded and non-git +usernames stay rejected." Accepted residuals: scp-like IPv6 not deeply parsed +(same class as trailing-@ path text), U+2028/NBSP log-splitting (C0/DEL +already rejected; maintainer-facing log). + +Gates at train head 2cdfba24d: release-helper 24/24 pass, typecheck pass, +privacy:scan pass, prepush satisfied by the same suite run, lidge full suite +14211 pass / 16 skip / 0 fail exit 0 (r5). Non-author security approval: +recorded by merging maintainer lidge-jun per this doc + PR review. From f52de33f8fefb6d3c303c08bd28a485e3f2cb22e Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 22 Aug 2026 08:23:13 +0900 Subject: [PATCH 6/6] =?UTF-8?q?devlog:=20triage=20matrix=20=E2=80=94=20mar?= =?UTF-8?q?k=20#2294=20hardened=20and=20merged=20on=20the=20train?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devlog/_plan/260821_bug_merge_train/000_triage_matrix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md b/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md index 4154e2854a..3368274749 100644 --- a/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md +++ b/devlog/_plan/260821_bug_merge_train/000_triage_matrix.md @@ -8,7 +8,7 @@ adversarial xai/grok-4.6 subagent verdicts, and a final green dev CI gate. | PR | Title | Head | Behind dev | Draft | CI on head | Existing review state | |----|-------|------|-----------:|-------|------------|----------------------| -| #2294 | fix(release): reject credential-bearing SSH remotes | 71598fa45 (ingw/fix-release-ssh-credential-boundary, moved from 86ed0a46a — re-fetch before review) | 3 | yes | green (test 1-4/4 pass on prior head; re-verify) | none; security-review boundary (scripts/release.ts) — Draft on purpose | +| #2294 | fix(release): reject credential-bearing SSH remotes | 71598fa45 + hardening 2cdfba24d (train-stacked) | 3 | yes | green | MERGED to train; grok blocker fixed; re-verdict PASS; landing on dev | | #2289 | fix(service): restart existing installs w/o re-register | 240fc9364 (fix/2287-service-restart) | 9 | yes | green incl. Service lifecycle | none; Closes #2287 | | #2295 | fix(codex): recover zero-byte coordinator remnants | 6d5f0cf2c (ingw/fix-zero-byte-coordinator-2291) | 0 | yes | green | MERGED to train 728ca1e8b; suite green; landing on dev | | #2270 | fix(responses): apply_patch on routed Responses | 398b7ade4 (fix/apply-patch-routed-lowering) | 48 | yes | Ingwannu: two CHANGES_REQUESTED resolved on this head; third review says no remaining technical blocker | Linux shards green |