Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions packages/extension/scripts/opencode_dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,68 @@
// 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/<platform>/.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 {
// 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") !== "";
}
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) {
Expand Down Expand Up @@ -98,7 +153,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() {
Expand All @@ -125,8 +186,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;
}
Expand Down
20 changes: 18 additions & 2 deletions packages/extension/src/bug_report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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})`);
}
Expand Down
3 changes: 3 additions & 0 deletions packages/extension/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise<void> {
},
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<string>("defaultModel", "").trim() || undefined,
});
ctx.subscriptions.push({ dispose: () => unregisterBugReport() });

Expand Down
37 changes: 37 additions & 0 deletions packages/extension/test/bug_report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function deps(overrides: Partial<BugReportDeps>, fetchImpl: typeof fetch) {
postDown: (m) => posted.push(m),
showError: (m) => errors.push(m),
fetchImpl,
defaultModel: () => "opencode/deepseek-v4-pro",
...overrides,
};
return { d, posted, errors };
Expand Down Expand Up @@ -75,13 +76,49 @@ 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);
expect(arm[0].body).toEqual({ command: "report-a-bug", arguments: "", model: "opencode/deepseek-v4-pro" });
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: [] },
Expand Down
2 changes: 1 addition & 1 deletion packages/extension/test/chat_bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 43 additions & 2 deletions packages/extension/test/opencode_dev.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -66,6 +66,47 @@ 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");
// 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/);
});

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 => {
Expand Down
Loading