From 8920eaa121543710bd489583c90dca140e26ba92 Mon Sep 17 00:00:00 2001 From: Zheng Weihao Date: Fri, 14 Aug 2026 18:43:18 +0800 Subject: [PATCH 1/2] fix: anchor session-injection hooks to the host-injected project dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook CWD is not a cross-host contract: a host may run hooks from outside the project directory, and orbit's CWD-based workspace detection then silently skips injection ("not in a workspace" is a designed silent no-op, so nothing surfaces). - hooks/session-start.sh / session-resume.sh: cd to CLAUDE_PROJECT_DIR (Claude Code's documented contract, also injected by Qoder), falling back to QODER_PROJECT_DIR (Qoder's documented contract) — guarded by [ -n ]/[ -d ] so empty/unset/invalid values and env-less hosts (codex sets hook CWD correctly by contract) pass through unchanged; stdout and stderr of the cd are both suppressed. - .opencode-plugin/plugin.ts: rawContext() anchors its shell to the SDK-provided PluginInput.directory via .cwd(...) instead of inheriting the opencode process cwd; the directory is per-instance, so the anchor names the session's project in every run mode. - tests: bats contract for the anchor chain (order, guards, fallthrough); node test pinning the plugin's .cwd(directory) call. - docs: spec-hooks states the host-CWD anchoring contract; CHANGELOG entry under Bug or Regression. Signed-off-by: Zheng Weihao --- .opencode-plugin/plugin.test.ts | 24 ++++++++++ .opencode-plugin/plugin.ts | 14 ++++-- CHANGELOG.md | 1 + docs/spec-hooks.md | 13 ++++++ hooks/session-resume.sh | 10 +++++ hooks/session-start.sh | 13 ++++++ tests/26_hook_cwd_anchor.bats | 80 +++++++++++++++++++++++++++++++++ 7 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 tests/26_hook_cwd_anchor.bats diff --git a/.opencode-plugin/plugin.test.ts b/.opencode-plugin/plugin.test.ts index d17f03a..48622cc 100644 --- a/.opencode-plugin/plugin.test.ts +++ b/.opencode-plugin/plugin.test.ts @@ -147,3 +147,27 @@ test("allowsOrbitCommand: sync --branch prompts (pool-wide state, root-only)", ( // Lookalike must not trip it. assert.equal(allowsOrbitCommand("orbit sync --branches"), true) }) + +test("rawContext anchors the shell to PluginInput.directory", async () => { + // Bun's `$` inherits the opencode process cwd, which equals the project + // only when opencode was launched from it — the plugin must anchor to the + // SDK-provided directory itself (serve/desktop modes differ). + const cwdCalls: string[] = [] + const fake$ = ((_strings: TemplateStringsArray) => { + const chain = { + cwd(d: string) { cwdCalls.push(d); return chain }, + nothrow() { return chain }, + quiet() { return chain }, + // thenable: rawContext awaits the chain + then(onFulfilled: (v: { exitCode: number; text: () => string }) => unknown) { + return Promise.resolve({ exitCode: 0, text: () => "CTX" }).then(onFulfilled) + }, + } + return chain + }) + const hooks = await orbitPlugin({ client: {}, $: fake$, directory: "/proj/dir" } as never) + const output = { system: [] as string[] } + await hooks["experimental.chat.system.transform"]!({ sessionID: "t1" } as never, output as never) + assert.deepEqual(cwdCalls, ["/proj/dir"]) + assert.match(output.system[0] ?? "", /CTX/) +}) diff --git a/.opencode-plugin/plugin.ts b/.opencode-plugin/plugin.ts index e0d29d9..9c071c8 100644 --- a/.opencode-plugin/plugin.ts +++ b/.opencode-plugin/plugin.ts @@ -121,7 +121,7 @@ const allowsOrbitCommand = (cmd: string): boolean => { return true } -const orbitPlugin = (async ({ client, $ }) => { +const orbitPlugin = (async ({ client, $, directory }) => { // Per-session cache for injected system context. Refreshed when a bash // tool that invokes the orbit CLI executes (workspace state may have // changed). compactedSessions marks sessions that have been compacted — @@ -134,12 +134,18 @@ const orbitPlugin = (async ({ client, $ }) => { // Run an orbit context command and return its raw markdown. Returns "" on // any failure (orbit missing / not in a workspace — the command fails - // fast in both cases). + // fast in both cases). The shell is anchored to the SDK-provided project + // directory: Bun's `$` otherwise inherits the opencode process cwd, which + // equals the project only when opencode was launched from it. Verified + // against opencode source (2026-08-14): PluginInput.directory is + // per-instance — plugin state is cached per directory (InstanceState + // scoped cache) and events route by directory — so the anchor holds in + // serve mode (multi-project server) as well. const rawContext = async (startup: boolean): Promise => { try { const res = startup - ? await $`orbit context --startup`.nothrow().quiet() - : await $`orbit context`.nothrow().quiet() + ? await $`orbit context --startup`.cwd(directory).nothrow().quiet() + : await $`orbit context`.cwd(directory).nothrow().quiet() if (res.exitCode !== 0) return "" return res.text().trim() } catch { diff --git a/CHANGELOG.md b/CHANGELOG.md index 16bd621..e100994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Hardens the destructive surface: `prune` and `sync --force`/`--branch` become ma - Session guard warns when process ancestry is unreadable, instead of failing silently open. - `orbit info` and the `orbit context --startup` reignite block no longer fetch — read paths are purely local again (zero network): #29's touchpoint fetch made every `info` and every session start with worktrees pay N serial remote round-trips (the default branch plus each tracked branch, one fetch each), multiplying with pool residue. Ruling: without an async daemon, auto-fetch on a main path taxes a synchronous caller for advisory freshness, and low friction outranks it — auto-fetch may return only off the main path. Layer-1 staleness (`remoteAhead`) now reads last-fetched refs, refreshed by the remaining fetching touchpoints (`orbit sync` / `orbit prune`) or the user's own fetch/pull; fetch-config maintenance (a local write) stays. - Bare `orbit prune` no longer reaps an empty repo's default-branch config: pool maintenance's orphan-config sweep treats the pool HEAD's target branch as always alive (possibly unborn) — its `branch..*` section is first-push routing, not residue. The protection tracks HEAD and self-releases once the branch gains a ref or the pool switches defaults; non-empty repos are unchanged (the ref check already keeps such sections). ([#36](https://github.com/orbcli/orbit/pull/36)) +- Session-injection hooks anchor their working directory to the host-injected project dir before workspace detection: hook CWD is not a cross-host contract, so a host running hooks from outside the project silently disabled `` injection for the entire session ("not in a workspace" is a designed silent no-op, so nothing ever surfaced). The shared `session-start.sh` / `session-resume.sh` now `cd` to `CLAUDE_PROJECT_DIR` (Claude Code's documented contract, also injected by Qoder) with `QODER_PROJECT_DIR` as fallback — guarded so empty/unset/invalid values and env-less hosts (codex sets hook CWD correctly by contract) pass through unchanged — and the OpenCode plugin anchors its shell to the SDK's `PluginInput.directory` instead of inheriting the opencode process cwd. #### Removal diff --git a/docs/spec-hooks.md b/docs/spec-hooks.md index 5b96342..6cc7f3b 100644 --- a/docs/spec-hooks.md +++ b/docs/spec-hooks.md @@ -58,6 +58,19 @@ merge; "behind" = sync; "over budget" = curate). - **Fail-safe.** Every hook is a silent no-op when orbit is missing or CWD is not in a workspace (`orbit context` fails fast in both cases). +- **Host-CWD anchoring.** Hook CWD is not a cross-host contract: a host may + run hooks from a directory other than the project (Claude Code only + promises "the current directory"; codex sets it to the session cwd). The + shared scripts therefore anchor to the host-injected project dir before + detection — `CLAUDE_PROJECT_DIR` (Claude Code's documented contract, also + injected by Qoder), then `QODER_PROJECT_DIR` (Qoder's documented + fallback) — guarded by `[ -n ]`/`[ -d ]` so empty/unset/invalid values + and env-less hosts (codex) pass through as a no-op. The OpenCode plugin + anchors its shell to the SDK-provided `PluginInput.directory` via + `.cwd(...)` instead of inheriting the opencode process cwd — + `directory` is per-instance (opencode materializes plugin state per + project directory, serve mode included), so the anchor names the + session's project in every run mode. ## Event routing diff --git a/hooks/session-resume.sh b/hooks/session-resume.sh index eeba6d0..839e82b 100755 --- a/hooks/session-resume.sh +++ b/hooks/session-resume.sh @@ -15,6 +15,16 @@ command -v orbit >/dev/null 2>&1 || exit 0 +# Same CWD anchoring as session-start.sh: hook CWD is not a cross-host +# contract, so anchor to the host-injected project dir +# (CLAUDE_PROJECT_DIR → QODER_PROJECT_DIR) before the CWD-based workspace +# detection. Guarded — empty/unset/invalid values are a silent no-op. +_orbit_anchor="${CLAUDE_PROJECT_DIR:-${QODER_PROJECT_DIR:-}}" +if [ -n "$_orbit_anchor" ] && [ -d "$_orbit_anchor" ]; then + cd "$_orbit_anchor" >/dev/null 2>&1 || true +fi +unset _orbit_anchor + HINT='' if out=$(orbit context 2>/dev/null) && [ -n "$out" ]; then diff --git a/hooks/session-start.sh b/hooks/session-start.sh index 07ff7af..38fa78c 100755 --- a/hooks/session-start.sh +++ b/hooks/session-start.sh @@ -27,6 +27,19 @@ EOF exit 0 fi +# Hook CWD is not a cross-host contract — a host may run hooks from outside +# the project directory — while orbit's workspace detection is CWD-based. +# Anchor to the host-injected project dir first: CLAUDE_PROJECT_DIR (Claude +# Code's documented contract, also injected by Qoder), then +# QODER_PROJECT_DIR (Qoder's documented fallback). Empty/unset/invalid +# values and hosts with a correct hook CWD (codex injects neither) pass +# through as a silent no-op. +_orbit_anchor="${CLAUDE_PROJECT_DIR:-${QODER_PROJECT_DIR:-}}" +if [ -n "$_orbit_anchor" ] && [ -d "$_orbit_anchor" ]; then + cd "$_orbit_anchor" >/dev/null 2>&1 || true +fi +unset _orbit_anchor + HINT='' if out=$(orbit context --startup 2>/dev/null) && [ -n "$out" ]; then diff --git a/tests/26_hook_cwd_anchor.bats b/tests/26_hook_cwd_anchor.bats new file mode 100644 index 0000000..becb04d --- /dev/null +++ b/tests/26_hook_cwd_anchor.bats @@ -0,0 +1,80 @@ +#!/usr/bin/env bats +# hooks/session-start.sh & hooks/session-resume.sh — host-CWD anchor contract. +# +# Hook CWD is not a cross-host contract — a host may run hooks from outside +# the project directory. The shared scripts anchor to the host-injected +# project dir (CLAUDE_PROJECT_DIR → QODER_PROJECT_DIR) before calling +# `orbit context`, whose workspace detection is CWD-based. These tests pin +# the anchor chain: fallback order, the guards (empty / unset / nonexistent / +# not-a-dir), and the no-op fallthrough for env-less hosts (codex). + +setup() { + load test_helper/common + common_setup + START_HOOK="$BATS_TEST_DIRNAME/../hooks/session-start.sh" + RESUME_HOOK="$BATS_TEST_DIRNAME/../hooks/session-resume.sh" + # Stub orbit on PATH: reports its argv and physical cwd. + STUB_BIN="$SANDBOX/bin" + mkdir -p "$STUB_BIN" + cat >"$STUB_BIN/orbit" <<'EOF' +#!/usr/bin/env bash +printf 'STUB args=%s cwd=%s\n' "$*" "$(pwd -P)" +EOF + chmod +x "$STUB_BIN/orbit" + export PATH="$STUB_BIN:$PATH" + PROJ="$SANDBOX/proj" + OTHER="$SANDBOX/other" + LAUNCH="$SANDBOX/launch" + mkdir -p "$PROJ" "$OTHER" "$LAUNCH" + PROJ_P="$(cd "$PROJ" && pwd -P)" + OTHER_P="$(cd "$OTHER" && pwd -P)" + LAUNCH_P="$(cd "$LAUNCH" && pwd -P)" +} + +teardown() { + common_teardown +} + +# Run both hooks from $LAUNCH with env vars passed as NAME=VALUE args; +# assert every run reports the expected physical cwd. +# $1: expected cwd; remaining args: env assignments for `env`. +assert_hooks_cwd() { + local want="$1"; shift + local hook args + for hook in "$START_HOOK" "$RESUME_HOOK"; do + if [ "$hook" = "$START_HOOK" ]; then args="context --startup"; else args="context"; fi + cd "$LAUNCH" + run env -u CLAUDE_PROJECT_DIR -u QODER_PROJECT_DIR "$@" bash "$hook" + [ "$status" -eq 0 ] + assert_contains "$output" "STUB args=$args cwd=$want" + done +} + +@test "anchor: CLAUDE_PROJECT_DIR wins when set" { + assert_hooks_cwd "$PROJ_P" "CLAUDE_PROJECT_DIR=$PROJ" +} + +@test "anchor: QODER_PROJECT_DIR is the fallback" { + assert_hooks_cwd "$PROJ_P" "QODER_PROJECT_DIR=$PROJ" +} + +@test "anchor: CLAUDE_PROJECT_DIR precedes QODER_PROJECT_DIR" { + assert_hooks_cwd "$PROJ_P" "CLAUDE_PROJECT_DIR=$PROJ" "QODER_PROJECT_DIR=$OTHER" +} + +@test "anchor: empty CLAUDE_PROJECT_DIR falls through to QODER_PROJECT_DIR" { + assert_hooks_cwd "$PROJ_P" "CLAUDE_PROJECT_DIR=" "QODER_PROJECT_DIR=$PROJ" +} + +@test "anchor: unset env keeps the launch cwd (env-less hosts unaffected)" { + assert_hooks_cwd "$LAUNCH_P" +} + +@test "anchor: nonexistent dir is refused" { + assert_hooks_cwd "$LAUNCH_P" "CLAUDE_PROJECT_DIR=$SANDBOX/no-such-dir" +} + +@test "anchor: a regular file is not a dir anchor" { + touch "$SANDBOX/afile" + assert_hooks_cwd "$LAUNCH_P" "CLAUDE_PROJECT_DIR=$SANDBOX/afile" +} From c7771fdb864e7962fd68e881891f564109e55a12 Mon Sep 17 00:00:00 2001 From: Zheng Weihao Date: Fri, 14 Aug 2026 18:52:59 +0800 Subject: [PATCH 2/2] docs: link #37 in the CHANGELOG entry Signed-off-by: Zheng Weihao --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e100994..2c628c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,7 @@ Hardens the destructive surface: `prune` and `sync --force`/`--branch` become ma - Session guard warns when process ancestry is unreadable, instead of failing silently open. - `orbit info` and the `orbit context --startup` reignite block no longer fetch — read paths are purely local again (zero network): #29's touchpoint fetch made every `info` and every session start with worktrees pay N serial remote round-trips (the default branch plus each tracked branch, one fetch each), multiplying with pool residue. Ruling: without an async daemon, auto-fetch on a main path taxes a synchronous caller for advisory freshness, and low friction outranks it — auto-fetch may return only off the main path. Layer-1 staleness (`remoteAhead`) now reads last-fetched refs, refreshed by the remaining fetching touchpoints (`orbit sync` / `orbit prune`) or the user's own fetch/pull; fetch-config maintenance (a local write) stays. - Bare `orbit prune` no longer reaps an empty repo's default-branch config: pool maintenance's orphan-config sweep treats the pool HEAD's target branch as always alive (possibly unborn) — its `branch..*` section is first-push routing, not residue. The protection tracks HEAD and self-releases once the branch gains a ref or the pool switches defaults; non-empty repos are unchanged (the ref check already keeps such sections). ([#36](https://github.com/orbcli/orbit/pull/36)) -- Session-injection hooks anchor their working directory to the host-injected project dir before workspace detection: hook CWD is not a cross-host contract, so a host running hooks from outside the project silently disabled `` injection for the entire session ("not in a workspace" is a designed silent no-op, so nothing ever surfaced). The shared `session-start.sh` / `session-resume.sh` now `cd` to `CLAUDE_PROJECT_DIR` (Claude Code's documented contract, also injected by Qoder) with `QODER_PROJECT_DIR` as fallback — guarded so empty/unset/invalid values and env-less hosts (codex sets hook CWD correctly by contract) pass through unchanged — and the OpenCode plugin anchors its shell to the SDK's `PluginInput.directory` instead of inheriting the opencode process cwd. +- Session-injection hooks anchor their working directory to the host-injected project dir before workspace detection: hook CWD is not a cross-host contract, so a host running hooks from outside the project silently disabled `` injection for the entire session ("not in a workspace" is a designed silent no-op, so nothing ever surfaced). The shared `session-start.sh` / `session-resume.sh` now `cd` to `CLAUDE_PROJECT_DIR` (Claude Code's documented contract, also injected by Qoder) with `QODER_PROJECT_DIR` as fallback — guarded so empty/unset/invalid values and env-less hosts (codex sets hook CWD correctly by contract) pass through unchanged — and the OpenCode plugin anchors its shell to the SDK's `PluginInput.directory` instead of inheriting the opencode process cwd. ([#37](https://github.com/orbcli/orbit/pull/37)) #### Removal