Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .github/scripts/test-bootstrap-state.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
#
# Unit tests for strip_host_hooks() in the devcontainer bootstrap script.
#
# Sources the template (made importable by its exec-guard) and exercises the
# hook-stripping logic on fixture settings.json blobs: gryph/cot commands are
# removed, rtk + user hooks are preserved, emptied events are pruned, the
# transform is idempotent, and malformed/missing input doesn't crash.
#
# Run: .github/scripts/test-bootstrap-state.sh
# CI: wired into .github/workflows/shellcheck.yml
set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$here/../.." && pwd)"
tmpl="$repo_root/templates/devcontainer/scripts/bootstrap-state.sh.tmpl"

# Source the prod template. Its exec-guard stops the init/sync dispatch from
# firing; we only want the strip_host_hooks() definition.
# shellcheck source=../../../templates/devcontainer/scripts/bootstrap-state.sh.tmpl
# shellcheck disable=SC1090
source "$tmpl"

pass=0
fail=0
assert_eq() { # <label> <expected> <actual>
if [[ "$2" == "$3" ]]; then
printf ' ok %s\n' "$1"; pass=$((pass + 1))
else
printf ' FAIL %s\n expected: %s\n actual: %s\n' "$1" "$2" "$3" >&2
fail=$((fail + 1))
fi
}

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

# --- Case 1: mixed PreToolUse keeps rtk, drops gryph; cot/gryph events pruned
f1="$tmp/c1.json"
cat >"$f1" <<'JSON'
{
"model": "opus",
"hooks": {
"PreToolUse": [
{"matcher": "*", "hooks": [{"type": "command", "command": "gryph _hook claude-code PreToolUse"}]},
{"matcher": "Bash", "hooks": [{"type": "command", "command": "rtk hook claude"}]}
],
"UserPromptSubmit": [
{"hooks": [{"type": "command", "command": "/Users/ion1/.cot/bin/cot hook claude"}]}
],
"Stop": [
{"hooks": [{"type": "command", "command": "cot hook claude"}]}
],
"Notification": [
{"hooks": [{"type": "command", "command": "gryph _hook claude-code Notification"}]}
]
}
}
JSON
strip_host_hooks "$f1"
assert_eq "only PreToolUse event survives" '["PreToolUse"]' "$(jq -c '.hooks | keys' "$f1")"
assert_eq "PreToolUse keeps rtk entry only" \
'[{"matcher":"Bash","hooks":[{"type":"command","command":"rtk hook claude"}]}]' \
"$(jq -c '.hooks.PreToolUse' "$f1")"
assert_eq "non-hook keys preserved" 'opus' "$(jq -r '.model' "$f1")"

# --- Case 2: a user hook next to a host hook in the same entry is preserved --
f2="$tmp/c2.json"
cat >"$f2" <<'JSON'
{
"hooks": {
"PreToolUse": [
{"matcher": "*", "hooks": [
{"type": "command", "command": "gryph _hook claude-code PreToolUse"},
{"type": "command", "command": "/usr/local/bin/myfmt --check"}
]}
]
}
}
JSON
strip_host_hooks "$f2"
assert_eq "user hook preserved" '/usr/local/bin/myfmt --check' \
"$(jq -r '.hooks.PreToolUse[0].hooks[0].command' "$f2")"

# --- Case 3: idempotent — a second pass is a byte-for-byte no-op -------------
strip_host_hooks "$f1"
cp "$f1" "$tmp/c1.once"
strip_host_hooks "$f1"
if cmp -s "$tmp/c1.once" "$f1"; then
assert_eq "idempotent on re-run" "same" "same"
else
assert_eq "idempotent on re-run" "same" "different"
fi

# --- Case 4: nothing to strip -> file bytes unchanged -----------------------
f4="$tmp/c4.json"
printf '{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"rtk hook claude"}]}]}}' >"$f4"
before="$(cat "$f4")"
strip_host_hooks "$f4"
assert_eq "no rewrite when nothing matches" "$before" "$(cat "$f4")"

# --- Case 5: malformed JSON exits 0 and leaves the file untouched -----------
f5="$tmp/c5.json"
printf 'this is not json' >"$f5"
strip_host_hooks "$f5" && rc=0 || rc=$?
assert_eq "malformed JSON exits 0" '0' "$rc"
assert_eq "malformed file untouched" 'this is not json' "$(cat "$f5")"

