Add selectable CASAN IDE integrations
This commit is contained in:
@@ -28,15 +28,45 @@ if sh "$REPO_ROOT/install.sh" >/dev/null 2>&1; then pass "install.sh completes";
|
||||
[[ -x "$CASAN" ]] && pass "global launcher created" || fail "launcher missing"
|
||||
[[ -f "$CASAN_HOME/current/.harness-hash" ]] && pass "integrity hash recorded at install" || fail "no .harness-hash"
|
||||
"$CASAN" version >/dev/null 2>&1 && pass "casan version works via launcher" || fail "casan version failed"
|
||||
python3 - "$CASAN_HOME/current/packages/casan-devkit/casan-init.py" <<'PY' \
|
||||
&& pass "client selector accepts menu numbers, aliases, repeats, and all" \
|
||||
|| fail "client selector normalization failed"
|
||||
import importlib.util,sys
|
||||
spec=importlib.util.spec_from_file_location("casan_init",sys.argv[1])
|
||||
m=importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
|
||||
assert m.select_clients(["1,3"],False)==["claude","vscode-copilot"]
|
||||
assert m.select_clients(["codex","copilot"],False)==["codex","vscode-copilot"]
|
||||
assert m.select_clients(["all"],False)==["claude","codex","vscode-copilot"]
|
||||
PY
|
||||
|
||||
echo "===== ② casan init (config only, no harness copy) ====="
|
||||
PROJ="$WORK/proj/my-app"; mkdir -p "$PROJ"; echo '{"name":"x"}' > "$PROJ/package.json"
|
||||
( cd "$PROJ" && "$CASAN" init --project my-app --mode enforce >/dev/null 2>&1 ) \
|
||||
&& pass "casan init completes" || fail "casan init failed"
|
||||
for f in .casan/config.json .casan/version.lock .casan/agentic.env .claude/settings.json .codex/hooks.json .specify/.gitignore; do
|
||||
for f in .casan/config.json .casan/version.lock .casan/agentic.env .casan/casan-hook.py .claude/settings.json .codex/hooks.json .vscode/extensions.json .specify/.gitignore; do
|
||||
[[ -f "$PROJ/$f" ]] && pass "init wrote $f" || fail "init missing $f"
|
||||
done
|
||||
if [[ -d "$PROJ/packages/casan-harness" ]]; then fail "harness was copied into the repo (should not be)"; else pass "harness NOT copied into repo (hybrid model)"; fi
|
||||
if grep -R -q 'packages/casan-harness/adapters' "$PROJ/.claude/settings.json" "$PROJ/.codex/hooks.json"; then fail "generated hooks still target a repo-local harness"; else pass "generated hooks target the stable project bootstrap"; fi
|
||||
|
||||
echo "===== ②b generated hooks execute through the GLOBAL harness ====="
|
||||
CLAUDE_BEGIN=$(printf '%s' '{"session_id":"hybrid-claude","prompt_id":"t1","prompt":"add a safe helper","cwd":"'"$PROJ"'"}' |
|
||||
CASAN_APP_ROOT="$PROJ" python3 "$PROJ/.casan/casan-hook.py" --client claude --event UserPromptSubmit)
|
||||
echo "$CLAUDE_BEGIN" | grep -q '"additionalContext"' && pass "Claude generated hook opens admission" || fail "Claude generated hook failed ($CLAUDE_BEGIN)"
|
||||
CLAUDE_PRE=$(printf '%s' '{"session_id":"hybrid-claude","tool_name":"Bash","tool_input":{"command":"printf ok"},"cwd":"'"$PROJ"'"}' |
|
||||
CASAN_APP_ROOT="$PROJ" python3 "$PROJ/.casan/casan-hook.py" --client claude --event PreToolUse)
|
||||
echo "$CLAUDE_PRE" | grep -q '"permissionDecision": "allow"' && pass "Claude generated hook gates an admitted tool" || fail "Claude pre-tool failed ($CLAUDE_PRE)"
|
||||
CODEX_BEGIN=$(printf '%s' '{"session_id":"hybrid-codex","turn_id":"t2","prompt":"review this project","cwd":"'"$PROJ"'"}' |
|
||||
CASAN_APP_ROOT="$PROJ" python3 "$PROJ/.casan/casan-hook.py" --client codex --event UserPromptSubmit)
|
||||
echo "$CODEX_BEGIN" | grep -q '"continue": true' && pass "Codex generated hook opens admission" || fail "Codex generated hook failed ($CODEX_BEGIN)"
|
||||
python3 - "$PROJ/.codex/hooks.json" <<'PY' && pass "Codex hook JSON matches the current nested command schema" || fail "Codex hook JSON schema is stale"
|
||||
import json,sys
|
||||
d=json.load(open(sys.argv[1]))
|
||||
handlers=[h for groups in d["hooks"].values() for group in groups for h in group["hooks"]]
|
||||
assert handlers and all(h.get("type") == "command" for h in handlers)
|
||||
assert all(isinstance(h.get("command"), str) for h in handlers)
|
||||
assert all(isinstance(h.get("timeout"), int) and "timeout_ms" not in h for h in handlers)
|
||||
PY
|
||||
|
||||
echo "===== ③ version.lock pins the installed harness ====="
|
||||
LOCK_HASH="$(python3 -c 'import json;print(json.load(open("'"$PROJ"'/.casan/version.lock"))["harness_hash"])')"
|
||||
@@ -45,10 +75,17 @@ REC_HASH="$(cat "$CASAN_HOME/current/.harness-hash")"
|
||||
|
||||
echo "===== ④ verify-harness: ok before tamper, drift after ====="
|
||||
( cd "$PROJ" && "$CASAN" verify-harness >/dev/null 2>&1 ) && pass "verify-harness OK on a clean install" || fail "verify-harness reported drift on clean install"
|
||||
cp "$CASAN_HOME/current/packages/casan-harness/scripts/bash/security-check.sh" "$WORK/security-check.clean"
|
||||
echo "# tampered $(date)" >> "$CASAN_HOME/current/packages/casan-harness/scripts/bash/security-check.sh"
|
||||
VRC=0; ( cd "$PROJ" && "$CASAN" verify-harness >/dev/null 2>"$WORK/vh.err" ) || VRC=$?
|
||||
[[ "$VRC" -eq 3 ]] && pass "verify-harness detects tamper (rc=3)" || fail "tamper not detected (rc=$VRC)"
|
||||
grep -q "HARNESS_INTEGRITY_DRIFT" "$WORK/vh.err" && pass "drift message emitted" || fail "no drift message"
|
||||
cp "$WORK/security-check.clean" "$CASAN_HOME/current/packages/casan-harness/scripts/bash/security-check.sh"
|
||||
cp "$CASAN_HOME/current/packages/casan-harness/adapters/codex/codex_hook.py" "$WORK/codex-hook.clean"
|
||||
echo "# adapter tamper" >> "$CASAN_HOME/current/packages/casan-harness/adapters/codex/codex_hook.py"
|
||||
ARC=0; ( cd "$PROJ" && "$CASAN" verify-harness >/dev/null 2>&1 ) || ARC=$?
|
||||
[[ "$ARC" -eq 3 ]] && pass "integrity pin includes client adapters" || fail "adapter tamper was not detected"
|
||||
cp "$WORK/codex-hook.clean" "$CASAN_HOME/current/packages/casan-harness/adapters/codex/codex_hook.py"
|
||||
|
||||
echo "===== ⑤ agentic bridge runs against the PROJECT state via GLOBAL harness ====="
|
||||
BR="$CASAN_HOME/current/packages/casan-harness/scripts/python/agentic_bridge.py"
|
||||
@@ -63,6 +100,8 @@ PROJ2="$WORK/proj2/Some_App"; mkdir -p "$PROJ2"
|
||||
( cd "$PROJ2" && "$CASAN" init >/dev/null 2>&1 ) && pass "init works with a defaulted project id" || fail "init default id failed"
|
||||
PID=$(python3 -c 'import json;print(json.load(open("'"$PROJ2"'/.casan/config.json"))["project_id"])' 2>/dev/null)
|
||||
[[ "$PID" =~ ^[a-z][a-z0-9-]{1,62}$ ]] && pass "defaulted project id is sanitized ($PID)" || fail "bad default project id ($PID)"
|
||||
MODE=$(python3 -c 'import json;print(json.load(open("'"$PROJ2"'/.casan/config.json"))["enforcement_mode"])' 2>/dev/null)
|
||||
[[ "$MODE" == "enforce" ]] && pass "production init defaults to enforce mode" || fail "default mode is not enforce ($MODE)"
|
||||
|
||||
echo "===== ⑦ init MERGES into an existing shell (agents/skills/hooks preserved) ====="
|
||||
EXP="$WORK/existing"; mkdir -p "$EXP/.claude/agents" "$EXP/.claude/skills" "$EXP/.codex"
|
||||
@@ -81,14 +120,17 @@ PY
|
||||
}
|
||||
[ -f "$EXP/.claude/agents/reviewer.md" ] && [ -f "$EXP/.claude/skills/deploy.md" ] && pass "existing agents/skills preserved" || fail "agents/skills lost"
|
||||
py_check "$EXP/.claude/settings.json" "my-existing-hook" && pass "existing Claude hook preserved (not clobbered)" || fail "existing hook clobbered"
|
||||
py_check "$EXP/.claude/settings.json" "claude_hook.py" && pass "CASAN Claude hook merged in" || fail "CASAN hook not merged"
|
||||
py_check "$EXP/.claude/settings.json" "casan-hook.py" && pass "CASAN Claude hook merged in" || fail "CASAN hook not merged"
|
||||
[ "$(python3 -c 'import json;print(json.load(open("'"$EXP"'/.claude/settings.json")).get("model"))')" = "claude-opus-4-8" ] && pass "unrelated settings key (model) preserved" || fail "model key lost"
|
||||
grep -q '\[mytool\]' "$EXP/.codex/config.toml" && pass "existing codex [mytool] preserved" || fail "mytool lost"
|
||||
[ "$(grep -c '^\[hooks\]' "$EXP/.codex/config.toml")" = "1" ] && pass "codex config.toml has no duplicate [hooks] table" || fail "duplicate [hooks]"
|
||||
# idempotent
|
||||
( cd "$EXP" && "$CASAN" init --project existing-app >/dev/null 2>&1 )
|
||||
N=$(python3 -c 'import json;d=json.load(open("'"$EXP"'/.claude/settings.json"));print(sum(1 for g in d["hooks"]["PreToolUse"] for h in g["hooks"] if "claude_hook.py" in h["command"]))')
|
||||
N=$(python3 -c 'import json;d=json.load(open("'"$EXP"'/.claude/settings.json"));print(sum(1 for g in d["hooks"]["PreToolUse"] for h in g["hooks"] if "casan-hook.py" in h["command"]))')
|
||||
[ "$N" = "1" ] && pass "re-running init is idempotent (no duplicate CASAN hook)" || fail "init duplicated CASAN hook (n=$N)"
|
||||
( cd "$EXP" && "$CASAN" init --project existing-app --client claude >/dev/null 2>&1 )
|
||||
if py_check "$EXP/.codex/hooks.json" "casan-hook.py"; then fail "re-selecting Claude left the CASAN Codex hook enabled"; else pass "re-running init synchronizes disabled clients"; fi
|
||||
py_check "$EXP/.claude/settings.json" "my-existing-hook" && pass "client re-selection still preserves user hooks" || fail "client re-selection removed user hooks"
|
||||
|
||||
echo "===== ⑧ level-aware install + init (packaging/levels.json) ====="
|
||||
# core install: no devkit, casan init unavailable
|
||||
@@ -129,6 +171,46 @@ GRC=0; ( cd "$HUB" && "$DKC" init --project hub >/dev/null 2>"$WORK/hub.err" ) |
|
||||
[ ! -f "$HUB/.claude/settings.json" ] && pass "no hooks written into the refused hub" || fail "hooks written into source hub"
|
||||
( cd "$HUB" && "$DKC" init --project hub --force >/dev/null 2>&1 ) && pass "--force overrides the source-hub guard" || fail "--force did not override guard"
|
||||
|
||||
echo "===== ⑩ VS Code/Copilot @casan packaging + install + doctor ====="
|
||||
FAKE_BIN="$WORK/fake-bin"; mkdir -p "$FAKE_BIN"
|
||||
cat > "$FAKE_BIN/code" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
if [[ "${1:-}" == "--list-extensions" ]]; then
|
||||
printf '%s\n' 'fpt-casan.casan-governed-chat' 'GitHub.copilot' 'GitHub.copilot-chat'
|
||||
exit 0
|
||||
fi
|
||||
printf '%s\n' "$*" >> "$CASAN_FAKE_CODE_LOG"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "$FAKE_BIN/code"
|
||||
VSP="$WORK/vscode-project"; mkdir -p "$VSP"
|
||||
export CASAN_FAKE_CODE_LOG="$WORK/code.log"
|
||||
( cd "$VSP" && PATH="$FAKE_BIN:$PATH" "$DKC" init --project vscode-project --client vscode-copilot --mode enforce --vscode-install yes >/dev/null 2>&1 ) \
|
||||
&& pass "init enables the selected VS Code/Copilot integration" || fail "VS Code/Copilot init failed"
|
||||
grep -q -- '--install-extension .*casan-governed-chat.vsix --force' "$CASAN_FAKE_CODE_LOG" \
|
||||
&& pass "init installs the packaged CASAN VSIX through code CLI" || fail "VSIX install was not invoked"
|
||||
VSIX1="$WORK/one.vsix"; VSIX2="$WORK/two.vsix"
|
||||
CASAN_HARNESS_ROOT="$DK_HOME/current/packages/casan-harness" python3 "$DK_HOME/current/packages/casan-devkit/package-vscode-extension.py" --output "$VSIX1" >/dev/null
|
||||
CASAN_HARNESS_ROOT="$DK_HOME/current/packages/casan-harness" python3 "$DK_HOME/current/packages/casan-devkit/package-vscode-extension.py" --output "$VSIX2" >/dev/null
|
||||
cmp -s "$VSIX1" "$VSIX2" && pass "VSIX packaging is deterministic" || fail "VSIX package bytes drift between builds"
|
||||
python3 - "$VSIX1" <<'PY' && pass "VSIX contains the required production extension assets" || fail "VSIX structure is invalid"
|
||||
import zipfile,sys
|
||||
with zipfile.ZipFile(sys.argv[1]) as z:
|
||||
names=set(z.namelist())
|
||||
assert {"[Content_Types].xml","extension.vsixmanifest","extension/package.json","extension/extension.js"} <= names
|
||||
PY
|
||||
python3 - "$VSP/.vscode/extensions.json" <<'PY' && pass "VS Code recommendations include Copilot + CASAN" || fail "VS Code recommendations incomplete"
|
||||
import json,sys
|
||||
r=set(json.load(open(sys.argv[1]))["recommendations"])
|
||||
assert {"GitHub.copilot","GitHub.copilot-chat","fpt-casan.casan-governed-chat"} <= r
|
||||
PY
|
||||
CASAN_HOME="$DK_HOME" node "$REPO_ROOT/packages/casan-devkit/tests/vscode-extension-tests.js" \
|
||||
"$VSP" "$REPO_ROOT/packages/casan-harness/adapters/vscode/extension/extension.js" \
|
||||
&& pass "VS Code @casan handler completes the real governed lifecycle" \
|
||||
|| fail "VS Code @casan handler contract failed"
|
||||
( cd "$VSP" && PATH="$FAKE_BIN:$PATH" "$DKC" doctor --client vscode-copilot >/dev/null 2>&1 ) \
|
||||
&& pass "doctor proves the VS Code/Copilot adapter and installed extension" || fail "VS Code doctor failed"
|
||||
|
||||
echo ""
|
||||
echo "===== HYBRID INSTALL SUMMARY: PASS=$PASS FAIL=$FAIL ====="
|
||||
[[ "$FAIL" -eq 0 ]] || exit 1
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
'use strict';
|
||||
|
||||
// Dependency-free contract test for the packaged VS Code @casan participant.
|
||||
// It mocks only the stable VS Code host surface and runs the real project
|
||||
// bootstrap + global harness adapters in a child process.
|
||||
|
||||
const assert = require('assert');
|
||||
const Module = require('module');
|
||||
const path = require('path');
|
||||
|
||||
const project = path.resolve(process.argv[2]);
|
||||
const extensionPath = path.resolve(process.argv[3]);
|
||||
let registeredHandler;
|
||||
const streamed = [];
|
||||
|
||||
class CancellationError extends Error {}
|
||||
class CancellationTokenSource {
|
||||
constructor() {
|
||||
this.token = {
|
||||
isCancellationRequested: false,
|
||||
onCancellationRequested: () => ({ dispose() {} })
|
||||
};
|
||||
}
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
const vscodeMock = {
|
||||
version: '1.98.0-test',
|
||||
workspace: {
|
||||
workspaceFolders: [{ uri: { fsPath: project } }],
|
||||
isTrusted: true,
|
||||
getConfiguration() {
|
||||
return {
|
||||
get(key, fallback) {
|
||||
if (key === 'pythonPath') return process.env.PYTHON || 'python3';
|
||||
if (key === 'hookTimeoutMs') return 30000;
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
chat: {
|
||||
createChatParticipant(_id, handler) {
|
||||
registeredHandler = handler;
|
||||
return { dispose() {} };
|
||||
}
|
||||
},
|
||||
LanguageModelChatMessage: {
|
||||
User(value) { return { role: 'user', value }; }
|
||||
},
|
||||
CancellationError,
|
||||
CancellationTokenSource
|
||||
};
|
||||
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
if (request === 'vscode') return vscodeMock;
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const extension = require(extensionPath);
|
||||
const subscriptions = [];
|
||||
extension.activate({ subscriptions });
|
||||
assert.strictEqual(typeof registeredHandler, 'function');
|
||||
assert.strictEqual(subscriptions.length, 1);
|
||||
|
||||
const token = {
|
||||
isCancellationRequested: false,
|
||||
onCancellationRequested: () => ({ dispose() {} })
|
||||
};
|
||||
const request = {
|
||||
prompt: 'Explain the CASAN integration without changing files.',
|
||||
model: {
|
||||
id: 'copilot-test-model',
|
||||
async sendRequest(messages) {
|
||||
assert.strictEqual(messages.length, 2);
|
||||
return {
|
||||
text: (async function* responseText() {
|
||||
yield 'Governed ';
|
||||
yield 'response';
|
||||
})()
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
const stream = {
|
||||
progress(value) { streamed.push(`progress:${value}`); },
|
||||
markdown(value) { streamed.push(value); }
|
||||
};
|
||||
const result = await registeredHandler(request, {}, stream, token);
|
||||
assert.strictEqual(streamed.filter(value => !value.startsWith('progress:')).join(''),
|
||||
'Governed response');
|
||||
assert.strictEqual(result.metadata.certified, true);
|
||||
assert.strictEqual(result.metadata.certificationStrength, 'casan_owned');
|
||||
assert.ok(result.metadata.traceId);
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error.stack || error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user