From 2028412e2f534a7b14451d627e473dad532b4911 Mon Sep 17 00:00:00 2001 From: Gennadi Ryan Date: Fri, 7 Aug 2026 07:33:42 +0000 Subject: [PATCH 1/3] feat(extension): bug-session orchestration + lifecycle (#251), chore(opencode.lock): pin to v1.18.10-amicode.4 (#245) --- packages/extension/src/bug_report.ts | 20 +++++++++-- packages/extension/src/extension.ts | 3 ++ packages/extension/test/bug_report.test.ts | 37 +++++++++++++++++++++ packages/extension/test/chat_bridge.test.ts | 2 +- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/extension/src/bug_report.ts b/packages/extension/src/bug_report.ts index ff43bd4..ad8856c 100644 --- a/packages/extension/src/bug_report.ts +++ b/packages/extension/src/bug_report.ts @@ -79,6 +79,16 @@ export interface BugReportDeps { showError(message: string): void; log?(line: string): void; fetchImpl?: typeof fetch; + /** The session-level model pin as `provider/model`, or undefined to let the + * server resolve its own default. Same rule as everywhere else in the + * extension: ONLY an explicit `amicode.defaultModel` pins (see + * extension.ts's boot/restart pins) — we never guess a model. + * + * This is the SESSION-level half of the model handoff. The COMPOSER-level + * half — carrying the user's live in-chat selection onto the command, via + * `extractReportBugModel` in chat_bridge.ts — is deliberately not wired + * here; it crosses the iframe boundary and is tracked separately. */ + defaultModel?(): string | undefined; } /** The bridge sink the chat/deck panels wire into their BridgeIo — the two @@ -262,11 +272,17 @@ export class BugReportManager { } /** Arm: the report-a-bug slash command as the session's first turn. - * Uses the server's default model (whatever the user has configured). */ + * + * `model` is optional on POST /session/:id/command (a `provider/model` + * string; the route also takes `variant`). We send it only when + * `amicode.defaultModel` is explicitly set — otherwise the field is omitted + * entirely and the server resolves its own default, which is the documented + * behaviour for an unpinned install. */ private async armSession(server: BugReportServer, sessionID: string): Promise { + const model = this.deps.defaultModel?.()?.trim(); const res = await this.fetch(new URL(`/session/${sessionID}/command`, server.url), server, { method: "POST", - body: { command: REPORT_A_BUG_SKILL, arguments: "" }, + body: { command: REPORT_A_BUG_SKILL, arguments: "", ...(model ? { model } : {}) }, }); if (!res.ok) throw new Error(`couldn't arm the report-a-bug skill (HTTP ${res.status})`); } diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 0e5894d..2f77b71 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -539,6 +539,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, showError: (m) => void vscode.window.showErrorMessage(m), log: (line) => opencodeChannel.appendLine(line), + // Same pin rule as the boot/restart config pins above: ONLY an explicit + // amicode.defaultModel pins a model; empty means "let the server decide". + defaultModel: () => vscode.workspace.getConfiguration("amicode").get("defaultModel", "").trim() || undefined, }); ctx.subscriptions.push({ dispose: () => unregisterBugReport() }); diff --git a/packages/extension/test/bug_report.test.ts b/packages/extension/test/bug_report.test.ts index 83f16be..fd939e8 100644 --- a/packages/extension/test/bug_report.test.ts +++ b/packages/extension/test/bug_report.test.ts @@ -48,6 +48,7 @@ function deps(overrides: Partial, fetchImpl: typeof fetch) { postDown: (m) => posted.push(m), showError: (m) => errors.push(m), fetchImpl, + defaultModel: () => "opencode/deepseek-v4-pro", ...overrides, }; return { d, posted, errors }; @@ -75,6 +76,11 @@ describe("amicode.reportBug — create, arm, open (AC1)", () => { origin_session_id: "ses_origin", }, }, + // The question tool is denied for bug sessions (ADR-0004): the dock owns + // dialogue via its textarea + session.prompt, not the question tool's + // structured Q&A. Production has sent this since #251; the assertion + // simply had not caught up. + permission: [{ permission: "question", pattern: "*", action: "deny" }], }); const arm = calls.filter((c) => c.url.endsWith("/session/ses_bug1/command")); expect(arm).toHaveLength(1); @@ -82,6 +88,37 @@ describe("amicode.reportBug — create, arm, open (AC1)", () => { expect(posted).toEqual([{ source: "amicode", kind: "open-bug-report", sessionID: "ses_bug1" }]); }); + it("omits model entirely when amicode.defaultModel is unset — the server resolves its own", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug_nm" } }, + "POST /session/ses_bug_nm/command": { status: 200, body: {} }, + }); + const { d } = deps({ defaultModel: () => undefined }, fetchImpl); + + await new BugReportManager(d).reportBug(); + + const arm = calls.filter((c) => c.url.endsWith("/session/ses_bug_nm/command")); + expect(arm).toHaveLength(1); + // Omitted, not sent as empty/null: `model` is optional on the route, and an + // empty string would pin the session to a nonexistent model. + expect(arm[0].body).toEqual({ command: "report-a-bug", arguments: "" }); + }); + + it("treats a whitespace-only defaultModel as unset", async () => { + const { fetchImpl, calls } = mockFetch({ + "GET /session": { status: 200, body: [] }, + "POST /session": { status: 200, body: { id: "ses_bug_ws" } }, + "POST /session/ses_bug_ws/command": { status: 200, body: {} }, + }); + const { d } = deps({ defaultModel: () => " " }, fetchImpl); + + await new BugReportManager(d).reportBug(); + + const arm = calls.filter((c) => c.url.endsWith("/session/ses_bug_ws/command")); + expect(arm[0].body).toEqual({ command: "report-a-bug", arguments: "" }); + }); + it("omits run_pointer when no run is active, and never sends an absolute path", async () => { const { fetchImpl, calls } = mockFetch({ "GET /session": { status: 200, body: [] }, diff --git a/packages/extension/test/chat_bridge.test.ts b/packages/extension/test/chat_bridge.test.ts index 979175c..271dba0 100644 --- a/packages/extension/test/chat_bridge.test.ts +++ b/packages/extension/test/chat_bridge.test.ts @@ -3,7 +3,7 @@ import * as vscode from "vscode"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { handleAmicodeBridgeMessage, type BridgeIo } from "../src/chat_bridge"; +import { handleAmicodeBridgeMessage, extractReportBugModel, type BridgeIo } from "../src/chat_bridge"; // ============================================================================ // The shared iframe⇄extension bridge: strict allowlists, https-only externals, From 98b7cffd59ccbb05b6db98921fd85ce5298cec63 Mon Sep 17 00:00:00 2001 From: Gennadi Ryan Date: Fri, 7 Aug 2026 23:49:13 +0000 Subject: [PATCH 2/3] Adding provenance stamping to catch bad build configurations --- packages/extension/scripts/opencode_dev.mjs | 64 +++++++++++++++++++- packages/extension/test/opencode_dev.test.ts | 43 ++++++++++++- 2 files changed, 103 insertions(+), 4 deletions(-) diff --git a/packages/extension/scripts/opencode_dev.mjs b/packages/extension/scripts/opencode_dev.mjs index 3102498..e397d3d 100755 --- a/packages/extension/scripts/opencode_dev.mjs +++ b/packages/extension/scripts/opencode_dev.mjs @@ -16,13 +16,64 @@ // Web-UI iteration (packages/app surfaces) requires a rebuild — the channel // define is baked at build time, so there is no hot path through `serve`. import { execFileSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { loadManifest, resolveCloneDir, sha256 } from "./fetch_opencode.mjs"; const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const git = (dir, ...args) => + execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); + +/** Stamp vendor/opencode//.buildinfo so anyone holding the built + * extension (dev host, remote machine, a vsix) can tell EXACTLY which fork + * state produced the vendored binary — the .source/.sha256 stamps record + * source+hash but not branch or dirty state, and --any-ref builds deliberately + * accept any checkout. JSON, one object per platform dir. */ +export function stampBuildInfo({ source, repo, version, cloneDir, tag, root = PKG_ROOT }) { + const vendorRoot = join(root, "vendor", "opencode"); + if (!existsSync(vendorRoot)) return; + const info = { + source, + version, + builtAt: new Date().toISOString(), + ...(repo ? { repo } : {}), + ...(tag ? { tag } : {}), + }; + if (cloneDir) { + info.clonePath = cloneDir; + try { + info.branch = git(cloneDir, "symbolic-ref", "--short", "-q", "HEAD") || "(detached)"; + } catch { + info.branch = "(unknown)"; + } + info.commit = git(cloneDir, "rev-parse", "HEAD"); + info.dirty = git(cloneDir, "status", "--porcelain") !== ""; + } + for (const key of readdirSync(vendorRoot)) { + const dir = join(vendorRoot, key); + if (!existsSync(join(dir, "opencode"))) continue; + writeFileSync(join(dir, ".buildinfo"), JSON.stringify(info, null, 2) + "\n"); + } +} + +/** Check a vendored binary's provenance. Prints one line per platform dir. */ +export function checkBuildInfo(root = PKG_ROOT) { + const vendorRoot = join(root, "vendor", "opencode"); + if (!existsSync(vendorRoot)) return "[opencode] no vendored binary yet"; + const lines = []; + for (const key of readdirSync(vendorRoot).sort()) { + const dir = join(vendorRoot, key); + if (!existsSync(join(dir, "opencode"))) continue; + const stamp = readdirSync(dir).includes(".buildinfo") + ? readFileSync(join(dir, ".buildinfo"), "utf8").trim().replace(/\n/g, " ") + : "(no .buildinfo — built before stamping was added; check .source/.sha256)"; + lines.push(`[opencode] ${key}: ${stamp}`); + } + return lines.length > 0 ? lines.join("\n") : "[opencode] no vendored binary yet"; +} + /** Download a private-release asset via the authenticated gh CLI (same path the * fetcher uses). Injectable for tests. */ export function ghDownloadAsset(repo, tag, asset) { @@ -98,7 +149,13 @@ function build() { execFileSync("node", [join(PKG_ROOT, "scripts", "fetch_opencode.mjs"), "--local", "--any-ref"], { stdio: ["ignore", "inherit", "inherit"], }); - console.log("[opencode:build] done — reload the Extension Dev Host (Cmd/Ctrl+R) to pick up the new binary."); + const manifest = loadManifest(); + stampBuildInfo({ source: "local", repo: manifest.repo, version: manifest.version, cloneDir }); + console.log( + `[opencode:build] done — vendored from ${cloneDir} @ ${git(cloneDir, "symbolic-ref", "--short", "-q", "HEAD")} ${git(cloneDir, "rev-parse", "HEAD").slice(0, 10)} ` + + `(dirty: ${git(cloneDir, "status", "--porcelain") !== ""}) — reload the Extension Dev Host (Cmd/Ctrl+R) to pick up the new binary.`, + ); + console.log(checkBuildInfo()); } function help() { @@ -125,8 +182,11 @@ function main(argv) { const i = rest.indexOf("--ref"); const ref = i >= 0 ? rest[i + 1] : undefined; const r = pinFromRelease({ tag, ref }); + const manifest = loadManifest(); + stampBuildInfo({ source: "release", repo: manifest.repo, version: manifest.version, tag: r.tag }); console.log(`[opencode:pin] opencode.lock.json → ${r.tag}${r.ref ? ` @ ${r.ref.slice(0, 10)}` : ""}`); for (const [k, v] of Object.entries(r.platforms)) console.log(` ${k} ${v}`); + console.log(checkBuildInfo()); console.log("[opencode:pin] commit the lock bump and open a PR."); return; } diff --git a/packages/extension/test/opencode_dev.test.ts b/packages/extension/test/opencode_dev.test.ts index d7d4b81..5c5c7ae 100644 --- a/packages/extension/test/opencode_dev.test.ts +++ b/packages/extension/test/opencode_dev.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect } from "vitest"; import { execFileSync } from "node:child_process"; -import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { join } from "node:path"; -import { pinFromRelease } from "../scripts/opencode_dev.mjs"; +import { checkBuildInfo, pinFromRelease, stampBuildInfo } from "../scripts/opencode_dev.mjs"; import { loadManifest, sha256 } from "../scripts/fetch_opencode.mjs"; const RELEASE_LOCK = { @@ -66,6 +66,45 @@ describe("pinFromRelease", () => { }); }); +describe("build provenance (.buildinfo)", () => { + const amicodeRoot = join(fileURLToPath(import.meta.url), "..", "..", ".."); + + it("stamps fork branch+commit next to the vendored binary", () => { + const root = mkdtempSync(join(tmpdir(), "ocdev-")); + const dir = join(root, "vendor", "opencode", "linux-x64"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "opencode"), "fake-binary"); + + stampBuildInfo({ source: "local", repo: "harmoniqs/opencode", version: "1.18.10", cloneDir: amicodeRoot, root }); + + const info = JSON.parse(readFileSync(join(dir, ".buildinfo"), "utf8")); + expect(info.source).toBe("local"); + expect(info.repo).toBe("harmoniqs/opencode"); + expect(info.version).toBe("1.18.10"); + expect(info.branch).toMatch(/^[a-zA-Z0-9_./-]+$/); + expect(info.commit).toMatch(/^[0-9a-f]{40}$/); + expect(typeof info.dirty).toBe("boolean"); + expect(info.builtAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it("skips dirs without a binary and survives a missing vendor dir", () => { + const root = mkdtempSync(join(tmpdir(), "ocdev-")); + expect(() => stampBuildInfo({ source: "release", version: "1.18.10", root })).not.toThrow(); + mkdirSync(join(root, "vendor", "opencode", "darwin-arm64"), { recursive: true }); // no binary inside + stampBuildInfo({ source: "release", version: "1.18.10", root }); + expect(checkBuildInfo(root)).toMatch(/no vendored binary|darwin-arm64/); + }); + + it("checkBuildInfo reports release stamps too", () => { + const root = mkdtempSync(join(tmpdir(), "ocdev-")); + const dir = join(root, "vendor", "opencode", "linux-x64"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "opencode"), "fake-binary"); + stampBuildInfo({ source: "release", repo: "harmoniqs/opencode", version: "1.18.10", tag: "v1.18.10-amicode.4", root }); + expect(checkBuildInfo(root)).toContain("v1.18.10-amicode.4"); + }); +}); + describe("assert_ui_gate.sh", () => { const script = fileURLToPath(new URL("../scripts/assert_ui_gate.sh", import.meta.url)); const fixture = (content: string): string => { From 4860c957d29266f78b3fa4a1dc68b7fc1e12942e Mon Sep 17 00:00:00 2001 From: Gennadi Ryan Date: Sat, 8 Aug 2026 04:20:16 +0000 Subject: [PATCH 3/3] fix(extension): attribute detached builds in the provenance stamp --- packages/extension/scripts/opencode_dev.mjs | 6 +++++- packages/extension/test/opencode_dev.test.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/extension/scripts/opencode_dev.mjs b/packages/extension/scripts/opencode_dev.mjs index e397d3d..2109ec0 100755 --- a/packages/extension/scripts/opencode_dev.mjs +++ b/packages/extension/scripts/opencode_dev.mjs @@ -46,7 +46,11 @@ export function stampBuildInfo({ source, repo, version, cloneDir, tag, root = PK try { info.branch = git(cloneDir, "symbolic-ref", "--short", "-q", "HEAD") || "(detached)"; } catch { - info.branch = "(unknown)"; + // Detached checkout (CI checks out the merge ref, not the branch): the + // checked-out ref is still attributable — GitHub Actions names it in + // GITHUB_HEAD_REF (PRs) / GITHUB_REF_NAME (pushes). Absent both, the + // stamp is honestly "(unknown)" rather than a guessed name. + info.branch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || "(unknown)"; } info.commit = git(cloneDir, "rev-parse", "HEAD"); info.dirty = git(cloneDir, "status", "--porcelain") !== ""; diff --git a/packages/extension/test/opencode_dev.test.ts b/packages/extension/test/opencode_dev.test.ts index 5c5c7ae..c5462a9 100644 --- a/packages/extension/test/opencode_dev.test.ts +++ b/packages/extension/test/opencode_dev.test.ts @@ -81,7 +81,9 @@ describe("build provenance (.buildinfo)", () => { expect(info.source).toBe("local"); expect(info.repo).toBe("harmoniqs/opencode"); expect(info.version).toBe("1.18.10"); - expect(info.branch).toMatch(/^[a-zA-Z0-9_./-]+$/); + // A real branch name, or the honest detached/unknown fallbacks — CI checks + // out the merge ref, so the stamp there comes from GITHUB_HEAD_REF/GITHUB_REF_NAME. + expect(info.branch).toMatch(/^(?:[a-zA-Z0-9_./-]+|\(unknown\)|\(detached\))$/); expect(info.commit).toMatch(/^[0-9a-f]{40}$/); expect(typeof info.dirty).toBe("boolean"); expect(info.builtAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);