# --- Case 6: missing file is a no-op (exit 0) -------------------------------
strip_host_hooks "$tmp/does-not-exist.json" && rc=0 || rc=$?
assert_eq "missing file exits 0" '0' "$rc"

echo
echo "passed=$pass failed=$fail"
[[ "$fail" -eq 0 ]] || exit 1
9 changes: 9 additions & 0 deletions .github/workflows/aidc-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -191,13 +191,22 @@ jobs:
.ai-container/project.env \
CLAUDE.md \
AGENTS.md \
CHANGELOG.md \
DETAILED_CHANGELOG.md \
logs/README.md \
.cursor/rules/00-core-logics.mdc; do
[ -f "$work/$f" ] || fail "init did not create $f"
done

grep -q '^AIDC_REPO_SLUG=' "$work/.ai-container/project.env" \
|| fail "project.env missing AIDC_REPO_SLUG"

# Documentation seeds are copy-once: a hand-edit must survive re-init.
echo "EDITED-BY-USER" >> "$work/CHANGELOG.md"
( cd "$work" && aidc init ) || fail "second aidc init failed"
grep -q 'EDITED-BY-USER' "$work/CHANGELOG.md" \
|| fail "re-init clobbered a user edit to CHANGELOG.md"

# Re-running init over an initialized repo must be a clean no-op.
( cd "$work" && aidc init ) || fail "second aidc init was not idempotent"
echo "init scaffold OK"
3 changes: 3 additions & 0 deletions .github/workflows/shellcheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ jobs:

- name: Syntax-check Python clipboard server
run: python3 -m py_compile bin/aidc-clipboard-server

- name: Run bootstrap unit tests
run: .github/scripts/test-bootstrap-state.sh
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ references/
# the source of truth; the generated copies at the repo root are local-only.
/CLAUDE.md
/AGENTS.md

