* test(#3245): failing-first coverage for host runtime detection in init Locks the behavior epic #2313 Phase 5 must produce before any of it exists: init reports the detected host, explicit GSD_RUNTIME and config runtime still outrank detection, non-Codex sessions are untouched, and nothing is ever written to shared defaults (#2297). * enhance(#3245): report the detected host runtime in init init reported agent_runtime: claude inside a Codex session, and resolved agents_dir to the Claude agents root with agents_installed: true — a spuriously healthy triple. Runtime identity was only ever read from GSD_RUNTIME or an explicit runtime in .planning/config.json. Adds a detection rung beneath both explicit sources, in a new pure module. Codex is identified from its own documented session environment (CODEX_SANDBOX / CODEX_SANDBOX_NETWORK_DISABLED), else an explicitly exported CODEX_HOME whose config.toml exists. The default ~/.codex is never probed: that file exists on every machine that has run Codex, so probing it would misreport other runtimes' sessions. resolveRuntime keeps its exact contract and all 71 dependents, including formatGsdSlash command-style emission; only withProjectRoot consumes the new rung. Nothing is written on any path (#2297). Explicit config still wins (#2517). * fix(#3245): make the parity guard real and single-source the marker Four independent review passes found the generative-fix-divergence guard was vacuous: it asserted agreement at the one input where inferPreferredRuntime and detectHostRuntime do not differ, so it could not fail. It now pins the actual divergence point (CODEX_HOME set, config.toml absent) and records that the asymmetry is deliberate. The config.toml marker is now single-sourced from update-context.cts and imported, rather than carried independently by two surfaces. tests/helpers.cjs now scrubs CODEX_SANDBOX and CODEX_SANDBOX_NETWORK_DISABLED: GSD reads them, so an ambient Codex session would otherwise make the non-codex control test fail non-deterministically. Also: detection is throw-safe end to end rather than only around the fs probe; the Windows-join test is replaced with one that can actually fail (trailing-separator, catches hand-rolled concatenation); the #2297 no-write proof now wraps resolveReportedRuntime, the function that ships, across all three ladder outcomes. * chore(#3245): backfill changeset pr number --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/serene-finches-bark.md
Normal file
5
.changeset/serene-finches-bark.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 3307
|
||||
---
|
||||
**`init` now reports the host runtime it is actually running under** — inside a Codex session GSD reported `agent_runtime: claude`, and checked the wrong directory for installed agents, because runtime identity was only ever read from `GSD_RUNTIME` or an explicit `runtime` in `.planning/config.json`. A detection rung now sits beneath both explicit sources, resolving `codex` from Codex's own session environment. Explicit settings still win, no shared defaults are written, and model resolution is untouched. (#3245)
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -262,6 +262,7 @@ build/
|
||||
/gsd-core/bin/lib/roadmap.cjs
|
||||
/gsd-core/bin/lib/audit.cjs
|
||||
/gsd-core/bin/lib/git-base-branch.cjs
|
||||
/gsd-core/bin/lib/host-runtime-detection.cjs
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
@@ -155,6 +155,9 @@ Leaf module (imports only `node:fs`/`node:path`) owning per-function complexity
|
||||
### Runtime Name Policy Module
|
||||
Module owning runtime identity normalization at runtime-selection seams. Canonicalizes alias signals from env/config (`GSD_RUNTIME`, `.planning/config.json:runtime`) to supported runtime IDs so output emitters and query runtime gates stay consistent across naming variants (for example `codex-app`/`codex-cli` -> `codex`). Sources: `gsd-core/bin/lib/runtime-name-policy.cjs`, alias manifest `gsd-core/bin/shared/runtime-aliases.manifest.json`.
|
||||
|
||||
### Host Runtime Detection Module
|
||||
Pure, no-write Module owning the **detection rung** of runtime identity — ADR-2313 Phase 5 (#3245, folded from #2320). The Runtime Name Policy Module normalizes the two *explicit* signals (`GSD_RUNTIME`, `.planning/config.json:runtime`); this module answers the different question those two cannot: *which host is this process actually running inside* when neither is set. Before it, `init` reported `agent_runtime: claude` inside a Codex session, because the ladder ended at a hardcoded default. `detectHostRuntime(deps?) → {runtime, source, signal}` is the typed surface tests assert against (`source` ∈ `session-env|config-home|none`); it probes, in order, the frozen `CODEX_SESSION_ENV_SIGNALS` table (`CODEX_SANDBOX`, `CODEX_SANDBOX_NETWORK_DISABLED` — injected by Codex into shell-tool children per openai/codex `AGENTS.md`; absent under `sandbox_mode = "danger-full-access"`, so best-effort), then an explicitly-exported `CODEX_HOME` whose `config.toml` exists — the marker FILENAME is single-sourced from the Update-Context Module's `inferPreferredRuntime` (`CODEX_CONFIG_MARKER`, re-exported here), while the TRUTHINESS RULE deliberately differs: `inferPreferredRuntime` accepts a bare, unchecked `CODEX_HOME` as sufficient to resolve an update context, whereas this module additionally requires the marker file to exist, because it asserts session identity rather than resolving an update context and needs the stronger signal — a difference pinned by a test in `tests/host-runtime-detection.test.cjs` rather than left implicit. `resolveReportedRuntime(projectDir, deps?)` composes the whole ladder: `GSD_RUNTIME` > config `runtime` > detection > `'claude'`. Three invariants are load-bearing and each has a test: it **never writes** (#2297 shared-defaults poisoning — no `~/.gsd/defaults.json`, no config mutation); it **never shells out**, so there is no subprocess to time-bound and no degraded-on-timeout path to design; and the **default `~/.codex/config.toml` is never probed**, because every machine that has run Codex carries that file and probing it would misreport Claude Code sessions as codex. _Avoid_: calling this "runtime resolution" — `resolveRuntime` (Runtime Slash Module) keeps its own frozen `GSD_RUNTIME > config > 'claude'` contract and its 71 dependents, including `formatGsdSlash`'s command-style decision, are deliberately untouched; only `withProjectRoot`'s reported `agent_runtime` consumes the detection rung. Sources: `src/host-runtime-detection.cts` → `gsd-core/bin/lib/host-runtime-detection.cjs`; the `resolveExplicitRuntime` seam it composes lives in `src/runtime-slash.cts`. See ADR-2313.
|
||||
|
||||
### Host-Integration Interface
|
||||
Pure, additive, no-I/O Module owning the versioned, negotiated contract over the six host-integration interface points (command, dispatch, model, hooks, state, artifact) — ADR-1239 Phase A. Extends the ADR-1016 runtime descriptor with nine closed-vocabulary axes carried under `capability.json` `runtime.hostIntegration`: `embeddingMode` (`imperative|declarative`), `commandSurface` (`slash-file|slash-programmatic|slash-toml|palette|prose-only`), `dispatch` (`{namedDispatch,nested,maxDepth,background,backgroundDispatch,subagentToolkit,isolation}`), `modelMode` (`active|passive`), `hookBus` (`host|engine|none`), `stateIO` (`filesystem|sandboxed-storage|session-log-append`), `transport` (`mcp|native-extension`), `runtime` (`node|bun|sandboxed-web|python|go|rust|electron|other`), `effortSurface` (`argv|none` — how reasoning effort reaches the host; ADR-1239 amendment #2481, the first axis whose consumer is an invocation-time argument rather than an install-time artifact). `dispatch.isolation` (`harness-worktree|orchestrator-worktree|none` — how a host isolates concurrent same-wave executors; ADR-1239 Codex-binding amendment #2584; declared and negotiated but not yet consumed by any scheduler — Phase 1 of #2584). `resolveOrchestratorExec(orchestratorExec, cwd) → { ok:true, command, args, cwd } | { ok:false, reason }` (#2584 Phase 2, pure, no I/O — resolves the `runtime.orchestratorExec` descriptor field, a sibling of `runtime.hostBehaviors` in `capability.json` carrying `{command, args?, cwdFlag?}`, into the concrete argv/cwd a process-spawn primitive would use for a `dispatch.isolation: orchestrator-worktree` host; appends `[cwdFlag, cwd]` to `args` when `cwdFlag` is a non-empty string, e.g. codex `exec --cd <cwd>`, opencode `run --dir <cwd>`, kimi `--work-dir <cwd>`; when `cwdFlag` is `null`/absent — kimi-code's process-cwd case — no flag is appended and `cwd` alone is returned for the caller to bind via the subprocess's own working-directory option; fail-closed `missing_command`/`invalid_cwd`/`invalid_args`/`invalid_cwd_flag`; declared and testable but UNCONSUMED — no scheduler spawns anything with it yet, Phase 3 wires it). Interface: `negotiateHostCapabilities(host, engine?) → { protocolVersion, effective, points, warnings }` enforcing the trust-boundary invariant `effective ⊆ host-declared ∩ engine-known` (never augment with an undeclared or unknown/future-`protocolVersion` value — fail-closed via the most-restrictive-known `SAFE_DEFAULTS`); `degradationFor(point, axes) → { level, fallback }` (a pure Full/Degraded/Absent ladder table, never throws); `profileOf(axes) → 'programmatic-cli'|'declarative-cli'|'ide'|null`; plus `PROTOCOL_VERSION` (integer, starts at 1 — distinct from the package `version`/`engines.gsd` semver), `HOST_INTEGRATION_AXES` (the frozen closed vocabulary, single source of truth), `PROFILE_BASELINES`, and `shouldFlattenDispatch(dispatch) → boolean` (ADR-1239 Phase B / #1708 — graduates the #853 rule: returns `true` = run the orchestrator inline UNLESS the host is documented to background a nesting-capable orchestrator (`background === true && backgroundDispatch === true`); fail-closed to inline; exposed to the plan/execute workflows via the `gsd_run query dispatch-should-flatten --raw` CLI, which replaced the former scattered `RUNTIME === 'codex'` prose check). The runtime-descriptor validator (`gsd-core/bin/lib/capability-validator.cjs` `validateRuntimeBody`) mirrors the closed vocabulary inline (exported as `_HOST_INTEGRATION_VOCAB`) and is kept in lock-step by the parity guard `tests/host-integration-validator-parity.test.cjs`. Orthogonal axes (resolved explicitly per ADR-1239 Phase A): `commandStyle` (GSD emission style, retained) vs `commandSurface` (host surface type); `hookEvents` dialect vs `hookBus` ownership (a host with `hooksSurface:none` may still be `hookBus:host` — e.g. opencode); `runtimeCompat` (feature→host) vs these negotiated runtime→engine axes. Phase A defined the interface; Phase B (#1679) wires it incrementally — `destSubpath` write-confinement (#1704) and the typed documentation-sourced #853 dispatch-flatten (#1708, the first consumer of a negotiated `dispatch` axis); adapters/MCP/host-bindings remain Phases C–E. Source of truth: `gsd-core/bin/lib/host-integration.cjs` (generated from `src/host-integration.cts`). See ADR-1239 and ADR-1016.
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ project one is reported, since that is the file you are most likely able to fix.
|
||||
| `mode` | enum | `interactive`, `yolo` | `interactive` | `yolo` auto-approves decisions; `interactive` confirms at each step |
|
||||
| `granularity` | enum | `coarse`, `standard`, `fine` | `standard` | Controls phase count: `coarse` (2-4), `standard` (4-6), `fine` (6-10) |
|
||||
| `model_profile` | enum | `quality`, `balanced`, `budget`, `adaptive`, `inherit` | `balanced` | Model tier for each agent (see [Model Profiles](#model-profiles)). `adaptive` was added per [#1713](https://github.com/open-gsd/gsd-core/issues/1713) / [#1806](https://github.com/open-gsd/gsd-core/issues/1806) and resolves the same way as the other tiers under runtime-aware profiles. |
|
||||
| `runtime` | string | `claude`, `codex`, or any string | (none) | Active runtime for [runtime-aware profile resolution](#runtime-aware-profiles-2517). When set, profile tiers (opus/sonnet/haiku) resolve to runtime-native model IDs. The resolved ID is embedded into each agent's static frontmatter at install time on `opencode` (whose `spawn_agent` interface does not accept an inline `model` parameter, so editing `model_overrides` requires re-running `gsd install <runtime>` to take effect — see [Per-Agent Overrides](#per-agent-overrides)); other runtimes consume the resolver at spawn time. **`codex` is the exception: it embeds no per-tier model at all.** Codex is a passive / session-only model host ([ADR-2313](adr/2313-codex-passive-model-posture.md)) — a ChatGPT-account session exposes only its own model, so a pinned tier model returns `400 invalid_request_error` and the agent fails to spawn. Codex agents therefore inherit the session model, and only an explicit real-Codex id in `model_overrides` (e.g. `"gpt-5.6-sol"`) is written into the `.toml`. When unset (default), behavior is unchanged from prior versions. Added in v1.39; Codex behavior changed in v1.11 |
|
||||
| `runtime` | string | `claude`, `codex`, or any string | (none) | Active runtime for [runtime-aware profile resolution](#runtime-aware-profiles-2517). When set, profile tiers (opus/sonnet/haiku) resolve to runtime-native model IDs. The resolved ID is embedded into each agent's static frontmatter at install time on `opencode` (whose `spawn_agent` interface does not accept an inline `model` parameter, so editing `model_overrides` requires re-running `gsd install <runtime>` to take effect — see [Per-Agent Overrides](#per-agent-overrides)); other runtimes consume the resolver at spawn time. **`codex` is the exception: it embeds no per-tier model at all.** Codex is a passive / session-only model host ([ADR-2313](adr/2313-codex-passive-model-posture.md)) — a ChatGPT-account session exposes only its own model, so a pinned tier model returns `400 invalid_request_error` and the agent fails to spawn. Codex agents therefore inherit the session model, and only an explicit real-Codex id in `model_overrides` (e.g. `"gpt-5.6-sol"`) is written into the `.toml`. When unset (default), model resolution is unchanged from prior versions — but the runtime GSD *reports* (`agent_runtime`) then falls through to [host detection](how-to/control-the-reported-host-runtime.md), which can resolve `codex` from Codex's own session environment. Detection affects reporting and the agent-installation check only; it never feeds tier resolution, which still reads this key alone. Added in v1.39; Codex behavior changed in v1.11; reporting-only host detection added in v1.11 |
|
||||
| `model_profile_overrides.<runtime>.<tier>` | string \| object | per-runtime tier override | (none) | Override the runtime-aware tier mapping for a specific `(runtime, tier)`. Tier is one of `opus`, `sonnet`, `haiku`. Value is either a model ID string (e.g. `"gpt-5-pro"`) or `{ model, reasoning_effort }`. See [Runtime-Aware Profiles](#runtime-aware-profiles-2517). Added in v1.39 |
|
||||
| `model_policy.provider` | string | `openai`, `anthropic`, `anthropic-fable`, `google`, `qwen`, `generic` | (none) | Declares the model provider. Known providers (`openai`, `anthropic`, `anthropic-fable`, `google`, `qwen`) unlock catalog-backed presets. `generic` treats all model IDs as opaque strings — no prefix inference, no reasoning-effort defaults. `model_policy.runtime_tiers` resolves before legacy `model_profile_overrides`. See [Model Policy Presets](#model-policy-presets-model_policy--added-in-v142). Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) |
|
||||
| `model_policy.budget` | enum | `high`, `medium`, `low` | (none) | Selects a budget tier when using a known provider. GSD materializes the matching catalog preset into explicit tier mappings at resolve time. Ignored when `provider` is `generic` or `custom`. Added in v1.42 ([#49](https://github.com/open-gsd/gsd-core/issues/49)) |
|
||||
|
||||
@@ -380,6 +380,7 @@
|
||||
"hook-bus.cjs",
|
||||
"host-integration-sdk.cjs",
|
||||
"host-integration.cjs",
|
||||
"host-runtime-detection.cjs",
|
||||
"init-command-router.cjs",
|
||||
"init.cjs",
|
||||
"install-effort-resolver.cjs",
|
||||
|
||||
@@ -492,6 +492,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
| `graphify-command-router.cjs` | ADR-959 capability command router for `gsd-tools graphify` — dispatches build/query/status/diff subcommands; first real capability command cutover (phase 4d-impl-2) |
|
||||
| `gsd2-import.cjs` | External-plan ingest for `/gsd-import --from-gsd2` |
|
||||
| `host-integration.cjs` | Host-Integration Interface (ADR-1239 Phase A) — negotiated capability contract over the six host-integration points; `negotiateHostCapabilities` fail-closes on undeclared/unknown/`undocumented` values, typed degradation ladder, host-capability profiles; the 8 `runtime.hostIntegration` axes are validated in `capability-validator.cjs` and sourced per-CLI in `docs/reference/host-integration-capability-matrix.md` |
|
||||
| `host-runtime-detection.cjs` | Host Runtime Detection Module (ADR-2313 Phase 5, #3245) — the detection rung beneath `GSD_RUNTIME` and `.planning/config.json` `runtime` that lets `init` report `agent_runtime: codex` inside a Codex session instead of the hardcoded `claude` default; `detectHostRuntime` returns the typed `{runtime, source, signal}` from citation-backed Codex signals (`CODEX_SANDBOX`/`CODEX_SANDBOX_NETWORK_DISABLED`, else `CODEX_HOME` + `config.toml`), `resolveReportedRuntime` composes the full ladder. Pure, injectable, never writes, never shells out |
|
||||
| `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` |
|
||||
| `init.cjs` | Compound context loading for each workflow type |
|
||||
| `install-effort-resolver.cjs` | Install-time effort resolution — `readGsdEffectiveEffortConfig` (merges `~/.gsd/defaults.json` + project `.planning/config.json`) + `resolveInstallTimeEffort`, extracted from `bin/install.js` (#2071) so `gsd-tools effort sync` can require it from the shipped runtime instead of the never-copied package-root installer; install.js imports them back (single source) |
|
||||
|
||||
@@ -31,6 +31,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
|
||||
- [Run phases autonomously](how-to/run-phases-autonomously.md) — use autonomous mode for unattended phase execution
|
||||
- [Handle quick and fast tasks](how-to/handle-quick-and-fast-tasks.md) — use `/gsd-quick` and `/gsd-fast` for ad-hoc work outside the phase loop
|
||||
- [Configure model profiles](how-to/configure-model-profiles.md) — switch between quality, balanced, and budget model tiers
|
||||
- [Control which host runtime GSD reports](how-to/control-the-reported-host-runtime.md) — read the `agent_runtime` ladder, understand what host detection looks at, and pin the runtime when detection is not what you want
|
||||
- [Set up cross-AI review](how-to/set-up-cross-ai-review.md) — configure a second AI to review code produced by the primary agent
|
||||
- [Work in parallel with workstreams](how-to/work-in-parallel-with-workstreams.md) — run independent lines of work simultaneously using workstreams
|
||||
- [Isolate work with workspaces](how-to/isolate-work-with-workspaces.md) — use workspaces to sandbox experimental or risky changes
|
||||
|
||||
83
docs/how-to/control-the-reported-host-runtime.md
Normal file
83
docs/how-to/control-the-reported-host-runtime.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# How to control which host runtime GSD reports
|
||||
|
||||
**Goal:** Make `agent_runtime` — the runtime GSD reports it is running under, and the one it checks for installed agents — say what you actually want, and know *why* it says what it says.
|
||||
|
||||
**Prerequisites:** A project with a `.planning/` directory. Read the current answer with:
|
||||
|
||||
```bash
|
||||
node gsd-tools.cjs init plan-phase 1 --raw
|
||||
```
|
||||
|
||||
The JSON carries `agent_runtime`, plus the `agents_dir` / `agents_installed` / `missing_agents` triple derived from it. For the config key itself, see [`runtime`](../CONFIGURATION.md#runtime-aware-profiles-2517).
|
||||
|
||||
---
|
||||
|
||||
## The ladder, in order
|
||||
|
||||
GSD answers "which runtime am I?" from the first of these that produces a value:
|
||||
|
||||
| # | Source | Set it by | Wins over |
|
||||
|---|---|---|---|
|
||||
| 1 | `GSD_RUNTIME` environment variable | `GSD_RUNTIME=opencode` in the environment | everything below |
|
||||
| 2 | `runtime` in `.planning/config.json` | `"runtime": "codex"` | detection and the default |
|
||||
| 3 | **Host detection** (added in v1.11) | nothing — it is automatic | the default only |
|
||||
| 4 | Default | — | — (`claude`) |
|
||||
|
||||
Rungs 1 and 2 are *explicit* — you stated an intent, and GSD does not second-guess it. Rung 3 only ever runs when **both** are unset. This is what preserves the behavior of every existing config: if you have ever set `runtime`, nothing about your setup changes.
|
||||
|
||||
---
|
||||
|
||||
## What detection actually looks at
|
||||
|
||||
Detection answers a narrow question — *is this process running inside a Codex session?* — and only from signals Codex itself documents.
|
||||
|
||||
| Signal | Where it comes from | Why it is trusted |
|
||||
|---|---|---|
|
||||
| `CODEX_SANDBOX` is set and non-empty | Codex injects it into child processes it spawns via Seatbelt | Documented in Codex's own `AGENTS.md`; present in exactly the processes Codex runs, which is where GSD runs |
|
||||
| `CODEX_SANDBOX_NETWORK_DISABLED` is set and non-empty | Codex injects it when running the shell tool with the network sandbox on | same source |
|
||||
| `CODEX_HOME` is set **and** `$CODEX_HOME/config.toml` exists | you exported it | Exporting `CODEX_HOME` is you designating a Codex state root; the `config.toml` check confirms the directory is a real one |
|
||||
|
||||
Anything else — no signal — means no detection, and rung 4 applies.
|
||||
|
||||
---
|
||||
|
||||
## The two questions this page exists for
|
||||
|
||||
### "I'm in a Codex session and it still says `claude`"
|
||||
|
||||
Work down the ladder:
|
||||
|
||||
| Check | What to do |
|
||||
|---|---|
|
||||
| Is `GSD_RUNTIME` set to something else? | `echo $GSD_RUNTIME` — it outranks everything. Unset it, or set it to `codex`. |
|
||||
| Does `.planning/config.json` have a `runtime`? | An explicit `"runtime": "claude"` wins over detection, by design. Change it or remove the key. |
|
||||
| Is your Codex sandbox off? | With `sandbox_mode = "danger-full-access"`, Codex sets **neither** sandbox variable, so there is nothing for GSD to detect. This is the most common cause. |
|
||||
| Still nothing? | Set it explicitly. Detection is a convenience, not a contract — `"runtime": "codex"` in `.planning/config.json` is the supported, permanent answer. |
|
||||
|
||||
Detection is deliberately conservative: when it cannot tell, it reports the old default rather than guessing. A wrong `agent_runtime` sends GSD looking for agents in the wrong directory, so silence is the safer failure.
|
||||
|
||||
### "It says `codex` and I am not using Codex"
|
||||
|
||||
One cause, and it is benign:
|
||||
|
||||
> `CODEX_HOME` is exported in your shell profile, and `$CODEX_HOME/config.toml` exists.
|
||||
|
||||
GSD treats an explicitly-exported `CODEX_HOME` as you designating a Codex root. If you keep it exported globally but work in another runtime, pin the runtime for that project:
|
||||
|
||||
```json
|
||||
{ "runtime": "claude" }
|
||||
```
|
||||
|
||||
in `.planning/config.json`. Rung 2 outranks detection, so this settles it permanently.
|
||||
|
||||
Note what is **not** a cause: simply having Codex installed. GSD never probes the default `~/.codex/config.toml`. That file exists on every machine that has ever run Codex, so treating it as a signal would misreport every other runtime's sessions — which is precisely why the check requires you to have exported `CODEX_HOME` yourself.
|
||||
|
||||
---
|
||||
|
||||
## What this does not change
|
||||
|
||||
Detection moves the **reported** runtime and the agent-installation check that hangs off it. It deliberately does not touch:
|
||||
|
||||
- **Model resolution.** Runtime-aware tier resolution still reads the explicit `runtime` config key only. A detected-Codex session does not gain or lose model pins — see [Runtime-aware profiles](../CONFIGURATION.md#runtime-aware-profiles-2517) and [ADR-2313](../adr/2313-codex-passive-model-posture.md).
|
||||
- **Slash-command style.** GSD still emits `/gsd-<cmd>` unless the runtime was set explicitly; a detected-Codex session does not switch to the `$gsd-<cmd>` shell-var form. Set `runtime` explicitly if you want that.
|
||||
- **Any file.** Detection reads environment variables and checks for one file's existence. It never writes `~/.gsd/defaults.json`, never edits `.planning/config.json`, and never shells out.
|
||||
@@ -64,6 +64,7 @@ export default tseslint.config(
|
||||
'gsd-core/bin/lib/claude-orchestration-command-router.cjs',
|
||||
'gsd-core/bin/lib/semver-compare.cjs',
|
||||
'gsd-core/bin/lib/host-integration.cjs',
|
||||
'gsd-core/bin/lib/host-runtime-detection.cjs',
|
||||
'gsd-core/bin/lib/handshake-serialized.cjs',
|
||||
'gsd-core/bin/lib/host-integration-sdk.cjs',
|
||||
'gsd-core/bin/lib/install-effort-resolver.cjs',
|
||||
|
||||
145
src/host-runtime-detection.cts
Normal file
145
src/host-runtime-detection.cts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Host runtime detection — ADR-2313 Phase 5 (#3245, folded from #2320).
|
||||
*
|
||||
* `init`'s reported `agent_runtime` was hardcoding `claude` even when run
|
||||
* inside a Codex session, because resolveRuntime's ladder only checks the
|
||||
* explicit `GSD_RUNTIME` env var and `.planning/config.json`'s `runtime`
|
||||
* field — there was no fallback that looked at the actual host process.
|
||||
*
|
||||
* This module adds a host-detection rung strictly BELOW those two explicit
|
||||
* sources: it only runs when neither `GSD_RUNTIME` nor config `runtime` is
|
||||
* set. It never writes anything (#2297 — no shared-defaults poisoning: this
|
||||
* module never touches .planning/config.json or any other file). It never
|
||||
* shells out, so there is no subprocess to time-bound — detection is pure
|
||||
* env-var and existence-check inspection.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveExplicitRuntime } from './runtime-slash.cjs';
|
||||
import { CODEX_CONFIG_MARKER } from './update-context.cjs';
|
||||
|
||||
export { CODEX_CONFIG_MARKER };
|
||||
|
||||
export type DetectionSource = 'session-env' | 'config-home' | 'none';
|
||||
|
||||
export interface HostRuntimeDetection {
|
||||
runtime: string | null;
|
||||
source: DetectionSource;
|
||||
signal: string | null;
|
||||
}
|
||||
|
||||
export interface DetectionDeps {
|
||||
env?: Record<string, string | undefined>;
|
||||
fileExists?: (p: string) => boolean;
|
||||
}
|
||||
|
||||
// Codex sandbox env vars set by the shell tool / Seatbelt child-process spawn.
|
||||
// Evidence: openai/codex AGENTS.md — "The sandbox environment automatically
|
||||
// sets CODEX_SANDBOX_NETWORK_DISABLED=1 when using the shell tool, and
|
||||
// CODEX_SANDBOX=seatbelt for child processes spawned via Seatbelt." (injected
|
||||
// by spawn_child_async in codex-rs/core/src/spawn.rs). These are absent under
|
||||
// sandbox_mode = "danger-full-access", so this signal is best-effort and
|
||||
// degrades to the default when unset.
|
||||
//
|
||||
// Note: CODEX_THREAD_ID (which appears in src/active-workstream-store.cts's
|
||||
// WORKSTREAM_SESSION_ENV_KEYS) is deliberately NOT used here — it is
|
||||
// undocumented in Codex's published env-var reference and source, so it
|
||||
// fails this repo's citation bar.
|
||||
export const CODEX_SESSION_ENV_SIGNALS: readonly string[] = Object.freeze([
|
||||
'CODEX_SANDBOX',
|
||||
'CODEX_SANDBOX_NETWORK_DISABLED',
|
||||
]);
|
||||
|
||||
// Evidence: learn.chatgpt.com/docs/config-file/environment-variables —
|
||||
// "Sets the root for Codex state, including config…". The marker FILENAME
|
||||
// (`config.toml`) is single-sourced from `update-context.cts`'s
|
||||
// `inferPreferredRuntime` (imported above and re-exported for existing
|
||||
// importers) — that is the only thing shared between the two functions. The
|
||||
// TRUTHINESS RULE deliberately differs: `inferPreferredRuntime` treats a
|
||||
// bare, unchecked `CODEX_HOME` as sufficient to resolve an update context,
|
||||
// while THIS module additionally requires the marker file to exist, because
|
||||
// it is asserting session identity rather than resolving an update context
|
||||
// and needs the stronger signal. That difference is intentional and pinned
|
||||
// by a test in `tests/host-runtime-detection.test.cjs` rather than left
|
||||
// implicit.
|
||||
//
|
||||
// The DEFAULT `~/.codex/config.toml` is deliberately NEVER probed here:
|
||||
// every machine that has ever run Codex has that file, so probing it
|
||||
// unconditionally would misreport Claude Code sessions (or any other
|
||||
// runtime) as codex just because Codex was installed at some point. An
|
||||
// explicitly-exported CODEX_HOME is the user designating a Codex root for
|
||||
// the CURRENT session, which is a much stronger signal.
|
||||
export const CODEX_CONFIG_HOME_ENV = 'CODEX_HOME';
|
||||
|
||||
// The degraded no-detection result. Also the fallback returned when ANY step
|
||||
// of detection throws (see the module's stated no-throw premise below).
|
||||
const NO_DETECTION: HostRuntimeDetection = { runtime: null, source: 'none', signal: null };
|
||||
|
||||
/**
|
||||
* Detect the host runtime from process environment signals, without ever
|
||||
* consulting the explicit GSD_RUNTIME/config.json sources (those are a
|
||||
* higher-priority rung handled by resolveExplicitRuntime).
|
||||
*
|
||||
* Never throws: the whole body — including the raw `env[key]` reads, which a
|
||||
* caller could supply as a throwing Proxy — is wrapped in a single guarded
|
||||
* region that degrades to `NO_DETECTION` on any unexpected error, rather than
|
||||
* only guarding the `fileExists` probe.
|
||||
*/
|
||||
export function detectHostRuntime(deps?: DetectionDeps): HostRuntimeDetection {
|
||||
try {
|
||||
const env = deps?.env ?? process.env;
|
||||
const fileExists = deps?.fileExists ?? ((p: string) => fs.existsSync(p));
|
||||
|
||||
for (const key of CODEX_SESSION_ENV_SIGNALS) {
|
||||
const value = env[key];
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return { runtime: 'codex', source: 'session-env', signal: key };
|
||||
}
|
||||
}
|
||||
|
||||
const codexHome = env[CODEX_CONFIG_HOME_ENV];
|
||||
if (typeof codexHome === 'string' && codexHome.trim() !== '') {
|
||||
try {
|
||||
if (fileExists(path.join(codexHome, CODEX_CONFIG_MARKER))) {
|
||||
return { runtime: 'codex', source: 'config-home', signal: CODEX_CONFIG_HOME_ENV };
|
||||
}
|
||||
} catch {
|
||||
// Swallow probe failures (EACCES etc.) and fall through to no-detection.
|
||||
}
|
||||
}
|
||||
|
||||
return NO_DETECTION;
|
||||
} catch {
|
||||
// A malformed `deps.env` (e.g. a throwing Proxy) must degrade like any
|
||||
// other unreadable signal, not propagate — this function's contract is
|
||||
// that it never throws.
|
||||
return NO_DETECTION;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the runtime to report from init: explicit sources first, then the
|
||||
* host-detection rung, then the 'claude' default. This is intentionally
|
||||
* separate from resolveRuntime — only init's agent_runtime reporting call
|
||||
* site uses this ladder; every other resolveRuntime caller is unaffected.
|
||||
*
|
||||
* Never throws: degrades to the 'claude' default on any unexpected error
|
||||
* (e.g. a throwing `deps.env`), matching detectHostRuntime's no-throw
|
||||
* contract.
|
||||
*/
|
||||
export function resolveReportedRuntime(projectDir: string | null | undefined, deps?: DetectionDeps): string {
|
||||
try {
|
||||
return resolveReportedRuntimeUnsafe(projectDir, deps);
|
||||
} catch {
|
||||
return 'claude';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveReportedRuntimeUnsafe(projectDir: string | null | undefined, deps?: DetectionDeps): string {
|
||||
const explicit = resolveExplicitRuntime(projectDir, deps?.env ?? process.env);
|
||||
if (explicit) return explicit;
|
||||
const detected = detectHostRuntime(deps);
|
||||
if (detected.runtime) return detected.runtime;
|
||||
return 'claude';
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { maskIfSecret } from './secrets.cjs';
|
||||
import scanPhasePlans = require('./plan-scan.cjs');
|
||||
import { stateExtractField } from './state-document.cjs';
|
||||
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
|
||||
import { resolveReportedRuntime } from './host-runtime-detection.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module
|
||||
import commandsMod = require('./commands.cjs');
|
||||
import { validatePath, loadTrustedGlobalRoots } from './security.cjs';
|
||||
@@ -304,7 +305,8 @@ function getLatestCompletedMilestone(cwd: string): { version: string; name: stri
|
||||
|
||||
function withProjectRoot(cwd: string, result: Record<string, unknown>): Record<string, unknown> {
|
||||
result['project_root'] = cwd;
|
||||
const activeRuntime = resolveRuntime(cwd);
|
||||
// #3245: the reported agent_runtime gets a host-detection rung below the two explicit sources; every other resolveRuntime caller keeps the old ladder (ADR-2313 scope boundary).
|
||||
const activeRuntime = resolveReportedRuntime(cwd);
|
||||
const agentStatus = checkAgentsInstalled(activeRuntime, cwd);
|
||||
result['agents_installed'] = agentStatus.agents_installed;
|
||||
result['missing_agents'] = agentStatus.missing_agents;
|
||||
|
||||
@@ -74,19 +74,24 @@ export function formatGsdSlash(commandName: unknown, runtime: unknown): unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective runtime for a project directory.
|
||||
* Resolve the explicit runtime for a project directory, from the two
|
||||
* explicit sources only — no default is applied.
|
||||
*
|
||||
* process.env.GSD_RUNTIME > config.runtime > 'claude'
|
||||
* env.GSD_RUNTIME > config.runtime > null
|
||||
*
|
||||
* Mirrors the precedence already used by profile-output.cjs and the rest of
|
||||
* the runtime resolution chain. Returns a lowercased string so downstream
|
||||
* comparisons can be case-blind.
|
||||
* Returns null when neither explicit source is set, so a caller can
|
||||
* distinguish "config said claude" from "nothing was set" (needed by
|
||||
* #3245's host-detection rung, which must only run when this returns null).
|
||||
*
|
||||
* @param projectDir - path to the project directory, or null/undefined
|
||||
* @returns the resolved runtime name
|
||||
* @param env - environment variables to read GSD_RUNTIME from; defaults to process.env
|
||||
* @returns the resolved runtime name, or null if neither source is set
|
||||
*/
|
||||
export function resolveRuntime(projectDir: string | null | undefined): string {
|
||||
const envRuntime = resolveRuntimeNameFromCandidates(process.env['GSD_RUNTIME']);
|
||||
export function resolveExplicitRuntime(
|
||||
projectDir: string | null | undefined,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): string | null {
|
||||
const envRuntime = resolveRuntimeNameFromCandidates(env['GSD_RUNTIME']);
|
||||
if (envRuntime) return envRuntime;
|
||||
if (projectDir) {
|
||||
try {
|
||||
@@ -109,7 +114,23 @@ export function resolveRuntime(projectDir: string | null | undefined): string {
|
||||
// runtime output formatting.
|
||||
}
|
||||
}
|
||||
return 'claude';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective runtime for a project directory.
|
||||
*
|
||||
* process.env.GSD_RUNTIME > config.runtime > 'claude'
|
||||
*
|
||||
* Mirrors the precedence already used by profile-output.cjs and the rest of
|
||||
* the runtime resolution chain. Returns a lowercased string so downstream
|
||||
* comparisons can be case-blind.
|
||||
*
|
||||
* @param projectDir - path to the project directory, or null/undefined
|
||||
* @returns the resolved runtime name
|
||||
*/
|
||||
export function resolveRuntime(projectDir: string | null | undefined): string {
|
||||
return resolveExplicitRuntime(projectDir) ?? 'claude';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,6 +35,13 @@ export const RUNTIME_DIRS: RuntimeDirEntry[] = [
|
||||
|
||||
const SEMVER_PREFIX = /^\d+\.\d+\.\d+/;
|
||||
|
||||
// Shared Codex config-root marker filename. Single-sourced here (this module
|
||||
// is the pre-existing, dependency-free owner) and re-exported by
|
||||
// host-runtime-detection.cts, whose detection rung reuses this exact probe
|
||||
// filename (though NOT the truthiness rule below — see that module's header
|
||||
// comment for the deliberate divergence).
|
||||
export const CODEX_CONFIG_MARKER = 'config.toml';
|
||||
|
||||
function expandHome(p: string | undefined | null, home: string): string {
|
||||
if (!p) return '';
|
||||
return p.startsWith('~/') ? path.join(home, p.slice(2)) : p;
|
||||
@@ -81,7 +88,7 @@ export function inferPreferredRuntime({ fs, env, preferredConfigDir }: InferPref
|
||||
fs.exists(path.join(preferredConfigDir, 'kilo.jsonc'))) return 'kilo';
|
||||
if (fs.exists(path.join(preferredConfigDir, 'opencode.json')) ||
|
||||
fs.exists(path.join(preferredConfigDir, 'opencode.jsonc'))) return 'opencode';
|
||||
if (fs.exists(path.join(preferredConfigDir, 'config.toml'))) return 'codex';
|
||||
if (fs.exists(path.join(preferredConfigDir, CODEX_CONFIG_MARKER))) return 'codex';
|
||||
}
|
||||
if (env['CODEX_HOME']) return 'codex';
|
||||
if (env['ANTIGRAVITY_CONFIG_DIR']) return 'antigravity';
|
||||
|
||||
@@ -135,6 +135,57 @@ describe('init commands: agents_installed field (#1371)', () => {
|
||||
assert.deepStrictEqual(output.missing_agents, []);
|
||||
});
|
||||
|
||||
test('init reports codex when the session signal is present', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
const result = runGsdTools('init plan-phase 1 --raw', tmpDir, { CODEX_SANDBOX: 'seatbelt' });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.agent_runtime, 'codex');
|
||||
});
|
||||
|
||||
test('init honors explicit config runtime over detection', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ runtime: 'claude' }),
|
||||
);
|
||||
|
||||
const result = runGsdTools('init plan-phase 1 --raw', tmpDir, { CODEX_SANDBOX: 'seatbelt' });
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.agent_runtime, 'claude');
|
||||
});
|
||||
|
||||
test('init is unaffected in a non-codex session', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
const result = runGsdTools('init plan-phase 1 --raw', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.agent_runtime, 'claude');
|
||||
});
|
||||
|
||||
test('init honors GSD_RUNTIME over detection', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
const result = runGsdTools('init plan-phase 1 --raw', tmpDir, {
|
||||
GSD_RUNTIME: 'opencode',
|
||||
CODEX_SANDBOX: 'seatbelt',
|
||||
});
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.agent_runtime, 'opencode');
|
||||
});
|
||||
|
||||
test('init execute-phase includes missing_agents list when agents are missing', () => {
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
|
||||
@@ -76,6 +76,16 @@ const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [
|
||||
'GSD_RUNTIME',
|
||||
'GSD_PROJECT',
|
||||
'GSD_WORKSTREAM',
|
||||
// #3245: host-session signals GSD now reads (host-runtime-detection.cts's
|
||||
// detectHostRuntime / resolveReportedRuntime). Scrubbed for the same reason
|
||||
// GSD_RUNTIME is — an ambiently-set CODEX_SANDBOX / (this repo's test suite
|
||||
// running from inside a Codex session, or any host that happens to export
|
||||
// these) would non-deterministically flip the detected runtime for every
|
||||
// test that does not explicitly pass them. Tests that WANT them set still
|
||||
// can, via the per-call env override, which is applied after this base and
|
||||
// so continues to win.
|
||||
'CODEX_SANDBOX',
|
||||
'CODEX_SANDBOX_NETWORK_DISABLED',
|
||||
];
|
||||
|
||||
// Write-escape PERMISSIONS — deliberately its own family, and deliberately NOT
|
||||
|
||||
582
tests/host-runtime-detection.test.cjs
Normal file
582
tests/host-runtime-detection.test.cjs
Normal file
@@ -0,0 +1,582 @@
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Unit tests for host-runtime-detection.cjs (#3245, epic #2313 Phase 5).
|
||||
*
|
||||
* Requires the COMPILED artifact:
|
||||
* ../gsd-core/bin/lib/host-runtime-detection.cjs
|
||||
*
|
||||
* detectHostRuntime is a pure, injected-deps function; resolveReportedRuntime
|
||||
* layers the explicit GSD_RUNTIME/config.json precedence (via runtime-slash's
|
||||
* resolveExplicitRuntime) above the host-detection rung. Neither writes to
|
||||
* disk (#2297).
|
||||
*/
|
||||
|
||||
const { test, describe, mock } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const fc = require('fast-check');
|
||||
|
||||
const { createTempProject, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const LIB_DIR = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
||||
|
||||
const {
|
||||
CODEX_SESSION_ENV_SIGNALS,
|
||||
CODEX_CONFIG_HOME_ENV,
|
||||
CODEX_CONFIG_MARKER,
|
||||
detectHostRuntime,
|
||||
resolveReportedRuntime,
|
||||
} = require(path.join(LIB_DIR, 'host-runtime-detection.cjs'));
|
||||
|
||||
const { resolveRuntime, resolveExplicitRuntime } = require(path.join(LIB_DIR, 'runtime-slash.cjs'));
|
||||
|
||||
const updateContext = require(path.join(LIB_DIR, 'update-context.cjs'));
|
||||
|
||||
const NONE_RESULT = { runtime: null, source: 'none', signal: null };
|
||||
|
||||
/**
|
||||
* Returns a fileExists spy pre-seeded with a set of "existing" paths. Every
|
||||
* probed path is recorded (normalized, backslash -> forward-slash) on
|
||||
* `.calls`, so a test can assert both the return value AND exactly which
|
||||
* paths were probed (needed for the negative-probe assertion at #13).
|
||||
*/
|
||||
function spyFileExists(existingPaths = []) {
|
||||
const normalized = new Set(existingPaths.map((p) => String(p).replace(/\\/g, '/')));
|
||||
const spy = (p) => {
|
||||
const key = String(p).replace(/\\/g, '/');
|
||||
spy.calls.push(key);
|
||||
return normalized.has(key);
|
||||
};
|
||||
spy.calls = [];
|
||||
return spy;
|
||||
}
|
||||
|
||||
/** Write `<dir>/.planning/config.json` with a raw (possibly non-JSON) string. */
|
||||
function writeConfig(dir, rawString) {
|
||||
const planningDir = path.join(dir, '.planning');
|
||||
fs.mkdirSync(planningDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), rawString);
|
||||
}
|
||||
|
||||
// ─── detectHostRuntime ───────────────────────────────────────────────────
|
||||
|
||||
describe('detectHostRuntime', () => {
|
||||
test('CODEX_SANDBOX detects a codex session', () => {
|
||||
const result = detectHostRuntime({ env: { CODEX_SANDBOX: 'seatbelt' }, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, { runtime: 'codex', source: 'session-env', signal: 'CODEX_SANDBOX' });
|
||||
});
|
||||
|
||||
test('network-disabled sandbox var detects a codex session', () => {
|
||||
const result = detectHostRuntime({
|
||||
env: { CODEX_SANDBOX_NETWORK_DISABLED: '1' },
|
||||
fileExists: () => false,
|
||||
});
|
||||
assert.deepStrictEqual(result, {
|
||||
runtime: 'codex',
|
||||
source: 'session-env',
|
||||
signal: 'CODEX_SANDBOX_NETWORK_DISABLED',
|
||||
});
|
||||
});
|
||||
|
||||
test('CODEX_HOME with config.toml detects codex', () => {
|
||||
const home = path.join(os.tmpdir(), 'fake-codex-home');
|
||||
const spy = spyFileExists([path.join(home, 'config.toml')]);
|
||||
const result = detectHostRuntime({ env: { CODEX_HOME: home }, fileExists: spy });
|
||||
assert.deepStrictEqual(result, { runtime: 'codex', source: 'config-home', signal: 'CODEX_HOME' });
|
||||
});
|
||||
|
||||
test('session signal outranks the config-home rung', () => {
|
||||
const home = path.join(os.tmpdir(), 'fake-codex-home');
|
||||
const spy = spyFileExists([path.join(home, 'config.toml')]);
|
||||
const result = detectHostRuntime({
|
||||
env: { CODEX_SANDBOX: 'seatbelt', CODEX_HOME: home },
|
||||
fileExists: spy,
|
||||
});
|
||||
assert.strictEqual(result.source, 'session-env');
|
||||
assert.deepStrictEqual(result, { runtime: 'codex', source: 'session-env', signal: 'CODEX_SANDBOX' });
|
||||
});
|
||||
|
||||
test('no signal resolves to no detection', () => {
|
||||
const result = detectHostRuntime({ env: {}, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
|
||||
test('empty env value is not a signal', () => {
|
||||
const result = detectHostRuntime({ env: { CODEX_SANDBOX: '' }, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
|
||||
test('whitespace-only env value is not a signal', () => {
|
||||
const result = detectHostRuntime({ env: { CODEX_SANDBOX: ' ' }, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
|
||||
test('CODEX_HOME without config.toml is not a codex root', () => {
|
||||
const home = path.join(os.tmpdir(), 'fake-codex-home-empty');
|
||||
const spy = spyFileExists([]);
|
||||
const result = detectHostRuntime({ env: { CODEX_HOME: home }, fileExists: spy });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
assert.deepStrictEqual(spy.calls, [path.join(home, 'config.toml').replace(/\\/g, '/')]);
|
||||
});
|
||||
|
||||
test('nonexistent CODEX_HOME degrades to no detection', () => {
|
||||
const home = path.join(os.tmpdir(), 'gsd-nonexistent-codex-home-' + Date.now());
|
||||
const result = detectHostRuntime({ env: { CODEX_HOME: home }, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
|
||||
test('CODEX_HOME pointing at a file degrades to no detection', (t) => {
|
||||
const tmpFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-codex-home-file-')), 'not-a-dir');
|
||||
fs.writeFileSync(tmpFile, 'not a codex home\n');
|
||||
t.after(() => cleanup(path.dirname(tmpFile)));
|
||||
// No `fileExists` override: exercises the real fs.existsSync default.
|
||||
assert.doesNotThrow(() => {
|
||||
const result = detectHostRuntime({ env: { CODEX_HOME: tmpFile } });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
});
|
||||
|
||||
test('empty CODEX_HOME is unset', () => {
|
||||
const result = detectHostRuntime({ env: { CODEX_HOME: '' }, fileExists: () => true });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
|
||||
test('a throwing fileExists degrades rather than propagating', () => {
|
||||
const throwingFileExists = () => {
|
||||
const err = new Error('permission denied');
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
};
|
||||
assert.doesNotThrow(() => {
|
||||
const result = detectHostRuntime({ env: { CODEX_HOME: '/some/codex/home' }, fileExists: throwingFileExists });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
});
|
||||
|
||||
test('a throwing env degrades to the none-result rather than propagating', () => {
|
||||
// The whole detection body — not just the fileExists probe — must be
|
||||
// throw-safe: a proxied env whose GET trap throws must degrade like any
|
||||
// other unreadable signal (#3245 fix 4).
|
||||
const throwingEnv = new Proxy({}, { get() { throw new Error('boom'); } });
|
||||
assert.doesNotThrow(() => {
|
||||
const result = detectHostRuntime({ env: throwingEnv, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
});
|
||||
});
|
||||
|
||||
test('the default codex home is never probed', () => {
|
||||
// Every machine that has ever run Codex has ~/.codex/config.toml, so
|
||||
// probing the DEFAULT home unconditionally would misreport a plain
|
||||
// Claude session as codex just because Codex was installed at some
|
||||
// point. Detection must require an explicit CODEX_HOME.
|
||||
const defaultMarker = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
const spy = spyFileExists([defaultMarker]);
|
||||
const result = detectHostRuntime({ env: {}, fileExists: spy });
|
||||
assert.strictEqual(result.runtime, null);
|
||||
assert.ok(
|
||||
spy.calls.every((p) => !p.includes('/.codex/')),
|
||||
`expected no probe of the default ~/.codex root, got: ${JSON.stringify(spy.calls)}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('detection never writes shared defaults or any file (#2297)', (t) => {
|
||||
const writeFileMock = mock.method(fs, 'writeFileSync', () => {});
|
||||
const appendFileMock = mock.method(fs, 'appendFileSync', () => {});
|
||||
const mkdirMock = mock.method(fs, 'mkdirSync', () => {});
|
||||
t.after(() => {
|
||||
writeFileMock.mock.restore();
|
||||
appendFileMock.mock.restore();
|
||||
mkdirMock.mock.restore();
|
||||
});
|
||||
|
||||
detectHostRuntime({ env: {}, fileExists: () => false });
|
||||
detectHostRuntime({ env: { CODEX_SANDBOX: 'seatbelt' }, fileExists: () => false });
|
||||
detectHostRuntime({ env: { CODEX_HOME: '/some/codex/home' }, fileExists: () => true });
|
||||
|
||||
assert.strictEqual(writeFileMock.mock.callCount(), 0);
|
||||
assert.strictEqual(appendFileMock.mock.callCount(), 0);
|
||||
assert.strictEqual(mkdirMock.mock.callCount(), 0);
|
||||
});
|
||||
|
||||
test('signal table is frozen and locked', () => {
|
||||
assert.strictEqual(Object.isFrozen(CODEX_SESSION_ENV_SIGNALS), true);
|
||||
assert.deepStrictEqual(
|
||||
[...CODEX_SESSION_ENV_SIGNALS].sort(),
|
||||
['CODEX_SANDBOX', 'CODEX_SANDBOX_NETWORK_DISABLED'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── resolveReportedRuntime precedence ──────────────────────────────────
|
||||
|
||||
describe('resolveReportedRuntime: precedence', () => {
|
||||
function withProject(t) {
|
||||
const savedGsdRuntime = process.env.GSD_RUNTIME;
|
||||
delete process.env.GSD_RUNTIME;
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => {
|
||||
cleanup(tmpDir);
|
||||
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = savedGsdRuntime;
|
||||
});
|
||||
return tmpDir;
|
||||
}
|
||||
|
||||
test('env runtime wins with no other signal', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { GSD_RUNTIME: 'opencode' } });
|
||||
assert.strictEqual(result, 'opencode');
|
||||
});
|
||||
|
||||
test('GSD_RUNTIME outranks config and detection', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'claude' }));
|
||||
const result = resolveReportedRuntime(tmpDir, {
|
||||
env: { GSD_RUNTIME: 'opencode', CODEX_SANDBOX: 'seatbelt', CODEX_SANDBOX_NETWORK_DISABLED: '1' },
|
||||
});
|
||||
assert.strictEqual(result, 'opencode');
|
||||
});
|
||||
|
||||
test('env alias normalizes before detection can fire', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { GSD_RUNTIME: 'codex-cli' } });
|
||||
assert.strictEqual(result, 'codex');
|
||||
});
|
||||
|
||||
test('invalid GSD_RUNTIME falls through to config', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'kimi' }));
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { GSD_RUNTIME: ' ' } });
|
||||
assert.strictEqual(result, 'kimi');
|
||||
});
|
||||
|
||||
test('explicit config runtime outranks detection (#2517 preserved)', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'claude' }));
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } });
|
||||
assert.strictEqual(result, 'claude');
|
||||
});
|
||||
|
||||
test('explicit codex config unchanged', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'codex' }));
|
||||
const result = resolveReportedRuntime(tmpDir, { env: {} });
|
||||
assert.strictEqual(result, 'codex');
|
||||
});
|
||||
|
||||
test('no signal resolves to the claude default', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const result = resolveReportedRuntime(tmpDir, { env: {} });
|
||||
assert.strictEqual(result, 'claude');
|
||||
});
|
||||
|
||||
test('missing config file still detects', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } });
|
||||
assert.strictEqual(result, 'codex');
|
||||
});
|
||||
|
||||
test('malformed config json falls through to detection', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
writeConfig(tmpDir, '{ this is not json');
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } });
|
||||
assert.strictEqual(result, 'codex');
|
||||
});
|
||||
|
||||
test('non-object config json is not a runtime source', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const rawBodies = ['0', '"str"', '[]', 'null', 'true', ''];
|
||||
for (const raw of rawBodies) {
|
||||
writeConfig(tmpDir, raw);
|
||||
assert.strictEqual(
|
||||
resolveReportedRuntime(tmpDir, { env: {} }),
|
||||
'claude',
|
||||
`raw config body ${JSON.stringify(raw)} unexpectedly acted as a runtime source (no signal)`,
|
||||
);
|
||||
assert.strictEqual(
|
||||
resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } }),
|
||||
'codex',
|
||||
`raw config body ${JSON.stringify(raw)} unexpectedly blocked detection`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('crlf config json is unaffected', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const crlfConfig = ['{', ' "runtime": "kimi"', '}'].join('\r\n');
|
||||
writeConfig(tmpDir, crlfConfig);
|
||||
const result = resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } });
|
||||
assert.strictEqual(result, 'kimi');
|
||||
});
|
||||
|
||||
// Deliberately NOT a "windows-shaped CODEX_HOME" test computed with
|
||||
// path.join(...) on the same POSIX host as the production code: that
|
||||
// construction passes by definition (both sides run the identical join)
|
||||
// and cannot catch a separator bug. This test instead proves path.join is
|
||||
// actually being used — a hand-rolled `home + '/' + marker` concatenation
|
||||
// would double the separator when `home` already ends in one; path.join
|
||||
// collapses it.
|
||||
test('probe path is joined, not concatenated', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const homeWithTrailingSep = path.join(os.tmpdir(), 'gsd-3245-trailing') + path.sep;
|
||||
const spy = spyFileExists([]);
|
||||
const result = resolveReportedRuntime(tmpDir, {
|
||||
env: { CODEX_HOME: homeWithTrailingSep },
|
||||
fileExists: spy,
|
||||
});
|
||||
assert.strictEqual(result, 'claude');
|
||||
assert.strictEqual(spy.calls.length, 1);
|
||||
const probedPath = spy.calls[0];
|
||||
assert.ok(
|
||||
!probedPath.includes('//'),
|
||||
`expected a normalized probe path with no doubled separator, got: ${probedPath}`,
|
||||
);
|
||||
assert.strictEqual(probedPath.split('/').pop(), CODEX_CONFIG_MARKER);
|
||||
});
|
||||
|
||||
test('a throwing env degrades to the claude default rather than propagating', (t) => {
|
||||
// resolveReportedRuntime's degraded answer is the 'claude' default, not
|
||||
// the detection none-result — matching detectHostRuntime's contract one
|
||||
// rung up the ladder (#3245 fix 4).
|
||||
const tmpDir = withProject(t);
|
||||
const throwingEnv = new Proxy({}, { get() { throw new Error('boom'); } });
|
||||
assert.doesNotThrow(() => {
|
||||
const result = resolveReportedRuntime(tmpDir, { env: throwingEnv, fileExists: () => false });
|
||||
assert.strictEqual(result, 'claude');
|
||||
});
|
||||
});
|
||||
|
||||
test('detection never mutates the project config', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'claude' }));
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
const before = fs.readFileSync(configPath);
|
||||
resolveReportedRuntime(tmpDir, { env: { CODEX_SANDBOX: 'seatbelt' } });
|
||||
const after = fs.readFileSync(configPath);
|
||||
assert.deepStrictEqual(before, after);
|
||||
});
|
||||
|
||||
test('default deps match injected deps', (t) => {
|
||||
const tmpDir = withProject(t);
|
||||
const savedCodexSandbox = process.env.CODEX_SANDBOX;
|
||||
process.env.CODEX_SANDBOX = 'seatbelt';
|
||||
t.after(() => {
|
||||
if (savedCodexSandbox === undefined) delete process.env.CODEX_SANDBOX;
|
||||
else process.env.CODEX_SANDBOX = savedCodexSandbox;
|
||||
});
|
||||
|
||||
const viaDefaultDeps = resolveReportedRuntime(tmpDir);
|
||||
const viaInjectedDeps = resolveReportedRuntime(tmpDir, { env: process.env });
|
||||
assert.strictEqual(viaDefaultDeps, 'codex');
|
||||
assert.strictEqual(viaDefaultDeps, viaInjectedDeps);
|
||||
});
|
||||
|
||||
test('resolveReportedRuntime never writes shared defaults or any file (#2297)', (t) => {
|
||||
// The #2297 negative proof above only wraps detectHostRuntime, but the
|
||||
// function that actually ships is resolveReportedRuntime (called by
|
||||
// withProjectRoot). This sibling wraps IT, across all three outcomes on
|
||||
// its ladder: explicit-config hit, detection hit, and the plain 'claude'
|
||||
// default.
|
||||
//
|
||||
// Fixture setup (createTempProject/writeConfig, which themselves call
|
||||
// mkdirSync/writeFileSync) runs BEFORE the mocks are installed, so the
|
||||
// fixtures land on real disk and only resolveReportedRuntime's own
|
||||
// behavior is under observation.
|
||||
const explicitDir = createTempProject();
|
||||
writeConfig(explicitDir, JSON.stringify({ runtime: 'opencode' }));
|
||||
const detectionDir = createTempProject();
|
||||
const defaultDir = createTempProject();
|
||||
|
||||
// Each mock is restored via t.after() immediately after creation — not a
|
||||
// try/finally in the test body (CONTRIBUTING.md bans that) — so restore
|
||||
// still runs even if an earlier assertion below throws.
|
||||
const writeFileMock = mock.method(fs, 'writeFileSync', () => {});
|
||||
t.after(() => writeFileMock.mock.restore());
|
||||
const appendFileMock = mock.method(fs, 'appendFileSync', () => {});
|
||||
t.after(() => appendFileMock.mock.restore());
|
||||
const mkdirMock = mock.method(fs, 'mkdirSync', () => {});
|
||||
t.after(() => mkdirMock.mock.restore());
|
||||
t.after(() => {
|
||||
cleanup(explicitDir);
|
||||
cleanup(detectionDir);
|
||||
cleanup(defaultDir);
|
||||
});
|
||||
|
||||
// Outcome 1: explicit config.json runtime wins.
|
||||
assert.strictEqual(resolveReportedRuntime(explicitDir, { env: {} }), 'opencode');
|
||||
// Outcome 2: no explicit config; host-detection rung fires.
|
||||
assert.strictEqual(
|
||||
resolveReportedRuntime(detectionDir, { env: { CODEX_SANDBOX: 'seatbelt' } }),
|
||||
'codex',
|
||||
);
|
||||
// Outcome 3: no explicit config, no detection signal; plain default.
|
||||
assert.strictEqual(resolveReportedRuntime(defaultDir, { env: {} }), 'claude');
|
||||
|
||||
assert.strictEqual(writeFileMock.mock.callCount(), 0);
|
||||
assert.strictEqual(appendFileMock.mock.callCount(), 0);
|
||||
assert.strictEqual(mkdirMock.mock.callCount(), 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── runtime-slash frozen contract ──────────────────────────────────────
|
||||
|
||||
describe('runtime-slash: frozen contract (#3245 extraction must not move it)', () => {
|
||||
test('resolveRuntime contract is unchanged by the extraction', (t) => {
|
||||
const savedGsdRuntime = process.env.GSD_RUNTIME;
|
||||
delete process.env.GSD_RUNTIME;
|
||||
t.after(() => {
|
||||
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = savedGsdRuntime;
|
||||
});
|
||||
|
||||
assert.strictEqual(resolveRuntime(undefined), 'claude');
|
||||
assert.strictEqual(resolveRuntime(null), 'claude');
|
||||
assert.strictEqual(
|
||||
resolveRuntime(path.join(os.tmpdir(), 'gsd-nonexistent-project-' + Date.now())),
|
||||
'claude',
|
||||
);
|
||||
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
assert.strictEqual(resolveRuntime(tmpDir), 'claude');
|
||||
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'opencode' }));
|
||||
assert.strictEqual(resolveRuntime(tmpDir), 'opencode');
|
||||
|
||||
process.env.GSD_RUNTIME = 'kimi';
|
||||
assert.strictEqual(resolveRuntime(tmpDir), 'kimi');
|
||||
});
|
||||
|
||||
test('resolveRuntime ignores a codex session signal', (t) => {
|
||||
// Detection is deliberately NOT wired into resolveRuntime — only
|
||||
// resolveReportedRuntime's separate ladder consults it — so command-style
|
||||
// emission (formatGsdSlash et al.) does not move (ADR-2313 scope boundary).
|
||||
const savedGsdRuntime = process.env.GSD_RUNTIME;
|
||||
const savedCodexSandbox = process.env.CODEX_SANDBOX;
|
||||
delete process.env.GSD_RUNTIME;
|
||||
process.env.CODEX_SANDBOX = 'seatbelt';
|
||||
t.after(() => {
|
||||
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = savedGsdRuntime;
|
||||
if (savedCodexSandbox === undefined) delete process.env.CODEX_SANDBOX;
|
||||
else process.env.CODEX_SANDBOX = savedCodexSandbox;
|
||||
});
|
||||
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
assert.strictEqual(resolveRuntime(tmpDir), 'claude');
|
||||
});
|
||||
|
||||
test('explicit claude is distinguishable from the default', (t) => {
|
||||
const savedGsdRuntime = process.env.GSD_RUNTIME;
|
||||
delete process.env.GSD_RUNTIME;
|
||||
t.after(() => {
|
||||
if (savedGsdRuntime === undefined) delete process.env.GSD_RUNTIME;
|
||||
else process.env.GSD_RUNTIME = savedGsdRuntime;
|
||||
});
|
||||
|
||||
const tmpDir = createTempProject();
|
||||
t.after(() => cleanup(tmpDir));
|
||||
assert.strictEqual(resolveExplicitRuntime(tmpDir), null);
|
||||
|
||||
writeConfig(tmpDir, JSON.stringify({ runtime: 'claude' }));
|
||||
assert.strictEqual(resolveExplicitRuntime(tmpDir), 'claude');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Generative-fix-divergence parity with inferPreferredRuntime ────────
|
||||
|
||||
describe('parity with update-context.cjs:inferPreferredRuntime (generative-fix-divergence guard)', () => {
|
||||
// Two surfaces independently encode "what indicates Codex" —
|
||||
// inferPreferredRuntime (update.md's version-detection ladder) and
|
||||
// detectHostRuntime (init's agent_runtime rung). This pins them to agree
|
||||
// on the CODEX_HOME signal so a future edit to one cannot silently diverge
|
||||
// from the other.
|
||||
test('codex marker filename is single-sourced', () => {
|
||||
// The ONLY thing actually shared between the two functions: the marker
|
||||
// FILENAME. update-context.cjs is the owner; host-runtime-detection.cjs
|
||||
// imports and re-exports it. This asserts the re-export is the same
|
||||
// binding (strict equality), not merely an independently-matching literal.
|
||||
assert.strictEqual(CODEX_CONFIG_MARKER, 'config.toml');
|
||||
assert.strictEqual(CODEX_CONFIG_MARKER, updateContext.CODEX_CONFIG_MARKER);
|
||||
});
|
||||
|
||||
test('detection requires the marker where inferPreferredRuntime does not', () => {
|
||||
// THE DIVERGENCE POINT (deliberate, not a bug — see the header comment on
|
||||
// CODEX_CONFIG_HOME_ENV in src/host-runtime-detection.cts and the "Host
|
||||
// Runtime Detection Module" entry in CONTEXT.md). With CODEX_HOME set and
|
||||
// NO config.toml present:
|
||||
// - inferPreferredRuntime resolves an UPDATE CONTEXT, where a bare
|
||||
// CODEX_HOME is a sufficient hint -> 'codex'.
|
||||
// - detectHostRuntime asserts SESSION IDENTITY and requires the
|
||||
// stronger marker-file signal -> no detection (null).
|
||||
// This test exists so that difference is a recorded decision rather than
|
||||
// an accident: it fails loudly if either side's rule ever changes.
|
||||
const dir = path.join(os.tmpdir(), 'not-a-real-codex-home');
|
||||
const env = { CODEX_HOME: dir };
|
||||
|
||||
const inferred = updateContext.inferPreferredRuntime({
|
||||
fs: { exists: () => false, readFile: () => null },
|
||||
env,
|
||||
preferredConfigDir: '',
|
||||
});
|
||||
assert.strictEqual(inferred, 'codex');
|
||||
|
||||
const detected = detectHostRuntime({ env, fileExists: () => false });
|
||||
assert.strictEqual(detected.runtime, null);
|
||||
});
|
||||
|
||||
test('config.toml is the shared codex marker filename', () => {
|
||||
const dir = path.join(os.tmpdir(), 'preferred-config-dir');
|
||||
const marker = path.join(dir, CODEX_CONFIG_MARKER);
|
||||
|
||||
const inferred = updateContext.inferPreferredRuntime({
|
||||
fs: { exists: (p) => p === marker, readFile: () => null },
|
||||
env: {},
|
||||
preferredConfigDir: dir,
|
||||
});
|
||||
assert.strictEqual(inferred, 'codex');
|
||||
assert.strictEqual(CODEX_CONFIG_MARKER, 'config.toml');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Properties ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('detectHostRuntime: properties', () => {
|
||||
const FC_OPTS = { numRuns: 200, seed: 32452843, verbose: true };
|
||||
|
||||
test('property — no signal key implies no detection', () => {
|
||||
const excludedKeys = new Set([...CODEX_SESSION_ENV_SIGNALS, CODEX_CONFIG_HOME_ENV]);
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.dictionary(
|
||||
fc.string({ minLength: 1, maxLength: 12 }).filter((k) => !excludedKeys.has(k)),
|
||||
fc.string(),
|
||||
),
|
||||
(env) => {
|
||||
const result = detectHostRuntime({ env, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, NONE_RESULT);
|
||||
},
|
||||
),
|
||||
FC_OPTS,
|
||||
);
|
||||
});
|
||||
|
||||
test('property — any non-empty signal value detects codex', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constantFrom(...CODEX_SESSION_ENV_SIGNALS),
|
||||
fc.string().filter((s) => s.trim() !== ''),
|
||||
(key, value) => {
|
||||
const result = detectHostRuntime({ env: { [key]: value }, fileExists: () => false });
|
||||
assert.deepStrictEqual(result, { runtime: 'codex', source: 'session-env', signal: key });
|
||||
},
|
||||
),
|
||||
FC_OPTS,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user