The devcontainer comments had grown dense and jargon-heavy. Rewrite them
across all 9 files into short, plain-English sentences — same facts and
reasoning, just clearer wording.
Comments only; no code changed. Verified: the diff touches comment lines
only, 25/25 config-transform tests pass, devcontainer.json is still valid
JSONC with build.args + readonly mounts unchanged, shell scripts pass
`bash -n`, and prettier is clean.
Follow-up to a local branch review (run after the cloud review crashed before
producing findings); all 5 confirmed findings were low/nit:
- chown via `find -xdev -exec chown -h`: add -h so chown acts on a symlink
ITSELF, not its target. Without it a dangling node_modules/.bin link aborted
provisioning under `set -e`, and a cross-fs symlink target could be
dereferenced/rewritten. Verified in a clean container (regular files still
chowned; dangling link no longer aborts; cross-fs target untouched). Applied
to install-deps.sh and post-create.sh; the inline comments are corrected to
describe -xdev (descent bound) and -h (no deref) as the two distinct guards.
- Reword the .cjs header claims from "lintable" to "unit-tested and
prettier-checked": ESLint applies no rules to .cjs in this repo; CI only
prettier-checks them.
- README: the initializeCommand is `node ensure-host-config-dirs.cjs`, which
creates the full bind-source set, not a bash `mkdir -p` of four dirs.
- ci-devcontainer.yml: document that the x64 runner exercises only the amd64
Cursor branch; the arm64 sha/URL is hash-pinned (verified against the
published artifact) but not built in CI.
- Make the seed chmod-644 test meaningful: pre-create dst at 0o600 so only the
explicit chmodSync can widen it (the prior assertion passed under the default
umask regardless of whether the chmod ran).
25/25 config-transform tests pass; arm64 + x64 Cursor artifacts verified.
Resolves the blocking + actionable findings from the PR #1875 review:
- Pin base image by digest as bare name@digest [#1]. The :tag@digest form
trips the @devcontainers/cli image-name parser (which builds this image
in CI and in VS Code "Reopen in Container"); bare name@digest is the
parser-compatible form. Verified by a full local build.
- Pin Cursor by version + per-arch sha256 and fetch the artifact directly
instead of executing cursor.com/install; fail-closed on mismatch [#2].
- Mount ~/.config/gh and ~/.docker read-only so a compromised dep can't
rewrite the host GitHub token / Docker credHelper [#4].
- Pin @devcontainers/cli@0.87.0 in the CI smoke [#5].
- chown via find -xdev in install-deps.sh (symlink-safe; matches
post-create.sh) [#6].
- Add filesystem-I/O tests (translate/readHostConfig/seed main/ensurePaths)
and refactor ensure-host-config-dirs to be unit-testable [#7].
- Stop pre-creating settings.json/config.toml on the host; only the real
single-file bind source (.claude.json) is touched [#10].
- Add a prominent top-of-README security callout for the RW write-through
trade-off and reframe the deferred egress firewall as the key missing
compensating control [#3, #9].
Full devcontainer build verified locally (digest pull + pinned Cursor
download/extract/symlink). 24/24 config-transform tests pass.
* feat(go): add builtInNames set to Go language provider
Add GO_BUILT_INS (15 functions, 18 types, 3 values) to the Go
LanguageProvider for parity with the other 13 language providers.
The set is converted to an isBuiltInName predicate by defineLanguage()
and consumed by the type-env return-type lookup to short-circuit
lookups for Go built-in symbols.
* feat(go): add Go 1.18+ and 1.21 predeclared identifiers to builtInNames
Add `clear`, `min`, `max` (Go 1.21 builtins), `any`, `comparable`
(Go 1.18 type aliases), and `iota` (predeclared constant) to
GO_BUILT_INS for complete coverage of the Go specification.
---------
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
- zizmor `artipacked` (CodeQL/GitHub Advanced Security) flagged both
actions/checkout steps in ci-devcontainer.yml: checkout defaults to
persist-credentials:true, leaving GITHUB_TOKEN in .git/config where it
can leak into uploaded artifacts. Both jobs are read-only (run tests /
build smoke, never push), so persist-credentials:false is correct —
matches the repo convention in codeql.yml / ci-tests.yml.
- Ran prettier 3.8.0 over the new .cjs modules + test (single-quote/style
normalization to match the repo). JSON/YAML were already compliant;
README is in .prettierignore; .sh has no prettier parser. Behavior
unchanged — 12/12 transform unit tests still pass.
Multi-agent review (9 reviewers) found the devcontainer files carried
comments + README from the abandoned read-only-symlink design, plus real
behavioral gaps. Resolved all actionable findings (no deferrals).
Documentation drift (the headline — stale comments described a security
model opposite to what shipped):
- README "Trust boundary" claimed a malicious dep "cannot write back …
the read-only /host mount blocks the write." FALSE — the shareable dirs
are RW-bound. Rewrote to document the bidirectional write-through, what
stays one-way (credentials never flow back), and how to close it.
- devcontainer.json mount group-1 comment described "selectively symlinks
… read-only eliminates write-through" — replaced with the RW-bind reality.
- Header "Windows-native is unsupported" -> supported (auto HOME setup).
- containerEnv comment "credentials persist in host-bind-mounted dirs" ->
they live in the named volumes.
- hooks.json exclusion documented honestly as a partial mitigation, not a
clean boundary (commands/agents/skills/rules are equally executing).
- ~/.local "named volume" -> image directory.
Behavioral fixes:
- chown -R recursed into the RW host binds (could rewrite host ownership /
EPERM-abort provisioning on non-UID-aligned Linux). Switched to
`find -xdev` per dir so chown stays on the volume filesystem.
- Cursor installer wrapped in `timeout 300` — its inner binary download
isn't covered by curl --max-time and could hang docker build forever.
- Removed dead CURSOR_VERSION ARG/ENV/build-arg (never consumed; "latest"
implied a pin the installer can't honor). Documented why Cursor is unpinned.
Extraction + tests (the two inline post-create.sh node heredocs were
unlintable and untestable; the path regex had had bugs):
- seed-claude-config.cjs — installMethod-strip seed, now with a non-object
guard (a bare-value/array host .claude.json could otherwise slip the
try/catch and silently re-trigger onboarding) and labeled write errors.
- translate-plugin-registries.cjs — plugin-registry path translation with
labeled errors.
- translate-plugin-registries.test.cjs — 12 tests (Windows/POSIX paths,
cross-CLI isolation, nested objects, non-object/empty-config guard).
- post-create.sh calls the modules via $SCRIPT_DIR.
CI:
- .github/workflows/ci-devcontainer.yml — runs the unit tests + shell
syntax checks + a `@devcontainers/cli build` smoke on .devcontainer/**
changes. Conforms to the repo concurrency convention (validator passes).
Documented (real gaps, fixes are honest docs since no correct auto-fix
exists): user-scope MCP servers with absolute host command paths don't
resolve in-container; user-scope config is copy-on-create so host edits
need a rebuild; in-container plugin installs get shadowed by an empty host
bind on rebuild (recovery noted); plugin installs are single-writer across
checkouts; gh/docker RW-vs-ssh/aws/azure-RO rationale.
Verified: fresh `@devcontainers/cli up` succeeds; installMethod stripped,
registry translated to Linux paths, credentials node:node, 12/12 tests pass.
Codex plugins installed in the container never reached the Windows host
because, unlike Claude, the Codex plugin tree wasn't bind-mounted —
only memories/ and skills/ were. Verified via live /proc/mounts: Claude
binds 6 shareable dirs (incl. plugins/marketplaces + plugins/cache),
Codex bound 2. So `codex plugin add` wrote into the ext4 named volume
and stayed there.
Codex changes:
- Bind the WHOLE ~/.codex/plugins dir + ~/.codex/prompts (plus existing
memories/skills). Strace of two real `codex plugin add` runs proved
the installer stages INSIDE plugins/cache/<marketplace>/ and renames
intra-dir, so a single 9p bind of plugins/ keeps the rename intra-fs —
no EXDEV (the bug that broke single-file binds). .tmp/ stays on the
volume (it's the cross-fs staging source). No path translation needed:
Codex enablement lives in config.toml as git URLs, not FS paths.
- Verified live: `codex plugin add compound-engineering@...` now writes
through to C:\Users\...\.codex\plugins\cache\ on the Windows host, and
host-created files appear in the container (bidirectional).
Cursor changes (review found cursor-agent has a real plugin surface, not
editor-only — Cursor 2.5 Marketplace shared by IDE + CLI):
- Bind plugins/marketplaces, plugins/local, rules, commands, agents,
skills (dir binds, EXDEV-safe).
- Copy-on-create mcp.json (single file → EXDEV-unsafe as bind), alongside
the existing cli-config.json.
- Translate plugins/installed_plugins.json (carries absolute Windows
paths like Claude's) — generalized the existing path-rewrite to run
for both Claude and Cursor.
- hooks.json deliberately NOT shared (runs shell commands → supply-chain
surface); documented as opt-in.
ensure-host-config-dirs.cjs pre-creates all new host bind sources.
post-create.sh defensive symlink cleanup extended to the new Codex/Cursor
paths. README updated with the accurate per-CLI share/sync/translate
matrix.
Design adversarially verified (straced installs, EXDEV primitive tests,
sqlite-under-bind check, path-encoding check) before implementing.
On Docker Desktop Windows the named volumes are ext4 (`/dev/sdd`) while
single-file bind mounts from the Windows host land as 9p (drvfs).
Different filesystems → atomic config writes (write `foo.tmp`, then
rename onto `foo`) trip EXDEV `inter-device move failed` /
`Device or resource busy`.
Codex's TUI surfaces this as `config/batchWrite failed in TUI` when
saving model preference. Claude's writes to settings.json / .claude.json
fail the same way, silently.
Reproduction in container:
$ echo x > /tmp/foo.toml; mv /tmp/foo.toml /home/node/.codex/config.toml
mv: inter-device move failed: ... Device or resource busy
Fix: drop the three single-file bind mounts. Sync host's versions into
the named volume on container-create via `sync_from_host` (same pattern
already used for credentials). Atomic rename within the volume works
because everything is ext4.
Trade-off: container writes to these files no longer propagate to host;
they stay in the volume until next rebuild, which re-syncs from host.
Host is source of truth on rebuild — same model as credentials. Plugin/
skill/agent/memory/command DIRS still bind-mount bidirectionally (atomic
writes within a dir bind stay on one filesystem, no EXDEV).
Files affected:
- ~/.codex/config.toml
- ~/.claude/settings.json
- ~/.claude.json (HOME-level — added `/host/.claude.json` RO mount back
for sync_from_host to read)
* feat(ingestion): resolve FastAPI include_router(prefix=...) cross-file routes
FastAPI sub-route files declare paths via @router.<verb> while the entry
file mounts the router with app.include_router(<router>, prefix='/x').
Previously both the ingestion-layer Route graph nodes and the group-layer
ExtractedContract URLs lost the cross-file prefix, breaking provider <->
consumer matching.
Ingestion layer:
- parse-worker emits routerIncludes / routerImports + decoratorReceiver
- parsing-processor / parse-impl thread the new fields and aggregate
prefixesByModule across chunks; decorator routes whose receiver is
'router' are duplicated once per matching prefix
- routes.ts joins prefix via normalizeExtractedRoutePath
Group layer:
- HttpLanguagePlugin gains an optional prepareRepo() pre-pass and a
repoContext arg to scan(); python.ts builds prefixesByModule and
falls back to the bare path when no entry matches
- http-route-extractor caches one repoContext per plugin
Tests:
- 3 new http-route-extractor cases (attr / named-import / no-prefix)
- ParseWorkerResult literals in 3 test files updated to the new shape
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(ingestion,group): address PR #1877 review — relative imports, cross-package collisions, host names, ingestion tests
Follow-ups to the FastAPI `include_router(prefix=...)` cross-file fix
based on PR #1877's automated production-readiness review. Three
correctness gaps and one test coverage gap addressed:
1. Relative-import support in the worker regex (FINDING 2)
`FROM_IMPORT_ROUTER_RE` now accepts module paths starting with a
`.` (e.g. `from .calls import router as calls_router`). The
previous `[A-Za-z_][\w.]*` rejected leading dots and silently
dropped every relative-import Shape-B include — a real pattern
from the PR description's own motivating example. The matching
helpers now strip leading dots before keying so absolute and
relative imports collapse to the same module key.
2. Cross-package same-name module collisions (FINDING 3)
Two-tier module keying replaces the previous basename-only key:
• short key — `users` (file basename without `.py`)
• long key — `api/users` (parent dir + stem)
`prefixesByLongKey` is consulted first and only falls back to
`prefixesByShortKey` when no long-key match is available. Both
the ingestion pipeline (parse-impl.ts) and the group extractor
(http-patterns/python.ts) carry the same scheme so the graph
nodes and HTTP contracts agree on which prefix applies.
New protocol field `ExtractedRouterModuleAlias` (parse-worker →
parsing-processor → parse-impl) lets Shape-A
`<host>.include_router(<mod>.router, prefix='/x')` calls promote
to a long key when the same file imports `<mod>` via
`from <pkg> import <mod>`. Without this, `api/users.py` and
`admin/users.py` collided on the basename `users` and the admin
file's routes inherited the `/users` prefix that was only meant
for `api/users.py`.
3. Non-`app` host variable names (FINDING 4)
The group-layer `INCLUDE_ROUTER_*_PATTERNS` queries pinned the
host identifier to the literal `"app"` and dropped every
`application = FastAPI()` / `api = FastAPI()` pattern — the
constraint was redundant given that the call shape
(`include_router` invoked with a router argument and a
`prefix=` keyword) is already specific enough. The pin is
removed; the ingestion regex was already unrestricted.
4. Ingestion-layer regression tests (FINDING 1)
The previous PR added group-layer tests
(`http-route-extractor.test.ts`) but zero in-tree tests for the
ingestion path. Two new suites pin the
worker → parse-impl → routes flow:
- `test/unit/fastapi-router-bindings.test.ts` (23 cases):
`extractFastAPIRouterBindings()` is split into a stand-alone
module so it can be unit-tested without booting a worker
thread, then pinned for regex shape, two-tier key emission,
relative-import support, and negative cases.
- `test/integration/fastapi-prefix-pipeline.test.ts` (5 cases)
plus `test/fixtures/fastapi-prefix-app/` — runs the full
`runPipelineFromRepo()` against a realistic multi-package
fixture (containing both `api/users.py` and `admin/users.py`)
and inspects the resulting `Route` graph nodes for cross-file
prefix joining and absence of cross-package bleed.
Verification
- `npx tsc --noEmit`: pass
- PR-touched test suites (6 files / 117 cases): all green
- `npx prettier --check`: pass on touched files
- `npx eslint`: 0 errors on touched files
Cache / compatibility
The new `routerModuleAliases?` field on `ParseWorkerResult` and
`routerModuleAliases` on `WorkerExtractedData` are optional /
guarded with `?? []`, so historical parse-cache entries continue
to load without forced re-scan.
Refs PR #1877.
* refactor(ingestion): move fastapi-router-bindings out of workers/ — pure module, not a worker
Addresses @magyargergo's `CHANGES_REQUESTED` review on PR #1877:
> Sorry I just found that we are introducing a new worker in the PR.
`gitnexus/src/core/ingestion/workers/fastapi-router-bindings.ts` was a
**pure-function module** — it never imported `worker_threads` or
`parentPort`, never spawned a worker, and was never registered as a
worker entry. It was placed in `workers/` purely because it was split
out of `workers/parse-worker.ts` to make its functions unit-testable
without booting a worker thread (parse-worker is itself the worker
entry and cannot be loaded from the main thread).
To remove the misleading directory placement:
• The implementation moves to
`gitnexus/src/core/ingestion/route-extractors/fastapi-router-bindings.ts`,
alongside the other framework-specific route extractors (`expo`,
`nextjs`, `php`, `laravel`, `middleware`, `response-shapes`).
• `workers/parse-worker.ts` keeps a thin re-export so the worker
entry can keep using `extractFastAPIRouterBindings` directly. The
re-export now carries an explicit comment stating that the imported
file is **not** a worker and that the `workers/` directory
deliberately hosts only true worker entries (`parse-worker.ts`,
`worker-pool.ts`, `quarantine.ts`).
• The new file's leading docstring opens with "NOT A WORKER" and
explains why it exists where it does.
• The unit test (`test/unit/fastapi-router-bindings.test.ts`) is
updated to import from the new path.
No behaviour change. The function body, signatures, and exported types
are identical.
Verification
• `npx tsc --noEmit`: pass
• `npx tsc` (dist rebuild): pass
• `test/unit/fastapi-router-bindings.test.ts` (23 cases): all green
• `test/integration/fastapi-prefix-pipeline.test.ts` (5 cases): all green
• `test/unit/group/http-route-extractor.test.ts` (63 cases): all green
• `npx prettier --check` on touched files: pass
• `npx eslint` on touched files: 0 errors
Refs PR #1877.
* refactor(ingestion): drop parse-worker re-exports; consumers import router types directly from route-extractors
Addresses @magyargergo's two remaining review comments on PR #1877:
1. **`gitnexus/src/core/ingestion/workers/parse-worker.ts:247`** —
"Can you please remove them and update the call sites?"
The `export type { ExtractedRouterInclude, ExtractedRouterImport,
ExtractedRouterModuleAlias } from '../route-extractors/...'` block
in parse-worker.ts is gone. The remaining `import type {…}` is
purely local — used only to type the corresponding fields on
`ParseWorkerResult` below — and the leading comment now says so
explicitly ("this file does NOT re-export them"). The
`extractFastAPIRouterBindings` symbol is also no longer re-exported
from parse-worker.ts; it's still imported here so the worker entry
can call it per file, but downstream consumers must reach it via
`route-extractors/fastapi-router-bindings` directly.
Call sites updated:
- `gitnexus/src/core/ingestion/parsing-processor.ts`
- `gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts`
Both files now `import type { ExtractedRouterInclude,
ExtractedRouterImport, ExtractedRouterModuleAlias }` directly from
`route-extractors/fastapi-router-bindings.js`. The worker types
they still need (`ParseWorkerResult`, `ExtractedToolDef`, etc.)
keep coming from `workers/parse-worker.js`.
The unit + integration tests already imported from the new path,
so no test changes were required.
2. **`gitnexus/src/core/ingestion/parsing-processor.ts:168`** —
suggested simplification:
for (const item of result.routerIncludes ?? []) allRouterIncludes.push(item);
for (const item of result.routerImports ?? []) allRouterImports.push(item);
for (const item of result.routerModuleAliases ?? []) allRouterModuleAliases.push(item);
Applied verbatim. Replaces the previous `if (result.…) for …`
guards. The cache-compat semantics are unchanged — historical
parse-cache entries that lack these fields still load cleanly,
the new form just spells the fallback inline.
No behavior change, no tests touched, no public API change.
Verification
• `npx tsc --noEmit`: pass
• `npx tsc` (dist rebuild): pass
• PR-touched test suites (6 files / 117 cases): all green
• `npx prettier --check` on touched files: pass
• `npx eslint` on touched files: 0 errors
Refs PR #1877.
* refactor(ingestion): hoist fastapi-router-bindings type imports to top of parse-worker.ts
Move the `import type { ExtractedRouterInclude, ExtractedRouterImport,
ExtractedRouterModuleAlias }` block to the top of the file with the
other type imports, and drop the comment that previously sat next to
ExtractedDecoratorRoute.
---------
Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
post-create.sh was doing two unrelated jobs: workspace dependency install
(four `npm install` runs in topological order) and AI CLI credential
sync. They have different lifecycle needs — deps should re-run when
lockfiles change, AI sync should run once per container — but both were
gated on container-create.
Per Dev Container spec lifecycle, `updateContentCommand` is the right
hook for workspace deps: runs at container-create AND on content
changes (lockfile updates). `postCreateCommand` is right for AI CLI
sync: container-create only.
Move steps 3-7 (husky cleanup + four `npm install` runs) into
install-deps.sh wired as `updateContentCommand`. Split the chown step
too — install-deps owns workspace-side dirs (node_modules volumes,
~/.npm), post-create owns AI-side dirs (~/.claude, ~/.codex, ~/.cursor,
/commandhistory, ~/.local). Each script now has one concern.
post-create.sh drops from ~187 lines to 148; install-deps.sh is 56 lines
new. Faster rebuilds when nothing about deps changed (the credential
sync + path translation work still runs every container-create, but the
npm install dance no longer does).
Research backing (no other simplification applies):
- Anthropic's reference devcontainer uses pure named volumes; no
host-state inheritance pattern is published.
- Path translation has no upstream fix (issues #21916, #10379 closed
without resolution). Our Node rewrite is the workaround.
- pnpm workspaces (`pnpm -r install`) would replace the four installs
with one command, but that's a real refactor (touches
gitnexus/scripts/build.js + 4 package.json files); deferred.
- `HUSKY=0` in containerEnv would drop the `rm -rf .husky/_` hack, but
would also stop pre-commit hooks from firing inside the container;
deferred.
A user upgrading from Option B (read-only host stage + symlinks) to the
current hybrid RW-bind topology hit EROFS in post-create.sh when the
plugin registry path-translator tried to write
`/home/node/.claude/plugins/known_marketplaces.json`. The named volume
still carried `/home/node/.claude/plugins -> /host/.claude/plugins`
(Option B's symlink). The new design's sub-path bind mounts at
`plugins/marketplaces` and `plugins/cache` overlay through the symlink,
but writes to the parent dir itself resolve via the symlink to the RO
host stage and fail.
Drop any leftover symlinks at known target paths early in step 2 so the
mkdir/writes that follow land in the volume.
The previous topology bind-mounted the entire `~/.claude/plugins/`
directory from host. That brought through plugins, marketplaces, and
extracted cache content correctly — but ALSO brought through the
registry JSONs (`known_marketplaces.json`, `installed_plugins.json`,
`plugin-catalog-cache.json`) which carry absolute OS-native paths:
"installLocation": "C:\Users\gergo\.claude\plugins\marketplaces\X"
"installPath": "C:\Users\gergo\.claude\plugins\cache\Y\Z"
Claude in the Linux container fails to resolve these Windows paths and
reports `Marketplace X failed to load: cache-miss`.
Split the topology:
- `plugins/marketplaces/` (git clones) and `plugins/cache/` (extracted
plugin files) stay bidirectional RW binds — content is path-independent.
- Registry JSONs move into the per-container named volume. post-create.sh
reads host's versions, rewrites any absolute path ending in
`/.claude/plugins/<rest>` (Windows `C:\Users\...` and POSIX
`/Users/...` / `/home/...` patterns) to `/home/node/.claude/plugins/<rest>`,
and writes the translated result to the volume.
What this gets you:
- Plugin installed on host → next container rebuild has it (translated).
- Plugin installed inside container → lives in volume registry; lost on
rebuild (consistent with credentials model). Re-install on host for
persistence.
ensure-host-config-dirs.cjs now also creates `plugins/marketplaces/` and
`plugins/cache/` on host if absent (Docker rejects bind mounts whose
source doesn't exist).
The previous Option B topology (RO host stage + named volume + symlinks
into the volume) made `/plugin marketplace add` inside the container fail
with EROFS — the symlinks pointed at a read-only mount, so Claude
couldn't create new marketplace dirs. Switch to a hybrid: shareable
content (plugins/skills/agents/memory/commands/settings.json/$HOME/.claude.json
for Claude; config.toml/memories/skills for Codex) gets a direct RW bind
from host so reads and writes go bidirectionally; credentials + the
small identity file stay in per-container named volumes so logout in
container doesn't log out host.
Mount precedence does the heavy lifting: the named volume mounts at
/home/node/.<cli> first, then sub-path bind mounts overlay specific
sub-paths. Container's view at /home/node/.claude/plugins/ is the host
dir; container's view at /home/node/.claude/.credentials.json is the
named volume's file.
What this gives you:
- /plugin marketplace add in container = installed on host
- New skill on host = visible in container immediately (no rebuild)
- claude logout in container = host stays logged in
- compound-engineering plugin enabled on host = enabled in container
- Theme picker fires once (or never if host has theme set)
What it costs:
- Write-through: a compromised npm dep in workspace deps can write to
host ~/.claude/{plugins,skills,agents,memory,commands}/. Documented
trade-off; for personal dev, accepted. Credentials still per-container.
post-create.sh becomes much simpler — only syncs the four credential
files from host into the named volumes. No more symlink dance, no more
state-file merging.
ensure-host-config-dirs.cjs gains the new bind sources: the shareable
subdirs and settings.json/config.toml files get mkdir/touched on host
so Docker doesn't reject the mount when a CLI has never been used.
Two changes that together give the container the same plugins and configs
as the host for all three AI CLIs (login stays per-container):
1. Drop CLAUDE_CONFIG_DIR from containerEnv. The named-volume mount target
`/home/node/.claude` already matches Claude's default `~/.claude`, so
the env var added no behavior — but setting it changed which file
Claude reads `hasCompletedOnboarding` from. With it set, Claude reads
`$CLAUDE_CONFIG_DIR/.claude.json` (the small identity-only file that
does NOT carry `hasCompletedOnboarding`); without it, Claude reads
`$HOME/.claude.json` (the big onboarding-state file that does). The
wizard fires every container-create when set, skips when unset.
2. Sync `settings.json` from host (Claude) + symlink `memories/` and
`skills/` from host (Codex). Theme + `enabledPlugins` +
`extraKnownMarketplaces` live in `settings.json` — without syncing
it, the theme picker fires and host-installed plugins stay disabled
even though their files are symlinked in. Codex's `memories/` and
`skills/` are the symmetric Codex user-installed surface, now shared
the same way Claude's plugins/skills/agents/memory/commands are.
Cursor stays as-is — `cli-config.json` conflates auth+settings (already
synced), and there's no separate plugin surface to mirror.
Login details remain per-container by design (acceptable to re-login on
rebuild). Everything else — plugins, skills, agents, memory, MCP user-
scope config, project trust, theme, plugin enablement — now matches
host on every container-create.
* feat(impact): per-symbol processes field on byDepth items
Today `impact` returns aggregated `affected_processes` at the top level
but the per-symbol `byDepth` items don't say which processes each caller
participates in. Consumers planning a deploy want to know if a given
caller is hit by a daily cron, a webhook, or a user-facing route - each
is a different deploy-risk profile - and that information requires a
follow-up cypher query per symbol today.
This change attaches `processes: [...]` to every `byDepth[depth][i]`
item, listing the processes that symbol participates in:
byDepth: {
"1": [
{
depth: 1,
id: "Function:src/foo.ts:doStuff",
name: "doStuff",
...
processes: [
{ id: "proc:cron_daily", label: "Daily cron",
processType: "cron", step: 12 }
]
}
]
}
The list is empty for symbols not in any process. Additive change, no
breaking modifications to existing fields.
Implementation:
- A second chunked Cypher pass runs after the existing per-process
aggregation pass, returning per-(symbol, process) rows. Same chunk
size and MAX_CHUNKS as the aggregation pass, so worst-case adds 10
extra round-trips bounded by the same env var.
- The enrichment pass is skipped entirely when `affectedProcesses.length
=== 0` (nothing to enrich) or `summaryOnly === true` (byDepth not
returned anyway).
- The aggregation query is unchanged - the new query has a distinct
RETURN shape (`RETURN s.id AS sid, ...`) so an existing unit test that
counts STEP_IN_PROCESS chunks was narrowed to match only the
aggregation pattern.
Tests:
- New: byDepth items always have a `processes` field (default empty
when no STEP_IN_PROCESS edges exist).
- New: when STEP_IN_PROCESS rows exist, the matching byDepth item
carries the right `{id, label, processType, step}` entry.
- Updated: impact-batching-grouping test mock narrowed to count only
aggregation chunks (the new per-symbol pass is covered separately).
* style: apply prettier to gitnexus/src/mcp/local/local-backend.ts
Pure line-wrap fix flagged by quality / format CI on PR #1867. Zero
semantic change: prettier broke a chained .slice().map() across three
lines instead of one. No test changes, no logic changes.
* fix(impact): address PR review findings on per-symbol process enrichment
- byDepth.processes doc now states each item carries processes (Finding 1)
- move per-symbol STEP_IN_PROCESS enrichment post-pagination so symbols
beyond the pre-pagination cap no longer get false-empty processes:[]
(Finding 2); hoist CHUNK_SIZE/MAX_CHUNKS to function scope so the
post-pagination pass can reference them
- dedup per-symbol query with DISTINCT + MIN(r.step) per (symbol,process)
pair (Finding 3)
- suppress the per-symbol pass under summaryOnly, incl. impactByUid group
fan-out, plus a test asserting the query never fires (Findings 4, 6)
* fix(impact): address second-round review findings A-E
Finding A (blocker): impactByUid passed summaryOnly:true, which drops the
entire byDepth field. cross-impact.ts reads fan.byDepth to build the group
by_depth output, so cross-repo by_depth was always {}. Replace with a new
skipPerSymbolEnrichment option on _runImpactBFS that suppresses only the
per-symbol STEP_IN_PROCESS pass while preserving byDepth.
Finding B+D (blocker): rewrite the byDepth.processes tool description. Drop
the stale "enrichment cap" wording (no longer true post-pagination), document
the {id,label,processType,step} entry shape, and tell agents to cross-check
affected_processes when partial:true.
Finding C: bound the post-pagination per-symbol enrichment loop to
MAX_CHUNKS*CHUNK_SIZE page IDs and surface partial:true when capped, so a
large page cannot trigger unbounded DB round-trips (DoD 2.6).
Finding E: add a test exercising the real impactByUid -> _runImpactBFS path
asserting byDepth survives and the per-symbol query never fires.
---------
Co-authored-by: scotjelinski <58397194+scotjelinski@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
README still described the old "first-run copy" behavior. After the
post-create.sh change to always-sync-from-host, the design works either
direction:
- Log in on host → next container-create syncs the credentials into the
named volume.
- Log in inside the container → the named volume persists the login across
rebuilds; the host has no source to overwrite from, so it stays alone.
Also documents the two-Claude-state-files trap (`$HOME/.claude.json` AND
`$CLAUDE_CONFIG_DIR/.claude.json`, both with the same userID required), and
the volume-deletion recovery path for stale named volumes carried over
from earlier rebuilds.
Two bugs were causing Claude Code to fire the onboarding wizard inside the
container even with valid host credentials:
1. Missing the second state file. Claude Code 2.1.x writes a small `.claude.json`
INSIDE `CLAUDE_CONFIG_DIR` (carrying migration tracking + userID), not just the
one at `$HOME/.claude.json`. If the userIDs in the two files disagree, Claude
treats the session as inconsistent and re-onboards. The previous post-create.sh
only copied the `$HOME` one.
2. First-run guards (`[ ! -e $dst ]`) skipped the copy when stale named volumes
from earlier rebuilds still had the prior session's state in them, leaving the
container desynced from the host.
Replace `copy_on_first_run` with `sync_from_host` that always overwrites from
host on container-create. `link_readonly_share` now clears stale non-symlink dst
entries before linking. Copies both `$HOME/.claude.json` and
`$CLAUDE_CONFIG_DIR/.claude.json` so userIDs stay aligned. Container can still
mutate its own state between rebuilds; resync only happens on rebuild
(postCreate boundary).
Restructure the Claude Code / Codex / Cursor mount topology to fix the
silent first-run-UI bug surfaced in PR testing, and to harden against
the host-write-through escape class the previous bind-mount design
exposed.
The actual root cause of the first-run wizard firing on the user's
screenshot — confirmed via three parallel research agents (best
practices, framework docs deep dive of the OpenAI Codex Rust source,
adversarial design review) — was NOT a credential permission check.
Claude Code splits state across `~/.claude/.credentials.json` AND
`~/.claude.json` (a FILE at $HOME, sibling of the `.claude/` dir).
The latter holds `hasCompletedOnboarding`, `userID`, `oauthAccount`
metadata, MCP user-scope config, and per-project trust state — and
Claude Code reads it at literal `$HOME/.claude.json`, not via
`CLAUDE_CONFIG_DIR`. The previous design mounted `~/.claude/` but
left `~/.claude.json` outside the topology entirely, so every container
started with a missing onboarding-state file and re-ran the wizard.
Confirmed by tfvchow/field-notes-public#10:
"Persisting .credentials.json alone is NOT sufficient. Without
.claude.json, Claude Code treats the session as a fresh install and
prompts for login regardless of valid credentials being present."
The new topology:
**Mounts**
- `${localEnv:HOME}/.claude` → `/host/.claude` (read-only bind)
- `${localEnv:HOME}/.codex` → `/host/.codex` (read-only bind)
- `${localEnv:HOME}/.cursor` → `/host/.cursor` (read-only bind)
- `${localEnv:HOME}/.claude.json` → `/host/.claude.json` (read-only bind)
- `claude-config-${devcontainerId}` → `/home/node/.claude` (named volume)
- `codex-config-${devcontainerId}` → `/home/node/.codex` (named volume)
- `cursor-config-${devcontainerId}` → `/home/node/.cursor` (named volume)
**containerEnv** gains `CODEX_HOME=/home/node/.codex` (Codex's own env
override, per its public Rust source). `CLAUDE_CONFIG_DIR=/home/node/
.claude` was already set.
**`post-create.sh`** stages the named volumes on first run:
- Symlinks shareable subdirs from `/host/.claude` into the named volume:
`plugins/`, `skills/`, `agents/`, `memory/`, `commands/`. Codex gets
`config.toml` symlinked. Cursor has no shareable subdirs (cli-config
.json conflates auth and settings).
- Copies `.credentials.json`, `auth.json`, `cli-config.json` on first
run with `chmod 600`. After first run, container manages its own
refresh; host's credentials untouched.
- Copies `~/.claude.json` on first run (with stub
`{"hasCompletedOnboarding":true,"installMethod":"global"}` fallback
for hosts that haven't run Claude Code). This is the fix for the
observed onboarding-wizard loop.
`ensure-host-config-dirs.cjs` now also touches `~/.claude.json` on the
host if missing, so the bind mount has a valid source on hosts that
have never run Claude Code.
**Why read-only + named volume vs. the previous full bidirectional
bind mount:**
1. **Host filesystem write-through escape, eliminated.** Previous
design symlinked `plugins/`, `agents/`, `skills/` write-through
into the host's `~/.claude/` — a malicious npm package in the
workspace dep tree could drop `agents/evil.md` into the host's
config, which the next host Claude session would auto-load. The
read-only `/host` mount blocks this; container compromise no
longer persists across teardown via host-side autoload.
2. **Windows bind-mount perm-flattening, sidestepped.** Files
surfaced through a Docker Desktop Windows bind mount appear as
`root:root` mode `777`. Credentials in the named volume come with
proper Linux ownership and `chmod 600` — what each CLI expects on
write (none enforces on read, but write-side hygiene matters for
the host's understanding of "where credentials live").
3. **No `ide/` lock-file collisions.** Previous design symlinked
`~/.claude/ide/` write-through, including per-PID lock files. Host
PID and container PID namespaces are unrelated → lock-file PIDs
misclassify dead processes as alive. Skipping `ide/` keeps lock
files container-local.
4. **No `projects/` ghost dirs.** Host encodes the workspace path as
`D--development-coding-GitNexus`, container as `-workspace`.
Bidirectional `projects/` symlinks would split memory and session
state across two ghost project dirs for what is conceptually the
same project. Skipping `projects/` keeps per-project state
container-local; host's projects/ stays untouched.
5. **No `settings.json` version drift.** Container is pinned to a
specific Claude Code version (`CLAUDE_CODE_VERSION` build arg);
host floats with auto-update. Bidirectional `settings.json` writes
produced silent schema rollback. Skipping settings.json keeps each
side authoritative for its own version.
**README** rewritten in the same section to describe the new topology
honestly: what's shared, what isn't, the OAuth refresh-token
divergence between host and container, per-CLI quirks (macOS Keychain
storage, Cursor's known upstream in-container auth bug, Codex
keyring storage). Trust-boundary section updated to name the threat
model accurately — same read surface as before (malicious dep can
still READ all credentials), but write-through into host plugin/agent
dirs is now blocked.
Verified locally: `@devcontainers/cli read-configuration` resolves all
19 mounts correctly on Windows, `post-create.sh` parses, and
`ensure-host-config-dirs.cjs` idempotently touches `~/.claude.json`.
Research backing this design:
- Anthropic Claude Code devcontainer docs (named-volume pattern):
https://code.claude.com/docs/en/devcontainer
- tfvchow/field-notes-public#10 (both files required):
https://github.com/tfvchow/field-notes-public/issues/10
- anthropics/claude-code#29029 (VS Code extension strips
hasCompletedOnboarding):
https://github.com/anthropics/claude-code/issues/29029
- OpenAI Codex Rust source (no read-side perm check):
https://github.com/openai/codex/blob/main/codex-rs/login/src/auth/storage.rs
- Cursor CLI in-Docker auth issue:
https://forum.cursor.com/t/cursor-agent-authentication-issue-inside-docker/143995
Extend the host bind-mount surface so coding agents inside the container
inherit cloud + container-registry auth from the host without any
per-container setup:
- ~/.docker (read-write) — Docker registry auth (config.json) + buildx
config. Container-registry pushes (ghcr.io, docker.io) from inside the
container pick up host `docker login` state. Read-write because the
Docker CLI refreshes credential-helper tokens.
- ~/.aws (read-only) — AWS CLI / SDK credentials. Read-only because
rotating creds typically happens via the host. Empty on this dev box,
so forward-compatible: the moment you `aws configure` on the host the
container picks it up on the next rebuild.
- ~/.azure (read-only) — Azure CLI credentials. Same pattern as ~/.aws.
`ensure-host-config-dirs.cjs` extends to mkdir these three on init so
the bind mounts always have a valid source even if a CLI has never been
used on this host.
The Docker CLI itself isn't installed in the container by default — the
~/.docker/ mount is inert until you add `docker-outside-of-docker:1` or
similar Feature. README now calls this out under "What you still don't
have inside the container" so it's obvious which CLIs are agent-ready
and which need a feature add to become useful.
README updates:
- Bind-mount table gains a "Why" column and rows for the three new
mounts, making it clear at a glance what each one enables.
- Trust-boundary section lists Docker registry tokens, AWS, and Azure
creds in the read-side exfil path so the threat model stays honest as
the credential surface grows.
- New subsection lists not-included CLIs (Docker, AWS, Azure, gcloud,
kubectl, private-npm) with the exact Feature ID or mount snippet
needed to enable each — turns "I want my agent to do X" into a
one-line config change.
Verified locally: `npx @devcontainers/cli read-configuration` resolves
all 9 host bind mounts to valid C:\Users\<name>/* paths on Windows.
VS Code's Dev Containers extension auto-copies the host's gitconfig into
the container at attach time using `(dd ...) >> /home/node/.gitconfig`.
A read-only bind mount of ~/.gitconfig blocks that write, so attach
failed with `cannot create /home/node/.gitconfig: Read-only file system`.
Making it read-write would let the append succeed, but the bind mount
means the host file and the container file are the same file — VS Code's
append would double the host gitconfig contents on every container
start.
Drop the ~/.gitconfig bind mount entirely. VS Code's auto-copy is the
purpose-built mechanism for this, gives the container the host's
user.name / user.email transparently, and avoids both the read-only
write failure and the append-duplication trap. The container ends up
with a writable /home/node/.gitconfig that's a copy of the host's, not
a mount.
The remaining six bind mounts (.claude, .codex, .cursor, .ssh, .config/git,
.config/gh) keep their existing modes — XDG-style git config under
~/.config/git is unaffected by VS Code's auto-copy (which only targets
~/.gitconfig), so its read-only bind mount stays.
Also remove the `.gitconfig` touch from ensure-host-config-dirs.cjs
(now unnecessary) and update the README CLI-state table, sharing
explanation, and troubleshooting row to reflect that gitconfig flows
in via VS Code auto-copy rather than the bind mount.
Reverses the "WSL2 required on Windows" posture. Windows-native now
works after a one-time auto-handled setup.
The root cause of the bind-mount failure: VS Code resolves
`${localEnv:HOME}` by reading its own process env, and Windows doesn't
set `HOME` by default — Windows uses `USERPROFILE`. So the bind sources
were collapsing to `/.claude`, `/.codex`, etc., and Docker rejected them.
`ensure-host-config-dirs.cjs` now handles this automatically on Windows
hosts where `HOME` is unset:
1. Runs `setx HOME "%USERPROFILE%"`, which writes to the user-level
Windows environment (HKCU\Environment) — no admin required. Every
future user process inherits HOME from there.
2. Prints a clear one-time setup banner explaining the user needs to
fully restart VS Code (File > Exit, not just close the window) for
VS Code to pick up the new env at its next startup.
3. Exits 1 so VS Code surfaces this as a clean container-create failure
instead of letting Docker error opaquely later.
On the second Reopen-in-Container attempt, `HOME` is now set in VS
Code's env, the script skips the setup block, creates the bind-mount
source dirs, and the container builds normally. Subsequent rebuilds
have no extra steps.
Mac, Linux, and WSL2 hosts have `HOME` set by the shell, so the new
block is a no-op there. Same `devcontainer.json` works across all
supported hosts.
README rewritten to reflect the new posture:
- Header lists Windows 11 (native) as a supported host alongside macOS,
Linux, and WSL2, with a note that Windows-native gets a one-time
HOME setup handled by the initializeCommand.
- New "Windows 11 setup" section walks through the auto-handled setup
flow + a manual `setx HOME "%USERPROFILE%"` fallback for users who
want to do it themselves.
- "Known trade-offs of Windows-native vs WSL2" subsection lays out the
Docker Desktop Windows bind-mount edge cases (file watchers, npm
install perf, husky/_ EPERM) so users opting into Windows-native do
so eyes-open. WSL2 remains documented as the faster path for users
who want it, but it's no longer the only supported one.
- Troubleshooting table gets two new rows: the one-time setup banner
(with "what to do" instructions) and the residual `bind source path
does not exist` case (run setx manually + fully exit VS Code).
The previous commit's "Windows-native works" softening was wrong. VS Code
on Windows-native resolves `${localEnv:HOME}` by reading the host shell's
HOME env var, and cmd.exe has no HOME set — the bind sources collapse to
`/.claude`, `/.codex`, etc., and Docker errors:
Error response from daemon: invalid mount config for type "bind":
bind source path does not exist: /.claude
The @devcontainers/cli output that prompted the softening was misleading
because I ran it from a Bash session with HOME already set, not from VS
Code's cmd.exe call context. The original Finding-1 P0 — that Windows-
native silently breaks the bind-mount feature — was correct.
Three changes:
1. `ensure-host-config-dirs.cjs` detects the failure mode early:
`if (process.platform === 'win32' && !process.env.HOME)` prints a
targeted error message naming the root cause (cmd.exe has no HOME →
${localEnv:HOME} resolves empty → bind sources fail) and a step-by-step
pointer to set up WSL2. Exits 1 so VS Code surfaces it as a clean
container-creation failure, not the cryptic Docker bind-mount error.
2. README header reverted to "Windows 11 via WSL2" only (not "and
Windows-native"). The "Windows 11 — WSL2 is required" section names
the specific HOME-resolution mismatch concretely so future readers
understand why the constraint exists.
3. Troubleshooting table gets a new row for the `ERROR: GitNexus
devcontainer requires WSL2` message pointing at the setup section.
Two fixes bundled:
1. The previous commit's OS-keyed `initializeCommand` object was based
on a misread of the Dev Containers spec. The object form on command
properties is **named parallel tasks**, not OS dispatch — VS Code ran
all three keys in parallel via cmd.exe on Windows, the POSIX branches
failed, and container creation aborted before Docker was invoked.
Restore the single-string Node-based form:
`node .devcontainer/ensure-host-config-dirs.cjs`. Node works
identically in cmd.exe on Windows and bash/zsh on Linux/macOS/WSL,
and `os.homedir()` respects $HOME on POSIX and %USERPROFILE% on
Windows. The script is idempotent (mkdirSync recursive is a no-op
for existing dirs; touch is gated on .gitconfig existence).
Document Node ≥18 on the host as the only host-side prerequisite
beyond Docker Desktop and the VS Code Dev Containers extension.
Anyone running Claude Code on the host already has it.
2. Extend the host-bind mount surface with `~/.ssh` and `~/.config/git`,
both read-only:
- `~/.ssh` lets commit signing + push over SSH remotes work inside
the container without copying private keys. Read-only mount means
container code can read keys but can't modify or delete them.
(Threat: a malicious dep can still read private keys from inside
the container; the read-only mount narrows write-side blast
radius, not read-side. Documented in the trust-boundary section.)
- `~/.config/git` covers XDG-style git config (`~/.config/git/config`,
`~/.config/git/ignore`, `~/.config/git/attributes`) for users who
keep settings there instead of `~/.gitconfig`. Read-only, same as
`~/.gitconfig`.
Update the CLI-state-sharing table and trust-boundary paragraph to
reflect the expanded surface.
Re-adds .devcontainer/ensure-host-config-dirs.cjs (deleted before the
OS-keyed attempt).
The previous commit's `initializeCommand` was POSIX-only (`mkdir -p $HOME/...`).
VS Code on Windows runs the host shell as `cmd.exe /c ...`, which can't
parse POSIX syntax — `$HOME` doesn't expand, `mkdir -p` errors, the init
fails with `The syntax of the command is incorrect`, and container
creation aborts before Docker is invoked.
Switch `initializeCommand` to the spec's OS-keyed object form:
- linux/darwin (covers WSL2 because VS Code runs initializeCommand in
the WSL shell when attached via the WSL extension): POSIX mkdir+touch,
as before
- win32: PowerShell snippet that creates the same directories under
$USERPROFILE and touches the gitconfig if missing
Soften the README's hard "WSL2 required" framing from the previous
commit. Reality per `@devcontainers/cli read-configuration` output:
`${localEnv:HOME}` on Windows-native resolves to `C:\Users\<name>`
(VS Code falls back to USERPROFILE), so the bind mount sources are
valid Windows paths and Docker Desktop handles the translation. The
earlier `accessing specified distro mount service` failure was a
separate Docker Desktop WSL-integration issue, not a HOME-resolution
issue. Windows-native works; it's just slower with more bind-mount
permission edge cases (the husky/_/h EPERM class). The README now
explains the tradeoff and steers toward WSL2 for performance + file
watchers + permission reliability, rather than blocking Windows-native
checkouts outright.
Update the troubleshooting row to reflect the new posture.
Walkthrough resolution of the 16-finding ce-code-review on PR #1875. 15 of
16 findings applied; one (F12, Anthropic Feature floating tag) was
superseded by F6's Feature removal.
P0
- F1: WSL2 is now REQUIRED for Windows hosts, not just recommended.
${localEnv:HOME} resolves to empty string on Windows-native (no HOME env
var) — bind mounts then point at /.claude, /.codex etc. and silently
break. ensure-host-config-dirs.cjs wrote to USERPROFILE-derived paths
via os.homedir(), so the two surfaces disagreed about which env var was
"home" on Windows. README header reframed; "Windows 11 — WSL2 is required"
section explains the mismatch concretely.
P1
- F2: Workspace `node_modules` volume names now include `-${devcontainerId}`
so two GitNexus checkouts on the same host (~/work/GitNexus and
~/projects/GitNexus) don't share volumes and corrupt each other's
installs.
- F3 + F5: `postCreateCommand` extracted to `.devcontainer/post-create.sh`
with `set -euo pipefail` and six labeled echo steps so failure logs
name the step instead of an opaque &&-chain index. Chown step extended
to cover /home/node/.npm, /commandhistory, and /home/node/.local — these
named-volume mount points were owned by build-time UID 1000 but the
container's `node` is re-IDed at runtime by updateRemoteUserUID on
non-1000 Linux hosts, leaving them unwritable until now.
- F4: Cursor installer downloaded to a temp file with curl --retry +
--max-time; sha256 logged to build output before execution so drift
across rebuilds is visible in CI logs. Full hard-pin (to a versioned
downloads.cursor.com tarball with verified sha256) tracked as a
follow-up in README "What's not included".
P2
- F6: Anthropic Feature replaced with a direct
`npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}` so
CLAUDE_CODE_VERSION actually pins the installed binary (the Feature
ignored the ARG and pulled latest at install time). Honors the
earlier "pin known-good versions" decision and resolves F12's
floating-tag concern for this Feature.
- F7: Dockerfile ARG defaults dropped for the three version vars;
`devcontainer.json` `build.args` is now the single source of truth.
Standalone `docker build .devcontainer/` must pass --build-arg.
- F8: ensure-host-config-dirs.cjs deleted; `initializeCommand` now uses
POSIX `mkdir -p` + `touch ~/.gitconfig` directly, dropping the
host-Node-on-PATH prerequisite that broke on fresh Windows+Docker
Desktop installs without Node.
- F9: ~/.gitconfig bind-mounted read-only so `git commit` inside the
container uses the host's user.name / user.email. Read-only so
container-side `git config --global` doesn't leak to host.
- F10: ~/.config/gh bind-mounted (read-write) so `gh pr create` /
`gh pr checks` / `gh issue create` work inside the container without
re-auth. AGENTS.md's commit + PR workflow now fully functional for
agents inside the container.
- F11: CLAUDE_CONFIG_DIR removed from Dockerfile ENV; canonical value
lives only in devcontainer.json containerEnv. Eliminates the two-file
edit risk.
- F13: Mounts comment now documents per-instance vs per-workspace-name
scoping rationale so future contributors don't guess.
- F14: README "Trust boundary, concretely" paragraph names the exfil
path explicitly (malicious npm postinstall → OAuth tokens →
~/.claude/projects/<workspace>/memory/MEMORY.md secrets) and lists
vendor-side rotation runbook entries.
P3
- F15: Dockerfile pre-create + chown of /home/node/.claude, .codex,
.cursor dropped — those paths are bind-mounted, which fully shadows
any image-side ownership. Only .npm, .local, /commandhistory still
benefit from the pre-create.
- F16: README "Bumping CLI versions" section rewritten against the
post-F6 reality: CLAUDE_CODE_VERSION and CODEX_VERSION are real
pins; CURSOR_VERSION is informational only.
Verified locally: `docker build .devcontainer/ --build-arg ...` succeeds.
Smoke-tested image: `claude --version` (2.1.153), `codex --version`
(0.134.0), `cursor-agent --version` all resolve as the non-root `node`
user; named-volume mount points (/home/node/.npm, /commandhistory) are
node-owned at build time so non-1000 host UIDs get the post-create.sh
chown fix instead of EACCES.
Switch the credential/config mounts from per-devcontainer named volumes
to bind mounts of `${localEnv:HOME}/.claude`, `~/.codex`, and
`~/.cursor`. Effect inside the container:
- Authentication is shared with the host. If you've already run
`claude login` / `codex login --device-auth` / `cursor-agent login`
on the host, you're already authenticated in the container.
- Plugins, skills, agents, memory, and settings sync both ways. Install
a plugin in the container, it shows up on the host; add a custom
agent on the host, the container sees it immediately.
- All devcontainers on the host share the same CLI state, mirroring
how host shells already share it. (Per-workspace isolation of plugins
was never a stated requirement; the previous per-devcontainer named
volumes leaked nothing useful.)
Add `.devcontainer/ensure-host-config-dirs.cjs` and wire it as
`initializeCommand`. It runs on the host before container create and
guarantees `~/.claude`, `~/.codex`, `~/.cursor` exist, so Docker doesn't
reject the bind mount when a CLI has never been used on this host.
Cross-platform via Node `os.homedir()` + `fs.mkdirSync({recursive: true})`;
idempotent; no third-party deps.
Update `.devcontainer/README.md`:
- New "How CLI state is shared with your host" section explaining the
bind-mount model up front so users know their host plugins/skills/
memory carry into the container.
- Mark first-time-login section as skippable when the user is already
authenticated on the host.
- Note the high-trust escape hatch: replace the three bind mounts with
`type=volume` named volumes if the host/container trust boundary
needs to be separated (Anthropic's reference pattern for enterprise).
- Replace the obsolete "rm named volume" troubleshooting row with one
that covers EACCES/EPERM on the host-bind-mount path.
When `npm install` runs the root `prepare` script (husky), husky tries
to copyfile `node_modules/husky/husky` → `.husky/_/h`. On Docker Desktop
Windows bind mounts, if `.husky/_/` already exists from a prior
container run, the new container's `node` user can't overwrite it via
the bind mount's permission translation and the install fails with:
Error: EPERM: operation not permitted, copyfile
'/workspace/node_modules/husky/husky' -> '.husky/_/h'
Drop `.husky/_` defensively in `postCreateCommand` before `npm install`
so husky always starts from a clean slate. `.husky/_` is a husky
runtime cache (gitignored), so removing it has no effect on the repo —
husky regenerates it. No-op for WSL2-side checkouts (where this class
of bind-mount permission collision doesn't occur).
Add a troubleshooting row to `.devcontainer/README.md` covering the
manual recovery (`rm -rf .husky/_` on the host) and the long-term fix
(clone in WSL2 — Windows-side bind mounts will keep biting on this
kind of issue across rebuilds with different UID alignment).
The previous order (root → gitnexus-shared → gitnexus → gitnexus-web)
broke at the `gitnexus` install step because `gitnexus`'s `prepare`
script runs `scripts/build.js`, which compiles `gitnexus-web` whenever
its source tree exists. In the devcontainer the entire workspace is
bind-mounted, so `gitnexus-web/` is present from the start — but its
`node_modules/` wasn't yet, so `tsc -b` failed with:
error TS2688: Cannot find type definition file for 'vite/client'
error TS2688: Cannot find type definition file for 'node'
Reorder so `gitnexus-web` installs before `gitnexus`. Verified
end-to-end via `npx @devcontainers/cli up`: container builds clean,
all three CLIs (Claude 2.1.153, Codex 0.134.0, Cursor) respond, and
`npx tsc --noEmit` inside `/workspace/gitnexus` passes.
Production Dockerfiles (`Dockerfile.cli` etc.) don't hit this because
they only COPY `gitnexus/` + `gitnexus-shared/`, so `gitnexus-web/`
doesn't exist at install time and `scripts/build.js` skips the web
step. The devcontainer's full-tree bind mount changes that calculus.
The previous `containerEnv` injected `CURSOR_API_KEY: "${localEnv:CURSOR_API_KEY}"`.
When the host had no `CURSOR_API_KEY` set, this resolved to an empty
string and Docker injected `CURSOR_API_KEY=""` into the container.
Cursor CLI treats a set-but-empty `CURSOR_API_KEY` as "use this key"
rather than "fall back to stored login", which silently broke
`cursor-agent login` on the most common path — users who hadn't
explicitly opted into API key auth.
Drop `CURSOR_API_KEY` from `containerEnv`. Login is now the
unconditional default for all three CLIs (Claude Code, Codex CLI,
Cursor CLI); the named-volume + Dockerfile-chown pattern keeps
credentials persistent across container rebuilds for every login path.
Reorganize the README's auth section to put login first for all three
CLIs uniformly (matching the new behavior) and move API key
authentication into a separate "Alternative" section for CI/headless
use. Document that API keys are intentionally not auto-propagated from
the host and explain the export-in-shell or VS Code dotfiles-repo paths
for users who want them. Update the troubleshooting row to reflect the
new design.
Add a Dev Container that pre-installs Claude Code (2.1.153, via Anthropic's
official Feature), OpenAI Codex CLI (pinned 0.134.0), and Cursor CLI alongside
the GitNexus native build chain. Opens via VS Code's Dev Containers extension
on Windows 11 (Docker Desktop + WSL2), macOS, or Linux without OS-specific
branches in devcontainer.json.
Topology and base
- Base image `mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm`
(multi-arch, monthly patched, ships the `node` non-root user, zsh, `gh`).
- Node 22 LTS satisfies `gitnexus/`'s engines `>=22.0.0` and matches the
`node:22-bookworm-slim` SHA-pinned base used by `Dockerfile.cli`.
- Single container with all three CLIs co-installed (vs. docker-compose
per-tool) — prevailing 2026 community pattern, lowest daily-driver friction.
Persistence and auth
- Per-devcontainer named volumes scoped by `${devcontainerId}` for
`/home/node/.claude`, `/home/node/.codex`, `/home/node/.cursor`,
`/commandhistory`, and `/home/node/.npm`. Authentication survives rebuilds
without leaking between workspaces.
- Four sub-workspace `node_modules` volumes (root, gitnexus, gitnexus-web,
gitnexus-shared) keep tree-sitter native bindings and onnxruntime off the
bind mount — the actual Win/Mac perf win.
- Credential mount paths are pre-created in the Dockerfile with
`chown node:node` BEFORE `USER node`, so empty named volumes inherit
correct ownership on first mount and first-run logins don't EACCES.
- `CURSOR_API_KEY` is injected via `containerEnv: ${localEnv:CURSOR_API_KEY}`
(Cursor's documented headless path); falls back to interactive
`cursor-agent login` when the host env var is unset.
Build-arg promotion
- Build args (`CLAUDE_CODE_VERSION`, `CODEX_VERSION`, `CURSOR_VERSION`, `TZ`)
are promoted to ENV in the Dockerfile so lifecycle commands and shells can
resolve them. Without this promotion, Docker ARG values are build-only and
silently no-op at lifecycle time.
Workspace setup
- `postCreateCommand` chowns the four workspace `node_modules` volumes
(Docker creates them root-owned), then installs in dependency order:
root → gitnexus-shared (install + build) → gitnexus → gitnexus-web. The
shared package must build before its consumers (`file:../gitnexus-shared`).
Ports
- 5173 (Vite dev) and 4173 (Vite preview) auto-forwarded.
- 4747 (`gitnexus serve`) marked `requireLocalPort: true` because
`gitnexus-web/src/services/backend-client.ts` hardcodes
`http://localhost:4747` as the default backend URL; a remapped port would
silently break the web UI.
VS Code integration
- Recommended extensions: `anthropic.claude-code`,
`dbaeumer.vscode-eslint`, `esbenp.prettier-vscode`, `eamodio.gitlens`.
- Settings: format-on-save with Prettier, ESLint auto-fix on save, zsh as
default terminal profile, persistent zsh history via `HISTFILE` →
`/commandhistory`.
Documentation
- `.devcontainer/README.md` covers WSL2 setup (clone inside WSL2 for IO and
file-watcher reliability), first-time auth flows for each CLI, port-
forwarding notes, LadybugDB container limitations, and the bumping
procedure for each CLI version.
- `CONTRIBUTING.md` gets a "Containerized development (optional)"
subsection pointing at the devcontainer README.
Deferred to a follow-up PR
- Opt-in egress firewall (originally planned as a fourth implementation
unit). The Dev Containers spec makes `runArgs` static — toggling
`NET_ADMIN`/`NET_RAW` capabilities cleanly requires either a separate
`devcontainer-firewall.json` profile or an `initializeCommand`-generated
overlay. Keeping this PR focused on the working baseline.
- Codespaces-specific tuning (works incidentally when the firewall is off,
not actively tested).
- Inside-container Playwright e2e (needs Chromium libs not in the base
image).
Verification deferred to user
- This change introduces a new dev tooling artifact. Validate by running
`docker build .devcontainer/`, opening the repo in VS Code via
"Dev Containers: Reopen in Container", confirming `claude --version`,
`codex --version`, `cursor-agent --version` resolve inside the container,
and `cd gitnexus && npm run test:unit` runs clean against the
named-volume `node_modules`.
Append explicit `*.sh text eol=lf` and `*.bash text eol=lf` rules so
shell scripts (notably anything COPYed into a Linux container) check out
with LF endings on Windows hosts with `core.autocrlf=true`, regardless
of the auto-detection on the existing `* text=auto eol=lf` line. Add
binary markers for `*.node`, `*.wasm`, `*.onnx`, `*.so`, `*.dll`,
`*.dylib` so native and ML model artifacts aren't ever subjected to text
normalization.
The existing `* text=auto eol=lf` and `.husky/* text eol=lf` rules are
preserved. `git ls-files --eol` confirmed zero CRLF or mixed blobs in
the index, so no `--renormalize` was needed.
* feat(group): add Kotlin Spring HTTP consumer extraction
Follow-up to #1849 (Kotlin providers). Extends `http-patterns/kotlin.ts`
with three call-site patterns common in Kotlin Spring projects:
- RestTemplate: `restTemplate.getForObject("/x", ...)` and the
full verb family (getForObject/getForEntity → GET,
postForObject/postForEntity → POST, put → PUT, delete → DELETE,
patchForObject → PATCH). Mirrors the Java plugin's
`REST_TEMPLATE_TO_HTTP` map so polyglot repos coalesce on a
single contract id.
- WebClient short form: `webClient.get().uri("/x")` and the
`.post()` / `.put()` / `.delete()` / `.patch()` siblings. The
chain parses as two nested `call_expression` nodes; the query
anchors on the outer `.uri(...)` and walks one level inward
to constrain the verb.
- OkHttp: `Request.Builder().url("/x")`. Kotlin parses
`Request.Builder()` as a `call_expression` whose callee is a
`navigation_expression` (not Java's `object_creation_expression`),
so the query shape differs from `java.ts` but the receiver/method
constraints (`Request` / `Builder` / `url`) and emitted
contract format match.
Out of scope: `webClient.method(HttpMethod.X).uri("/y")` long form.
The verb sits on a sibling `call_expression` two hops away, so it
needs a walk-up helper rather than a flat tree-sitter query. A
dedicated anti-overreach test pins the current behavior so a future
short-form change can't accidentally start matching the long form.
Receiver name constraints (`#eq? @obj "restTemplate"`,
`#eq? @cls "Request"`) match the Java plugin's heuristic — a project
that aliases the receiver under a different name won't be picked up.
This trade-off keeps false-positive rates low and is documented in
the file header.
Tests: 5 new cases under `consumer extraction — fetch patterns`,
gated by tree-sitter-kotlin grammar availability.
positive (3)
- RestTemplate verbs (5 calls × 5 verbs)
- WebClient short-form verbs (5 calls × 5 verbs)
- OkHttp Request.Builder().url("/x")
anti-regression (2)
- WebClient long form `.method(HttpMethod.X)` produces no
consumer (deferred-feature pin)
- non-restTemplate receiver does not match (receiver-name pin)
Reverse-validated: removing the `(#eq? @obj "restTemplate")`
constraint causes the receiver-name anti-regression test to fail.
Local validation:
- test/unit/group/http-route-extractor.test.ts: 59/59 ✅
- test/unit/group: 539/539 ✅
- npm run format:check: clean ✅
* test(group): pin Kotlin OkHttp POST-chain heuristic-default GET behavior
Address Claude review on PR #1855 (Finding 1).
The OkHttp query in `kotlin.ts:OK_HTTP_PATTERNS` matches the
`.url("/x")` sub-expression of a builder chain, but the verb is
encoded on a separate sibling call (`.post(body)` / `.delete()` /
...). The query intentionally does not walk the chain to recover
the verb — it emits `method: 'GET'` for every match, mirroring the
Java plugin's `OK_HTTP_PATTERNS` (java.ts).
Concretely: `Request.Builder().url("/x").post(body).build()` becomes
`http::GET::/x`, not `http::POST::/x`. This is an already-accepted
Java parity heuristic, but it was untested on the Kotlin side.
This commit:
- Adds an anti-overreach test pinning the current behavior:
* exactly one consumer is emitted with method=GET
* no second http::POST::/x consumer appears
- Documents the limitation in kotlin.ts as a "Known limitation"
block tied to the test, so a future verb-walk implementation
has to update the comment in lockstep with the assertion.
Rationale for not implementing verb-walk in this PR:
- Verb-walk requires walking sibling call_expression nodes (the
`.post(body)` chain), which is the same shape as the
deferred WebClient long-form work
- Java has the same limitation in production today; fixing only
Kotlin would create polyglot drift
- A coordinated future PR can add verb-walk to both plugins at
once and update both comments + the pin tests together
Finding 2 (silent test-skip when tree-sitter-kotlin grammar is
unavailable) is intentionally NOT addressed here — same gating
pattern was accepted in #1849 for Provider tests, and a coordinated
follow-up should add a CI sentinel covering both Provider and
Consumer suites in one place.
Local validation:
- test/unit/group/http-route-extractor.test.ts: 60/60 ✅
- test/unit/group: 540/540 ✅
- npm run format:check: clean ✅
---------
Co-authored-by: henry <zhangwei2017@unipus.cn>
* feat(group): add Kotlin Spring HTTP route extraction (named + positional)
Mirror the Java Spring named-argument fix for Kotlin Spring Boot
controllers. Adds a new `http-patterns/kotlin.ts` plugin behind the
optional `tree-sitter-kotlin` grammar, registered for `.kt`/`.kts`.
Both annotation forms produce providers:
@RequestMapping("/api") / @GetMapping("/users")
@RequestMapping(path = "/api") / @GetMapping(value = "/users")
@RequestMapping(value = "/api") / @GetMapping(path = "/users")
The Kotlin AST (fwcd/tree-sitter-kotlin) shares one node type
(`value_argument`) for positional and named forms, so the queries
are split:
- positional: anchors `string_literal` as the first named child
of `value_argument` via the immediate-child anchor `.`
- named: explicitly captures `simple_identifier` and constrains
it to `^(path|value)$` via `#match?`, mirroring the same
safety bar enforced by `http-patterns/java.ts` and
`topic-patterns/java.ts`. Without this constraint the query
would also capture non-route attributes like `produces`,
`consumes`, `headers`, `name`, `params`.
`tree-sitter-kotlin` is an optionalDependency (parser-loader.ts,
parse-worker.ts pattern). When the native binding is unavailable
the plugin exports `null` and `index.ts` skips registering
`.kt`/`.kts` so the orchestrator stays healthy.
Scope: providers only. Consumer detection (RestTemplate, WebClient,
OkHttp) on Kotlin call-site ASTs differs enough from Java's
`method_invocation` shape to warrant a separate, focused PR.
Tests: 11 new cases under `provider extraction — source-scan
fallback (Strategy B)`, gated by the kotlin grammar availability.
positive (8)
- class @RequestMapping("/api/v1") (positional)
- class @RequestMapping(path = "/api/v2")
- class @RequestMapping(value = "/orders")
- method @GetMapping(value = "/users")
- method @GetMapping(path = "/users")
- method @PostMapping(path = "/users")
- mixed: class named-arg + method positional
- mixed: class positional + method named-arg
anti-regression (3)
- @GetMapping(produces = "application/json") emits no provider
- @GetMapping(name = "x", value = "/users") emits exactly one provider
- @RequestMapping(path = "/api", name = "myApi") prefix stays /api
Reverse-validated: removing the `(#match? @key "^(path|value)$")`
constraint causes precisely the 3 anti-regression tests to fail.
Local validation:
- test/unit/group/http-route-extractor.test.ts: 54/54
- test/unit/group: 534/534
- npx tsc --noEmit: clean (modulo the pre-existing TS2339 in
user-defined-conversions.ts merged from main, unrelated)
* style(test): apply prettier line wrapping to long itKotlin titles
---------
Co-authored-by: henry <zhangwei2017@unipus.cn>
* fix(group): handle named annotation args in Java Spring route extraction
The Java HTTP plugin only matched positional `@RequestMapping("/path")`
syntax for class-level prefixes and method-level routes. Named argument
forms (`path = "/path"` and `value = "/path"`) produce an
`element_value_pair` AST node that the tree-sitter queries did not cover,
causing the class prefix to be lost and named-arg method routes to be
missed entirely during cross-repo contract extraction.
Add a second pattern to both SPRING_CLASS_PREFIX_PATTERNS and
SPRING_METHOD_ROUTE_PATTERNS matching the element_value_pair structure.
* fix(group): constrain Spring named-arg query to path/value keys + add regression tests
Address Claude review on PR #1834. The named-argument patterns added
in 8b6fa6e used `value: (string_literal)` (a tree-sitter field
selector for the right-hand side of element_value_pair), which matched
ANY annotation member with a string value — not just `path`/`value`.
Concrete fallout (without this fix):
@GetMapping(produces = "application/json") → bogus http::GET::/application/json
@GetMapping(name = "listUsers", value = "/users") → extra http::GET::/listUsers
@RequestMapping(headers = "X-Foo=bar", path = "/api") → class prefix
could be set to "X-Foo=bar" because prefixByClassId.set runs per
match in document order, so the LAST element_value_pair wins.
The sibling topic-patterns/java.ts already demonstrates the correct
shape: constrain the `key:` field to the route member names.
This commit:
- Adds `key: (identifier) @key (#match? @key "^(path|value)$")` to
both SPRING_CLASS_PREFIX_PATTERNS and SPRING_METHOD_ROUTE_PATTERNS
named-arg queries.
- Adds 9 regression tests under
`provider extraction — source-scan fallback (Strategy B)`:
* @RequestMapping(path = "/api/v3") class prefix
* @RequestMapping(value = "/orders") class prefix
* @GetMapping(value = "/users") method route
* @PostMapping(path = "/users") method route
* mixed: class named-arg + method positional
* mixed: class positional + method named-arg
* @GetMapping(produces = "application/json") → no provider emitted
* @GetMapping(name = "listUsers", value = "/users") → exactly one
provider with path "/users", no /listUsers route
* @RequestMapping(path = "/api", name = "myApi") → prefix is /api,
not myApi (verifies the class-prefix overwrite scenario)
Tests: 42/42 pass in http-route-extractor.test.ts;
522/522 pass under test/unit/group;
npx tsc --noEmit clean.
* test(group): add @GetMapping(path = ...) case to match review checklist verbatim
Claude review on PR #1834 explicitly asked for the method-level
`@GetMapping(path = "/users")` case. The previous commit covered it
indirectly by exercising path= on @PostMapping (the Spring method
annotations share the same query, so any verb proves the path= field
is matched). Add a dedicated GET+path= test so the reviewer's
checklist is satisfied 1:1, and keep the POST+path= case as a bonus
verb-coverage test.
Tests: 43/43 pass in http-route-extractor.test.ts.
---------
Co-authored-by: henry <zhangwei2017@unipus.cn>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>