/logs
# Editor / OS noise
.DS_Store
*.swp
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ All notable changes to aidc are tracked here. Format follows [Keep a Changelog](

### Added

- **On-demand Claude OAuth token from the macOS Keychain.** `aidc claude` now reads `CLAUDE_CODE_OAUTH_TOKEN` straight from the Keychain (service `claude-code-oauth-token`, your `$USER` account) when it isn't already in the environment, so the token no longer has to be exported into every shell via `~/.zshrc`. An existing env var still wins (back-compat); override the service name or disable the lookup with `AIDC_CLAUDE_OAUTH_KEYCHAIN_SERVICE` (set empty to disable); no-op on hosts without `security`. Dropping `CLAUDE_CODE_OAUTH_TOKEN` from `AIDC_PASSTHROUGH_ENV_KEYS` (per-project via `.ai-container/project.env`) disables both forwarding and the Keychain lookup for that container. Covered by `tests/resolve-oauth-token.test.sh`.
- **Automatic session sync.** In-container agent transcripts now sync to the host on container start (down→up transition), agent exit, `aidc down`, and `aidc destroy` (synced before `-v` removes the volumes), so `/insights` stays current without a manual `aidc sync-sessions`. The start sync is the recovery path for ungraceful exits (crash / `docker kill`) the on-exit hooks can't cover. Toggle with `AIDC_AUTO_SYNC_SESSIONS` host-wide in `~/.config/aidc/config.env` or per project in `.ai-container/project.env` (per-folder overrides global). The agent's exit code is preserved across the post-run sync.
- **Host-wide config file.** New `~/.config/aidc/config.env` holds universal aidc defaults for every project; `.ai-container/project.env` is sourced after it and overrides per folder. Seeded (fully commented) on first run.
- **`aidc rescan` command.** Re-detects project languages and rebuilds so a repo that started empty (or single-language) picks up the matching toolchains and security scanners once code lands. Prints detected vs. effective (overridden) toolchains before building.
- **`shell` toolchain → shellcheck.** `aidc::detect_toolchains` now detects shell scripts (any `*.sh` file, or an extensionless executable with a shell shebang such as `bin/aidc`) and installs `shellcheck` via a new `shell)` arm in the Dockerfile toolchain block.
- **Project documentation seeds.** `aidc init` now seeds `CHANGELOG.md`, `DETAILED_CHANGELOG.md`, and `logs/README.md` into each project once (never overwritten, and intentionally not git-excluded so they are committed). The scaffolded `CLAUDE.md`/`AGENTS.md` guidance gains **Testing & coverage**, **Documentation & changelog**, **Documentation requirements**, and **Session log convention** sections, plus a `shellcheck` line in the security guardrails.
- **Grok Build agent.** `aidc grok` launches xAI's Grok Build CLI inside the container, with a `grok_home` named volume (`~/.grok`), a read-only `/host-seed/grok` seed mount, `sync_grok` seeding in `bootstrap-state.sh`, and `aidc sync-config`/`sync-sessions` support. Grok reads the same generated `AGENTS.md`.
- **GPL-3.0 license.** `LICENSE` at repo root.
- **Vulnerability disclosure policy.** `SECURITY.md` documents scope, reporting channels, and timelines.
Expand All @@ -28,6 +34,8 @@ All notable changes to aidc are tracked here. Format follows [Keep a Changelog](
- **Pinned `gitleaks` and `vet` versions** in `templates/devcontainer/Dockerfile.tmpl` (`v8.30.1` and `v1.17.3` respectively). Defaults were `latest`, which resolved at build time and defeated the surrounding base-image SHA pin. Repin instructions are in the Dockerfile.
- **README** and the docs/ tree brought into sync with the above. README's command list includes `aidc status`, `aidc down`, `aidc destroy`, `aidc exec`, and `aidc sync-sessions`. `docs/security.md` covers the scanners, supply-chain guardrails, and egress firewall; `docs/claude-profiles.md` covers the local-model profiles (`localhost.env.example`, `localnetwork.env.example`).

- **VM Claude hooks are now rtk-only.** The host-seed `settings.json` carries hooks for host-only tools (`gryph`, and `cot` on a hard-coded macOS path) that are pointless or broken inside the container. `bootstrap-state.sh` now strips just those entries on every `sync_claude` (preserving rtk and any user hooks; self-heals volumes seeded by an older bootstrap), and `install_agent_hooks` wires only `rtk init --global --auto-patch --hook-only` (non-interactive; installs just the hook without re-writing `CLAUDE.md`/`RTK.md`). In-container session transcripts already auto-sync to the host on start/exit, so agent observability stays host-side.

### Fixed

- **RTK install path.** Dockerfile previously set `RTK_INSTALL_DIR=/usr/local/bin` on the wrong side of the pipe (`VAR=val cmd1 | cmd2` scopes `VAR` to `cmd1`), so the installer fell back to `$HOME/.local/bin` while running as root — the binary landed in `/root/.local/bin/rtk` and was invisible to the `vscode` user. Env var moved onto the `sh` side of the pipe.
Expand All @@ -36,4 +44,6 @@ All notable changes to aidc are tracked here. Format follows [Keep a Changelog](

- **AWS Bedrock and Google Vertex support.** `/host-auth/aws` and `/host-auth/gcloud` bind mounts dropped. `AIDC_AWS_SOURCE`, `AIDC_GCLOUD_SOURCE`, `bedrock.env.example`, and `vertex.env.example` removed. AWS- and Google-specific keys (`AWS_PROFILE`, `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, `AWS_DEFAULT_REGION`, `ANTHROPIC_VERTEX_PROJECT_ID`, `CLOUD_ML_REGION`, `GOOGLE_APPLICATION_CREDENTIALS`, `GOOGLE_CLOUD_PROJECT`) dropped from `AIDC_PASSTHROUGH_ENV_KEYS`.

- **gryph removed from the image.** SafeDep's `gryph` agent-audit layer is no longer installed (Dockerfile) or hooked (`gryph install` dropped from `bootstrap-state.sh`); host-side hooks for it are stripped from the seeded `settings.json`. `cot` was never in the image (its hook command pointed at a macOS-only binary path) and is likewise stripped. Agent observability is host-side now that sessions auto-sync on container start and exit.

[Unreleased]: https://github.com/cyfinoid/aidc/commits/main
Loading
Loading