Skip to content

Commit 323097d

Browse files
harden: pre-release audit fixes (serve no-store, CLI binary threading, Rust env fallback)
Three SHOULD-FIX gaps from the pre-release audit (no blockers found): 1. serve /api responses now carry Cache-Control: no-store. The shared security middleware set CSP/Referrer/nosniff but not no-store, so /api/invoke results (transcripts, config, mutation results) were cacheable. Added an /api-scoped no-store layer (static hashed assets stay cacheable). +test asserting no-store on both a 200 result and an error response. 2. CLI model/version discovery now uses the resolved binary path. detectOpenCode can return an absolute binary not on PATH (stock ~/.opencode/bin, a shim), but getOpenCodeVersion/getAvailableModels shelled out to bare opencode, so such installs fell back to manual entry. Threaded detection.binary through via execFile. +test with a real executable stub. 3. Rust Desktop detection no longer misreports a Desktop user as none when env vars are unset. from_process now falls back home, USERPROFILE, dirs::home_dir and derives Windows AppData/Local from home when the env vars are absent, matching the TS fallbacks. Extracted OpencodeDesktopEnv::resolve for a test. Audit confirmed clean (no change needed): serve auth/Host/Origin/body-limit, guard preservation, command parity, doctor plugin-cache, TUI badge color. Gate: dashboard Rust 225/0, CLI 226/0, tsc + biome clean across both. Co-authored-by: Alfonso [Magic Context] <288211368+alfonso-magic-context@users.noreply.github.com>
1 parent a13b330 commit 323097d

6 files changed

Lines changed: 164 additions & 23 deletions

File tree

packages/cli/src/commands/setup-opencode.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ export async function runSetup(dryRun = false): Promise<number> {
269269
"Model auto-discovery needs the OpenCode CLI; you will enter models manually. Install the CLI to auto-populate: https://opencode.ai",
270270
);
271271
} else {
272-
const version = getOpenCodeVersion();
272+
const version = getOpenCodeVersion(detection.binary);
273273
s.stop(`OpenCode ${version ?? ""} detected`);
274274
}
275275

