mirror of
https://github.com/Canner/WrenAI.git
synced 2026-10-16 15:07:41 +08:00
fix(genbi): fail closed before native Codex launch
This commit is contained in:
@@ -26,6 +26,7 @@ import type { CodexWrenHome } from "./native-wren-home.js";
|
||||
import { runtimeSettingsCorrection } from "./runtime-binding.js";
|
||||
import { sealNativeClaudeResumeHandle, sealNativeResumeHandle, unsealNativeResumeHandle } from "./native-session-resume.js";
|
||||
import { RuntimeHost } from "./runtime-host/local.js";
|
||||
import { runtimeNotReady } from "./runtime-host/policy.js";
|
||||
import type { RuntimeReadiness } from "./runtime-host/types.js";
|
||||
|
||||
export const NATIVE_VENDORS = ["claude", "codex"] as const;
|
||||
@@ -52,6 +53,7 @@ const START_SEPARATE_REPLAY_LIMIT = 128;
|
||||
const CODEX_NATIVE_PERMISSION_HEADER = "[permissions.warble_native_wren.filesystem]";
|
||||
const PROVIDER_SESSION_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const CODEX_RESUME_HANDLE_FILE = path.join(".warble", "codex-thread-id");
|
||||
const CODEX_WREN_RUNTIME_UNPROVISIONED = runtimeNotReady("codex-app-server", "unprovisioned", "codex_wren_runtime_unprovisioned");
|
||||
|
||||
type NativeLaunchPhase =
|
||||
| "workspace"
|
||||
@@ -319,6 +321,13 @@ export interface NativeSessionServiceOptions {
|
||||
readonly warbleBin: string;
|
||||
/** Absolute server-configured Wren shim for native Codex sessions; never a request input. */
|
||||
readonly wrenShim?: string;
|
||||
/**
|
||||
* Server-only managed-runtime record resolver. Phase 2A intentionally has
|
||||
* no production implementation: absence is the fail-closed launch fence.
|
||||
* A later provisioner must revalidate its immutable record in this callback;
|
||||
* the editable `wrenShim` resolver is never a launch fallback.
|
||||
*/
|
||||
readonly resolveManagedCodexWrenRuntime?: () => NativeWrenRuntime;
|
||||
/** Fixed native child HOME captured by BFF composition, never request input. */
|
||||
readonly nativeHome?: string;
|
||||
/** Decision-17 profile source selected by server setup mode. */
|
||||
@@ -1378,6 +1387,14 @@ export class NativeSessionService {
|
||||
const selectedRuntimeUnavailable = runtimeHostReadiness.selectedReadiness.state === "ready"
|
||||
? undefined
|
||||
: runtimeHostReadiness.selectedReadiness.message;
|
||||
const codexBackendReadiness = runtimeHostReadiness.backends["codex-app-server"];
|
||||
const codexWrenRuntimeUnavailable = this.legacyFixtureMode()
|
||||
? undefined
|
||||
: this.options.resolveManagedCodexWrenRuntime === undefined
|
||||
? CODEX_WREN_RUNTIME_UNPROVISIONED.message
|
||||
: codexBackendReadiness.state === "ready"
|
||||
? undefined
|
||||
: codexBackendReadiness.message;
|
||||
const mcpReadiness = this.options.artifactService?.readiness();
|
||||
const runtime = this.options.store.getNativeRuntimeBinding();
|
||||
const runtimeCorrection = this.options.store.hasExplicitRuntimeSettings()
|
||||
@@ -1419,7 +1436,8 @@ export class NativeSessionService {
|
||||
: mcpReadiness && !mcpReadiness.available
|
||||
? mcpReadiness.reason
|
||||
: undefined;
|
||||
const reason = runtimeCorrection ?? selectedRuntimeUnavailable ?? sharedReason;
|
||||
const vendorRuntimeUnavailable = dispatch?.provider === "codex" ? codexWrenRuntimeUnavailable : undefined;
|
||||
const reason = runtimeCorrection ?? selectedRuntimeUnavailable ?? vendorRuntimeUnavailable ?? sharedReason;
|
||||
const executable = dispatch?.provider === "claude" ? "claude" : "codex";
|
||||
const unavailable = reason ?? (!hostAvailable
|
||||
? "native terminal host cannot spawn local processes on this machine"
|
||||
@@ -1431,7 +1449,9 @@ export class NativeSessionService {
|
||||
// one is unusable and why instead of only that "the producer" is.
|
||||
if (!legacyFixture && producer) for (const vendor of NATIVE_VENDORS) {
|
||||
const probe = producer.vendors[vendor];
|
||||
vendorReadiness[vendor] = probe?.available === false
|
||||
vendorReadiness[vendor] = vendor === "codex" && codexWrenRuntimeUnavailable
|
||||
? { available: false, reason: codexWrenRuntimeUnavailable }
|
||||
: probe?.available === false
|
||||
? { available: false, reason: probe.reason ?? "native session producer is incompatible" }
|
||||
: !executableAvailable(vendor)
|
||||
? { available: false, reason: `the ${vendor === "claude" ? "claude" : "codex"} interactive CLI is not available on this machine` }
|
||||
@@ -1496,6 +1516,17 @@ export class NativeSessionService {
|
||||
: capturedRuntime;
|
||||
const dispatchDefinition = dispatchForPurpose(purpose, legacyRuntime);
|
||||
if (!dispatchDefinition) throw new InteractiveLaunchError("native sessions require a saved Runtime & authentication binding");
|
||||
let managedCodexWrenRuntime: NativeWrenRuntime | undefined;
|
||||
if (dispatchDefinition.provider === "codex" && !this.legacyFixtureMode()) {
|
||||
const codexReadiness = runtimeReadiness.backends["codex-app-server"];
|
||||
if (codexReadiness.state !== "ready") throw new InteractiveLaunchError(codexReadiness.message);
|
||||
if (!this.options.resolveManagedCodexWrenRuntime) throw new InteractiveLaunchError(CODEX_WREN_RUNTIME_UNPROVISIONED.message);
|
||||
try {
|
||||
managedCodexWrenRuntime = this.options.resolveManagedCodexWrenRuntime();
|
||||
} catch {
|
||||
throw new InteractiveLaunchError(CODEX_WREN_RUNTIME_UNPROVISIONED.message);
|
||||
}
|
||||
}
|
||||
if (purpose !== "setup" && !binding) throw new InteractiveLaunchError("native session requires a current bound project");
|
||||
const configuredCwd = binding?.path ?? this.options.workspaceRoot;
|
||||
const configuredIr = this.options.irPaths[purpose];
|
||||
@@ -1564,12 +1595,7 @@ export class NativeSessionService {
|
||||
const mcp = artifactService?.issue(row, binding);
|
||||
if (mcp) this.artifactCredentials.set(id, mcp.credential);
|
||||
let wrenRuntime: NativeWrenRuntime | undefined;
|
||||
try {
|
||||
wrenRuntime = dispatchDefinition.provider === "codex" && !this.legacyFixtureMode() ? resolveNativeWrenRuntime(this.wrenShim) : undefined;
|
||||
} catch (error) {
|
||||
if (error instanceof NativeWrenRuntimeError) throw new InteractiveLaunchError("native Wren runtime is unavailable");
|
||||
throw error;
|
||||
}
|
||||
wrenRuntime = managedCodexWrenRuntime;
|
||||
const vendorExecutable = this.vendorExecutables[dispatchDefinition.provider];
|
||||
if (!vendorExecutable) throw new InteractiveLaunchError(`the ${dispatchDefinition.provider} interactive CLI is not available on this machine`);
|
||||
const producerExecutable = producer?.producer
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { NativeWrenRuntime } from "./native-wren-runtime.js";
|
||||
import { buildNativeChildEnvironment, nativeProcessEnvironment } from "./native-runtime-spec.js";
|
||||
@@ -103,10 +104,95 @@ export interface CodexWrenHome {
|
||||
readonly cleanup?: () => void;
|
||||
}
|
||||
|
||||
function validatePrivateFile(file: string): void {
|
||||
const metadata = lstatSync(file);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink() || realpathSync(file) !== file || (metadata.mode & 0o777) !== 0o600) {
|
||||
throw new InteractiveLaunchError("native Wren session home is unavailable");
|
||||
interface PrivatePathIdentity {
|
||||
readonly canonical: string;
|
||||
readonly dev: number;
|
||||
readonly ino: number;
|
||||
readonly mode: number;
|
||||
}
|
||||
|
||||
interface PrivateFileIdentity extends PrivatePathIdentity {
|
||||
readonly size: number;
|
||||
readonly mtimeMs: number;
|
||||
readonly ctimeMs: number;
|
||||
readonly digest: string;
|
||||
}
|
||||
|
||||
interface WrenHomeIdentity {
|
||||
readonly directory: PrivatePathIdentity;
|
||||
readonly files: Readonly<Record<string, PrivateFileIdentity>>;
|
||||
}
|
||||
|
||||
function unavailable(): never {
|
||||
throw new InteractiveLaunchError("native Wren session home is unavailable");
|
||||
}
|
||||
|
||||
function privateDirectoryIdentity(directory: string): PrivatePathIdentity {
|
||||
try {
|
||||
const metadata = lstatSync(directory);
|
||||
const canonical = realpathSync(directory);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink() || canonical !== directory || (metadata.mode & 0o777) !== 0o700) return unavailable();
|
||||
return { canonical, dev: metadata.dev, ino: metadata.ino, mode: metadata.mode & 0o777 };
|
||||
} catch (error) {
|
||||
if (error instanceof InteractiveLaunchError) throw error;
|
||||
return unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function privateFileIdentity(file: string): PrivateFileIdentity {
|
||||
try {
|
||||
const metadata = lstatSync(file);
|
||||
const canonical = realpathSync(file);
|
||||
if (!metadata.isFile() || metadata.isSymbolicLink() || canonical !== file || (metadata.mode & 0o777) !== 0o600) return unavailable();
|
||||
return {
|
||||
canonical,
|
||||
dev: metadata.dev,
|
||||
ino: metadata.ino,
|
||||
mode: metadata.mode & 0o777,
|
||||
size: metadata.size,
|
||||
mtimeMs: metadata.mtimeMs,
|
||||
ctimeMs: metadata.ctimeMs,
|
||||
digest: createHash("sha256").update(readFileSync(file)).digest("hex"),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof InteractiveLaunchError) throw error;
|
||||
return unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function samePathIdentity(left: PrivatePathIdentity, right: PrivatePathIdentity): boolean {
|
||||
return left.canonical === right.canonical && left.dev === right.dev && left.ino === right.ino && left.mode === right.mode;
|
||||
}
|
||||
|
||||
function sameFileIdentity(left: PrivateFileIdentity, right: PrivateFileIdentity): boolean {
|
||||
return samePathIdentity(left, right)
|
||||
&& left.size === right.size
|
||||
&& left.mtimeMs === right.mtimeMs
|
||||
&& left.ctimeMs === right.ctimeMs
|
||||
&& left.digest === right.digest;
|
||||
}
|
||||
|
||||
function captureWrenHome(destination: string, fileNames: readonly string[]): WrenHomeIdentity {
|
||||
try {
|
||||
const actualNames = readdirSync(destination).sort();
|
||||
const expectedNames = [...fileNames].sort();
|
||||
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) return unavailable();
|
||||
return {
|
||||
directory: privateDirectoryIdentity(destination),
|
||||
files: Object.freeze(Object.fromEntries(expectedNames.map((name) => [name, privateFileIdentity(path.join(destination, name))]))),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof InteractiveLaunchError) throw error;
|
||||
return unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
function assertWrenHome(destination: string, expected: WrenHomeIdentity): void {
|
||||
const current = captureWrenHome(destination, Object.keys(expected.files));
|
||||
if (!samePathIdentity(current.directory, expected.directory)) return unavailable();
|
||||
for (const [name, identity] of Object.entries(expected.files)) {
|
||||
const observed = current.files[name];
|
||||
if (!observed || !sameFileIdentity(observed, identity)) return unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +203,7 @@ function retirementMarker(cwd: string): string {
|
||||
function retire(destination: string, marker: string): void {
|
||||
rmSync(destination, { recursive: true, force: true });
|
||||
writeFileSync(marker, "retired\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
||||
validatePrivateFile(marker);
|
||||
privateFileIdentity(marker);
|
||||
}
|
||||
|
||||
/** Setup has no bound profile yet, but still receives a fresh private WREN_HOME. */
|
||||
@@ -129,17 +215,15 @@ export function createEmptyCodexWrenHome(cwd: string): CodexWrenHome {
|
||||
if (existsSync(marker)) throw new Error("retired");
|
||||
mkdirSync(destination, { mode: 0o700 });
|
||||
chmodSync(destination, 0o700);
|
||||
const metadata = lstatSync(destination);
|
||||
if (!metadata.isDirectory() || metadata.isSymbolicLink() || realpathSync(destination) !== destination || (metadata.mode & 0o777) !== 0o700) {
|
||||
throw new Error("invalid directory");
|
||||
}
|
||||
const identity = captureWrenHome(destination, []);
|
||||
active = true;
|
||||
return Object.freeze({
|
||||
home: destination,
|
||||
dataRoots: Object.freeze([]),
|
||||
active: () => active,
|
||||
assertActive: () => {
|
||||
if (!active || !existsSync(destination)) throw new InteractiveLaunchError("native Wren session home is unavailable");
|
||||
if (!active) return unavailable();
|
||||
assertWrenHome(destination, identity);
|
||||
},
|
||||
cleanup: () => {
|
||||
if (!active) return;
|
||||
@@ -180,19 +264,12 @@ export function materializeCodexWrenHome(input: {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 10_000,
|
||||
});
|
||||
const canonical = realpathSync(destination);
|
||||
const metadata = lstatSync(destination);
|
||||
if (canonical !== destination || !metadata.isDirectory() || metadata.isSymbolicLink() || (metadata.mode & 0o777) !== 0o700) {
|
||||
throw new InteractiveLaunchError("native Wren session home is unavailable");
|
||||
}
|
||||
validatePrivateFile(path.join(destination, "profiles.yml"));
|
||||
const envFile = path.join(destination, ".env");
|
||||
if (existsSync(envFile)) validatePrivateFile(envFile);
|
||||
const identity = captureWrenHome(destination, ["profiles.yml", ...(existsSync(envFile) ? [".env"] : [])]);
|
||||
active = true;
|
||||
const assertActive = () => {
|
||||
if (!active || !existsSync(destination) || !statSync(destination).isDirectory()) {
|
||||
throw new InteractiveLaunchError("native Wren session home is unavailable");
|
||||
}
|
||||
if (!active) return unavailable();
|
||||
assertWrenHome(destination, identity);
|
||||
};
|
||||
return Object.freeze({
|
||||
home: destination,
|
||||
|
||||
@@ -30,7 +30,10 @@ export interface RuntimeHostOptions {
|
||||
}
|
||||
|
||||
const fixedCodexProbe = (): RuntimeBackendProbeResult<"codex-app-server"> => ({
|
||||
readiness: runtimeNotReady("codex-app-server", "unprovisioned", "codex_app_server_unprovisioned"),
|
||||
// Phase 2A has no managed-runtime record/provisioner yet. This is the first
|
||||
// blocking link for every Codex launch, even while the development-local
|
||||
// PTY host itself is healthy.
|
||||
readiness: runtimeNotReady("codex-app-server", "unprovisioned", "codex_wren_runtime_unprovisioned"),
|
||||
diagnostic: { phase: "provisioning" },
|
||||
});
|
||||
|
||||
|
||||
@@ -12,8 +12,11 @@ import { initializeNativeSessionStateBase } from "../server/native-session-works
|
||||
import { NativeArtifactService } from "../server/native-artifacts.js";
|
||||
import { NativeSessionService, probeNativeSessionProducer } from "../server/native-sessions.js";
|
||||
import { RuntimeHost } from "../server/runtime-host/local.js";
|
||||
import { runtimeReady } from "../server/runtime-host/policy.js";
|
||||
import type { PtyFactory } from "../server/interactive-terminal.js";
|
||||
import { attestNativeExecutable } from "../server/native-runtime-spec.js";
|
||||
import { createEmptyCodexWrenHome } from "../server/native-wren-home.js";
|
||||
import { resolveNativeWrenRuntime } from "../server/native-wren-runtime.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
const HELP = ["dispatch", "--target", "--purpose", "--native-scope", "--native-mcp", "--out"].join("\n");
|
||||
@@ -103,7 +106,7 @@ function irPaths(dir: string) {
|
||||
return { analysis: path.join(dir, "analysis.json"), setup: path.join(dir, "setup.json"), context_enrichment: path.join(dir, "context.json") } as const;
|
||||
}
|
||||
|
||||
function productionService(dir: string, producer: string, wrenShim: string, terminalManager?: () => Promise<InteractiveTerminalManager>, subscriptionProvider: "claude" | "codex" = "codex", runtimeHost?: RuntimeHost) {
|
||||
function productionService(dir: string, producer: string, wrenShim: string, terminalManager?: () => Promise<InteractiveTerminalManager>, subscriptionProvider: "claude" | "codex" = "codex", runtimeHost?: RuntimeHost, managedRuntimeAvailable = true) {
|
||||
const store = new Store(":memory:");
|
||||
store.setRuntimeSettings({ ...store.getRuntimeSettings(), subscriptionProvider, subscriptionDriverModel: "driver", tierModels: subscriptionProvider === "claude" ? [{ tier: "cheap", model: "haiku" }, { tier: "strong", model: "sonnet" }] : [{ tier: "cheap", model: "cheap" }, { tier: "strong", model: "strong" }] });
|
||||
const binding = store.activateEnrichmentBinding(resolveEnrichmentBinding(dir));
|
||||
@@ -126,8 +129,26 @@ function productionService(dir: string, producer: string, wrenShim: string, term
|
||||
}
|
||||
};
|
||||
visitCodex(codexRoot);
|
||||
let managedRuntime;
|
||||
if (managedRuntimeAvailable) {
|
||||
try { managedRuntime = resolveNativeWrenRuntime(wrenShim); } catch { /* absent/invalid remains unprovisioned */ }
|
||||
}
|
||||
const effectiveRuntimeHost = runtimeHost ?? new RuntimeHost({
|
||||
selected: "local",
|
||||
deployment: "development",
|
||||
localAvailable: () => true,
|
||||
...(managedRuntime ? {
|
||||
vendorProbes: {
|
||||
"codex-app-server": async () => ({
|
||||
readiness: runtimeReady("fixture-managed", ["pty"]),
|
||||
diagnostic: { phase: "provisioning" as const },
|
||||
}),
|
||||
},
|
||||
} : {}),
|
||||
});
|
||||
return {
|
||||
store,
|
||||
state,
|
||||
service: new NativeSessionService({
|
||||
store,
|
||||
terminalManager: terminalManager ?? (async () => new InteractiveTerminalManager(pty)),
|
||||
@@ -137,20 +158,16 @@ function productionService(dir: string, producer: string, wrenShim: string, term
|
||||
irPaths: irPaths(dir),
|
||||
warbleBin: producer,
|
||||
wrenShim,
|
||||
runtimeHost: effectiveRuntimeHost,
|
||||
...(managedRuntime ? { resolveManagedCodexWrenRuntime: () => managedRuntime } : {}),
|
||||
terminalHostAvailable: async () => true,
|
||||
vendorExecutables: {
|
||||
claude: attestNativeExecutable("vendor", process.execPath),
|
||||
codex: attestNativeExecutable("vendor", codexBin),
|
||||
},
|
||||
...(runtimeHost ? { runtimeHost } : {}),
|
||||
executableAvailable: () => true,
|
||||
artifactService: artifacts,
|
||||
prepareCodexWrenHome: ({ cwd }) => {
|
||||
const home = path.join(cwd, ".wren");
|
||||
mkdirSync(home);
|
||||
writeFileSync(path.join(home, "profiles.yml"), "active: fixture\nprofiles:\n fixture:\n datasource: duckdb\n");
|
||||
return { home, dataRoots: [] };
|
||||
},
|
||||
prepareCodexWrenHome: ({ cwd }) => createEmptyCodexWrenHome(cwd),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -183,6 +200,58 @@ describe("native producer compatibility preflight", () => {
|
||||
store.close();
|
||||
});
|
||||
|
||||
it("keeps production Codex unprovisioned when the local PTY host is ready but no managed Wren record exists", async () => {
|
||||
const dir = fixtureDir();
|
||||
const wrenShim = runtimeFixture(dir);
|
||||
const terminalManager = vi.fn(async () => new InteractiveTerminalManager({ spawn: vi.fn() }));
|
||||
const { service, store } = productionService(dir, fakeProducer(dir, "compatible"), wrenShim, terminalManager, "codex", undefined, false);
|
||||
|
||||
const readiness = await service.readiness();
|
||||
expect(readiness.runtimeHost).toMatchObject({
|
||||
selected: "local",
|
||||
selectedReadiness: { state: "ready" },
|
||||
backends: { "codex-app-server": { state: "unprovisioned", code: "codex_wren_runtime_unprovisioned" } },
|
||||
});
|
||||
expect(readiness.purposes.analysis).toMatchObject({
|
||||
available: false,
|
||||
reason: "The managed Wren runtime has not been provisioned.",
|
||||
vendors: {
|
||||
codex: { available: false, reason: "The managed Wren runtime has not been provisioned." },
|
||||
claude: { available: true },
|
||||
},
|
||||
});
|
||||
await expect(service.openOrCreate({ purpose: "analysis" })).rejects.toThrow("The managed Wren runtime has not been provisioned.");
|
||||
expect(terminalManager).not.toHaveBeenCalled();
|
||||
expect(store.listNativeSessions()).toEqual([]);
|
||||
store.close();
|
||||
});
|
||||
|
||||
it("rejects a Codex session home replaced after materialization and before PTY start", async () => {
|
||||
const dir = fixtureDir();
|
||||
const wrenShim = runtimeFixture(dir);
|
||||
const replacement = mkdtempSync(path.join(tmpdir(), "genbi-native-wren-home-replacement-"));
|
||||
dirs.push(replacement);
|
||||
const spawn = vi.fn();
|
||||
let state: ReturnType<typeof initializeNativeSessionStateBase>;
|
||||
const terminalManager = vi.fn(async () => {
|
||||
const sessionIds = readdirSync(path.join(state.root, "native"));
|
||||
expect(sessionIds).toHaveLength(1);
|
||||
const sessionHome = path.join(state.root, "native", sessionIds[0]!, ".wren");
|
||||
rmSync(sessionHome, { recursive: true });
|
||||
symlinkSync(replacement, sessionHome, "dir");
|
||||
return new InteractiveTerminalManager({ spawn });
|
||||
});
|
||||
const fixture = productionService(dir, fakeProducer(dir, "compatible"), wrenShim, terminalManager);
|
||||
state = fixture.state;
|
||||
|
||||
await expect(fixture.service.openOrCreate({ purpose: "analysis" })).rejects.toThrow("native Wren session home is unavailable");
|
||||
expect(terminalManager).toHaveBeenCalledOnce();
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
expect(fixture.store.listNativeSessions()).toHaveLength(1);
|
||||
expect(fixture.store.listNativeSessions()[0]).toMatchObject({ status: "failed", failure: "native Wren session home is unavailable" });
|
||||
fixture.store.close();
|
||||
});
|
||||
|
||||
it("keeps the preflight-resolved executable when the configured symlink retargets before launch", async () => {
|
||||
const dir = fixtureDir();
|
||||
const wrenShim = runtimeFixture(dir);
|
||||
@@ -281,12 +350,12 @@ describe("native producer compatibility preflight", () => {
|
||||
purposes: {
|
||||
analysis: {
|
||||
available: false,
|
||||
reason: expect.stringContaining("native Wren runtime is unavailable"),
|
||||
reason: "The managed Wren runtime has not been provisioned.",
|
||||
producer: { available: false, category: "native_session_producer_incompatible", diagnostic: expect.stringContaining("wren_runtime_unavailable") },
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(service.openOrCreate({ purpose: "analysis" })).rejects.toThrow("native Wren runtime is unavailable");
|
||||
await expect(service.openOrCreate({ purpose: "analysis" })).rejects.toThrow("The managed Wren runtime has not been provisioned.");
|
||||
expect(store.listNativeSessions()).toEqual([]);
|
||||
store.close();
|
||||
});
|
||||
@@ -309,9 +378,11 @@ describe("native producer compatibility preflight", () => {
|
||||
expect(readiness.purposes.analysis.available).toBe(true);
|
||||
expect(readiness.purposes.analysis.reason).toBeUndefined();
|
||||
expect(readiness.purposes.analysis.producer).toMatchObject({ available: true });
|
||||
// The unusable vendor is still reported, with its own cause, rather than
|
||||
// being hidden because the configured one happens to work.
|
||||
expect(readiness.purposes.analysis.vendors.codex).toMatchObject({ available: false, reason: expect.stringContaining("native Wren runtime is unavailable") });
|
||||
// The unusable vendor is still reported through the authoritative managed
|
||||
// record fence rather than being hidden because the configured one happens
|
||||
// to work. The lower deterministic probe above retains the exact broken
|
||||
// editable-chain diagnostic without making it a launch fallback.
|
||||
expect(readiness.purposes.analysis.vendors.codex).toMatchObject({ available: false, reason: "The managed Wren runtime has not been provisioned." });
|
||||
expect(readiness.purposes.analysis.vendors.claude).toMatchObject({ available: true });
|
||||
store.close();
|
||||
});
|
||||
|
||||
@@ -13,11 +13,29 @@ import type { NativeSessionServiceOptions } from "../server/native-sessions.js";
|
||||
import { NativeArtifactService, NATIVE_MCP_CREDENTIAL_ENV_VAR, NATIVE_MCP_TOOL_NAME } from "../server/native-artifacts.js";
|
||||
import { InteractiveTerminalManager } from "../server/interactive-terminal.js";
|
||||
import { RuntimeHost } from "../server/runtime-host/local.js";
|
||||
import { runtimeReady } from "../server/runtime-host/policy.js";
|
||||
import type { PtyFactory } from "../server/interactive-terminal.js";
|
||||
import { attestNativeExecutable, type NativeChildEnvironment } from "../server/native-runtime-spec.js";
|
||||
import { resolveNativeWrenRuntime } from "../server/native-wren-runtime.js";
|
||||
|
||||
const dirs: string[] = [];
|
||||
const NATIVE_MCP_URL = "http://127.0.0.1:4787/api/native-sessions/mcp";
|
||||
function managedCodexRuntimeOptions() {
|
||||
return {
|
||||
runtimeHost: new RuntimeHost({
|
||||
selected: "local",
|
||||
deployment: "development",
|
||||
localAvailable: () => true,
|
||||
vendorProbes: {
|
||||
"codex-app-server": async () => ({
|
||||
readiness: runtimeReady("fixture-managed", ["pty"]),
|
||||
diagnostic: { phase: "provisioning" as const },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
resolveManagedCodexWrenRuntime: () => resolveNativeWrenRuntime(),
|
||||
} as const;
|
||||
}
|
||||
function welcomeFor(purpose: "analysis" | "setup" | "context_enrichment") {
|
||||
return purpose === "setup"
|
||||
? "Help me set up this GenBI project. Start by explaining the next setup step and ask what data source I want to connect."
|
||||
@@ -1444,6 +1462,7 @@ describe("native session persistence", () => {
|
||||
workspaceRoot: undefined, materializationState: successState, irPaths: { analysis: path.join(success.dir, "analysis.json"), setup: undefined, context_enrichment: undefined },
|
||||
warbleBin: fakeV4Producer(success.dir), artifactService: successArtifacts,
|
||||
vendorExecutables: { [vendor]: attestNativeExecutable("vendor", process.execPath) },
|
||||
...(vendor === "codex" ? managedCodexRuntimeOptions() : {}),
|
||||
prepareCodexWrenHome: ({ cwd }) => {
|
||||
const home = path.join(cwd, ".wren");
|
||||
mkdirSync(home);
|
||||
@@ -1479,6 +1498,7 @@ describe("native session persistence", () => {
|
||||
workspaceRoot: undefined, materializationState: failureState, irPaths: { analysis: undefined, setup: undefined, context_enrichment: path.join(failure.dir, "enrich.json") },
|
||||
warbleBin: fakeV4Producer(failure.dir), artifactService: failureArtifacts,
|
||||
vendorExecutables: { [vendor]: attestNativeExecutable("vendor", process.execPath) },
|
||||
...(vendor === "codex" ? managedCodexRuntimeOptions() : {}),
|
||||
});
|
||||
await expect(failureService.create({ purpose: "context_enrichment", vendor })).rejects.toThrow(/materialization failed/);
|
||||
const failedDescriptor = failedIssue.mock.results[0]?.value as { credential: string };
|
||||
@@ -1702,7 +1722,7 @@ describe("native session persistence", () => {
|
||||
],
|
||||
[
|
||||
new RuntimeHost({ selected: "codex-app-server", deployment: "development", localAvailable: () => true }),
|
||||
"The Codex app-server runtime has not been provisioned by this GenBI release.",
|
||||
"The managed Wren runtime has not been provisioned.",
|
||||
],
|
||||
[
|
||||
new RuntimeHost({ selected: "claude-sandbox-runtime", deployment: "development", localAvailable: () => true }),
|
||||
@@ -1757,7 +1777,7 @@ describe("native session persistence", () => {
|
||||
const state = materializationState();
|
||||
writeFileSync(path.join(dir, "missing-ir.json"), "{}");
|
||||
const store = new Store(":memory:");
|
||||
const service = new NativeSessionService({ store, terminalManager: async () => { throw new Error("not reached"); }, getBinding: () => binding, workspaceRoot: undefined, materializationState: state, irPaths: { analysis: path.join(dir, "missing-ir.json"), setup: undefined, context_enrichment: undefined }, warbleBin: path.join(dir, "missing-warble") });
|
||||
const service = new NativeSessionService({ store, terminalManager: async () => { throw new Error("not reached"); }, getBinding: () => binding, workspaceRoot: undefined, materializationState: state, irPaths: { analysis: path.join(dir, "missing-ir.json"), setup: undefined, context_enrichment: undefined }, warbleBin: path.join(dir, "missing-warble"), ...managedCodexRuntimeOptions() });
|
||||
// The launch still fails closed; it now names which link broke instead of
|
||||
// reporting the whole producer incompatible.
|
||||
await expect(service.create({ purpose: "analysis", vendor: "codex" })).rejects.toThrow("the Warble binary could not be resolved");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -131,4 +131,33 @@ describe("Codex session WREN_HOME", () => {
|
||||
expect(() => materialize(value)).toThrow(/profile is unavailable/);
|
||||
expect(existsSync(path.join(value.session, ".wren"))).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["symlink", "directory", "mode", "profile", "secrets"] as const)(
|
||||
"rejects retained session-home %s replacement or tampering",
|
||||
(mutation) => {
|
||||
const value = fixture();
|
||||
const home = materialize(value);
|
||||
const destination = home.home;
|
||||
if (mutation === "symlink") {
|
||||
const replacement = path.join(value.root, "replacement-home");
|
||||
mkdirSync(replacement, { mode: 0o700 });
|
||||
rmSync(destination, { recursive: true });
|
||||
symlinkSync(replacement, destination, "dir");
|
||||
} else if (mutation === "directory") {
|
||||
rmSync(destination, { recursive: true });
|
||||
mkdirSync(destination, { mode: 0o700 });
|
||||
writeFileSync(path.join(destination, "profiles.yml"), "active: replacement\n", { mode: 0o600 });
|
||||
writeFileSync(path.join(destination, ".env"), "DB_FILE=replacement\n", { mode: 0o600 });
|
||||
} else if (mutation === "mode") {
|
||||
chmodSync(destination, 0o755);
|
||||
} else if (mutation === "profile") {
|
||||
writeFileSync(path.join(destination, "profiles.yml"), "active: replacement\n");
|
||||
} else {
|
||||
writeFileSync(path.join(destination, ".env"), "DB_FILE=replacement\n");
|
||||
}
|
||||
|
||||
expect(() => home.assertActive?.()).toThrow("native Wren session home is unavailable");
|
||||
home.cleanup?.();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,8 +80,8 @@ describe("RuntimeHost readiness", () => {
|
||||
|
||||
const vendor = new RuntimeHost({ selected: "codex-app-server", deployment: "development", localAvailable: () => true });
|
||||
await expect(vendor.probe()).resolves.toMatchObject({
|
||||
backends: { local: { state: "ready" }, "codex-app-server": { state: "unprovisioned", code: "codex_app_server_unprovisioned" } },
|
||||
selectedReadiness: { state: "unprovisioned", code: "codex_app_server_unprovisioned" },
|
||||
backends: { local: { state: "ready" }, "codex-app-server": { state: "unprovisioned", code: "codex_wren_runtime_unprovisioned" } },
|
||||
selectedReadiness: { state: "unprovisioned", code: "codex_wren_runtime_unprovisioned" },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user