@@ -278,8 +278,9 @@ export async function runSetup(dryRun = false): Promise<number> {
278278

279279
// Only the CLI can enumerate the authed/resolved model list; Desktop-only
280280
// installs have no on-disk equivalent, so models stay empty and the model
281-
// prompts fall back to free-text entry.
282-
const allModels = detection.kind === "cli" ? getAvailableModels() : [];
281+
// prompts fall back to free-text entry. Use the resolved binary path so a
282+
// stock CLI that is not on PATH still enumerates.
283+
const allModels = detection.kind === "cli" ? getAvailableModels(detection.binary) : [];
283284
if (allModels.length > 0) {
284285
s.stop(`Found ${allModels.length} models`);
285286
} else {

packages/cli/src/lib/diagnostics-opencode.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -726,7 +726,9 @@ export async function collectDiagnostics(): Promise<DiagnosticReport> {
726726

727727
const conflictResult = detectConflicts(process.cwd());
728728
const recentSessions = await collectRecentSessions();
729-
const openCodeInstallKind = detectOpenCode().kind;
729+
const openCodeDetection = detectOpenCode();
730+
const openCodeInstallKind = openCodeDetection.kind;
731+
const openCodeBinary = openCodeDetection.kind === "cli" ? openCodeDetection.binary : null;
730732

731733
return {
732734
timestamp: new Date().toISOString(),
@@ -736,7 +738,7 @@ export async function collectDiagnostics(): Promise<DiagnosticReport> {
736738
pluginVersion,
737739
opencodeInstalled: openCodeInstallKind !== "none",
738740
opencodeInstallKind: openCodeInstallKind,
739-
opencodeVersion: getOpenCodeVersion(),
741+
opencodeVersion: getOpenCodeVersion(openCodeBinary),
740742
configPaths,
741743
opencodeConfigHasPlugin: configHasPluginEntry(opencodeConfig.value),
742744
tuiConfigHasPlugin: configHasPluginEntry(tuiConfig.value),
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { afterEach, describe, expect, it } from "bun:test";
2+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { getAvailableModels, getOpenCodeVersion } from "./opencode-helpers";
6+
7+
// These assert that a RESOLVED absolute binary path is actually invoked (the
8+
// #196 follow-up: a stock CLI not on PATH must still enumerate). POSIX-only:
9+
// the test writes an executable shell stub, which CI runs on Linux/macOS.
10+
const isPosix = process.platform !== "win32";
11+
const tempDirs: string[] = [];
12+
13+
afterEach(() => {
14+
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
15+
});
16+
17+
function fakeOpencode(body: string): string {
18+
const dir = mkdtempSync(join(tmpdir(), "mc-oc-bin-"));
19+
tempDirs.push(dir);
20+
const bin = join(dir, "opencode");
21+
writeFileSync(bin, `#!/bin/sh\n${body}\n`);
22+
chmodSync(bin, 0o755);
23+
return bin;
24+
}
25+
26+
describe.if(isPosix)("opencode helpers with a resolved binary path", () => {
27+
it("getAvailableModels invokes the given absolute binary", () => {
28+
const bin = fakeOpencode(
29+
'if [ "$1" = "models" ]; then printf "anthropic/claude-opus-4-8\\nopenai/gpt-5.5\\n"; fi',
30+
);
31+
expect(getAvailableModels(bin)).toEqual(["anthropic/claude-opus-4-8", "openai/gpt-5.5"]);
32+
});
33+
34+
it("getOpenCodeVersion invokes the given absolute binary", () => {
35+
const bin = fakeOpencode('if [ "$1" = "--version" ]; then echo "1.2.3"; fi');
36+
expect(getOpenCodeVersion(bin)).toBe("1.2.3");
37+
});
38+
39+
it("returns empty / null when the binary path does not exist", () => {
40+
const missing = join(tmpdir(), "definitely-not-a-real-opencode-binary-xyz");
41+
expect(getAvailableModels(missing)).toEqual([]);
42+
expect(getOpenCodeVersion(missing)).toBeNull();
43+
});
44+
});
Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
1-
import { execSync } from "node:child_process";
1+
import { execFileSync, execSync } from "node:child_process";
22

3-
export function getOpenCodeVersion(): string | null {
3+
/**
4+
* Run `opencode <args>`. If a `binary` path is given (an absolute path resolved
5+
* for a stock `~/.opencode/bin` install or a version-manager shim that is not on
6+
* PATH), call that exact path via execFile; otherwise fall back to a bare
7+
* `opencode` on PATH.
8+
*/
9+
function runOpenCode(args: string[], binary?: string | null): string | null {
410
try {
5-
return execSync("opencode --version", { stdio: "pipe" }).toString().trim();
11+
if (binary) {
12+
return execFileSync(binary, args, { stdio: "pipe" }).toString().trim();
13+
}
14+
return execSync(`opencode ${args.join(" ")}`, { stdio: "pipe" })
15+
.toString()
16+
.trim();
617
} catch {
718
return null;
819
}
920
}
1021

11-
export function getAvailableModels(): string[] {
12-
try {
13-
const output = execSync("opencode models", { stdio: "pipe" }).toString().trim();
14-
return output
15-
.split("\n")
16-
.map((l) => l.trim())
17-
.filter(Boolean);
18-
} catch {
19-
return [];
20-
}
22+
export function getOpenCodeVersion(binary?: string | null): string | null {
23+
return runOpenCode(["--version"], binary);
24+
}
25+
26+
export function getAvailableModels(binary?: string | null): string[] {
27+
const output = runOpenCode(["models"], binary);
28+
if (output === null) return [];
29+
return output
30+
.split("\n")
31+
.map((l) => l.trim())
32+
.filter(Boolean);
2133
}

packages/dashboard/src-tauri/src/commands.rs

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -584,12 +584,39 @@ struct OpencodeDesktopEnv {
584584

585585
impl OpencodeDesktopEnv {
586586
fn from_process() -> Self {
587+
let home = env_path("HOME")
588+
.or_else(|| env_path("USERPROFILE"))
589+
.or_else(dirs::home_dir);
590+
Self::resolve(
591+
home,
592+
env_path("APPDATA"),
593+
env_path("LOCALAPPDATA"),
594+
env_path("XDG_CONFIG_HOME"),
595+
env_path("XDG_DATA_HOME"),
596+
)
597+
}
598+
599+
/// Derive the env from already-resolved roots. Falls back to home-derived
600+
/// Windows AppData roots when those env vars are unset, so a Desktop user is
601+
/// never misreported as having no OpenCode install just because %APPDATA% /
602+
/// %LOCALAPPDATA% are absent in this process environment. Kept in lockstep
603+
/// with the CLI's opencode-detect.ts fallbacks.
604+
fn resolve(
605+
home: Option<PathBuf>,
606+
appdata: Option<PathBuf>,
607+
localappdata: Option<PathBuf>,
608+
xdg_config_home: Option<PathBuf>,
609+
xdg_data_home: Option<PathBuf>,
610+
) -> Self {
611+
let appdata = appdata.or_else(|| home.as_ref().map(|h| h.join("AppData").join("Roaming")));
612+
let localappdata =
613+
localappdata.or_else(|| home.as_ref().map(|h| h.join("AppData").join("Local")));
587614
Self {
588-
home: env_path("HOME"),
589-
appdata: env_path("APPDATA"),
590-
localappdata: env_path("LOCALAPPDATA"),
591-
xdg_config_home: env_path("XDG_CONFIG_HOME"),
592-
xdg_data_home: env_path("XDG_DATA_HOME"),
615+
home,
616+
appdata,
617+
localappdata,
618+
xdg_config_home,
619+
xdg_data_home,
593620
mac_system_applications: PathBuf::from("/Applications"),
594621
}
595622
}
@@ -1287,6 +1314,25 @@ mod tests {
12871314
);
12881315
}
12891316

1317+
#[test]
1318+
fn windows_desktop_marker_detected_when_appdata_env_unset() {
1319+
// With %APPDATA% unset but home known, the Windows userData marker must
1320+
// still resolve from the home-derived AppData/Roaming fallback.
1321+
let dir = tempfile::tempdir().expect("tempdir");
1322+
let home = dir.path().join("home");
1323+
let env = OpencodeDesktopEnv::resolve(Some(home.clone()), None, None, None, None);
1324+
write_file(
1325+
home.join("AppData")
1326+
.join("Roaming")
1327+
.join(OPENCODE_DESKTOP_APP_IDS[0])
1328+
.join("opencode.settings"),
1329+
);
1330+
assert!(opencode_desktop_detected_for_env(
1331+
DesktopPlatform::Windows,
1332+
&env
1333+
));
1334+
}
1335+
12901336
#[test]
12911337
fn opencode_desktop_settings_marker_detects_each_channel() {
12921338
for app_id in OPENCODE_DESKTOP_APP_IDS {

packages/dashboard/src-tauri/src/serve/mod.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ pub fn build_router(app_state: Arc<AppState>, options: &ServeOptions, token: Str
149149
.route("/invoke", post(invoke_handler))
150150
.route("/*path", any(api_not_found))
151151
.route_layer(middleware::from_fn_with_state(state.clone(), api_guard))
152+
// /api responses carry transcripts, config, and mutation results: never
153+
// let a browser (or intermediary) cache them. Static hashed assets stay
154+
// cacheable; only this sub-tree is marked no-store.
155+
.layer(middleware::from_fn(add_no_store_header))
152156
.layer(DefaultBodyLimit::max(MAX_JSON_BODY_BYTES));
153157

154158
Router::new()
@@ -264,6 +268,15 @@ async fn add_security_headers(request: Request, next: Next) -> Response {
264268
response
265269
}
266270

271+
async fn add_no_store_header(request: Request, next: Next) -> Response {
272+
let mut response = next.run(request).await;
273+
response.headers_mut().insert(
274+
header::CACHE_CONTROL,
275+
header::HeaderValue::from_static("no-store"),
276+
);
277+
response
278+
}
279+
267280
async fn index_handler() -> Response {
268281
index_response()
269282
}
@@ -746,6 +759,29 @@ mod tests {
746759
assert_eq!(body["error"], "Unknown command");
747760
}
748761

762+
#[tokio::test]
763+
async fn api_responses_are_marked_no_store() {
764+
let server = spawn_test_server().await;
765+
766+
// A successful command response carries data and must not be cached.
767+
let ok = invoke(&server, json!({ "cmd": "get_db_health", "args": {} })).await;
768+
assert_eq!(
769+
ok.headers()
770+
.get(reqwest::header::CACHE_CONTROL)
771+
.map(|v| v.to_str().unwrap().to_string()),
772+
Some("no-store".to_string()),
773+
);
774+
775+
// Error responses are no-store too (they can echo arguments back).
776+
let err = invoke(&server, json!({ "cmd": "missing_command", "args": {} })).await;
777+
assert_eq!(
778+
err.headers()
779+
.get(reqwest::header::CACHE_CONTROL)
780+
.map(|v| v.to_str().unwrap().to_string()),
781+
Some("no-store".to_string()),
782+
);
783+
}
784+
749785
#[tokio::test]
750786
async fn route_read_command_returns_json_200() {
751787
let server = spawn_test_server().await;

0 commit comments

Comments
 (0)