diff --git a/bin/gstack-config b/bin/gstack-config index 6c23b5d8d..3521825f4 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -193,8 +193,16 @@ lookup_default() { # ────────────────────────────────────────────────────────────────────── # Compute sha8 of a string. Used for endpoint hashing. +# shasum is macOS/perl; most Linux distros ship only coreutils sha256sum — +# resolve whichever exists (same fallback chain as the codex-probe timeout +# wrapper). Without this, any Linux user with a git email hit exit 127 in +# resolve-user-slug's Layer-3 fallback. sha8_of() { - printf '%s' "$1" | shasum -a 256 | cut -c1-8 + if command -v sha256sum >/dev/null 2>&1; then + printf '%s' "$1" | sha256sum | cut -c1-8 + else + printf '%s' "$1" | shasum -a 256 | cut -c1-8 + fi } # Detect the active brain endpoint hash. Reads ~/.claude.json for the gbrain @@ -228,7 +236,11 @@ endpoint_hash_with_collision_check() { _claude_json="$HOME/.claude.json" if [ -n "$_matching" ] && [ -f "$_claude_json" ] && command -v jq >/dev/null 2>&1; then _url=$(jq -r '.mcpServers.gbrain.url // .mcpServers.gbrain.transport.url // empty' "$_claude_json" 2>/dev/null) - _sha16=$(printf '%s' "$_url" | shasum -a 256 | cut -c1-16) + if command -v sha256sum >/dev/null 2>&1; then + _sha16=$(printf '%s' "$_url" | sha256sum | cut -c1-16) + else + _sha16=$(printf '%s' "$_url" | shasum -a 256 | cut -c1-16) + fi # Look for any sha16-namespaced key that conflicts. If a stored sha16 exists # and differs from current sha16, that's the collision evidence; emit sha16. _stored16=$(grep -E "^(brain_trust_policy|user_slug_at)@${_sha16}" "$CONFIG_FILE" 2>/dev/null | head -1 || true) diff --git a/browse/SKILL.md b/browse/SKILL.md index 88f961de6..4d7f53465 100644 --- a/browse/SKILL.md +++ b/browse/SKILL.md @@ -192,7 +192,13 @@ If `NEEDS_SETUP`: BUN_INSTALL_SHA="bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd" tmpfile=$(mktemp) curl -fsSL "https://bun.sh/install" -o "$tmpfile" - actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}') + # shasum is macOS/perl; coreutils-only Linux ships sha256sum instead — + # resolve whichever exists so the verify never fails on a missing tool. + if command -v sha256sum >/dev/null 2>&1; then + actual_sha=$(sha256sum "$tmpfile" | awk '{print $1}') + else + actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}') + fi if [ "$actual_sha" != "$BUN_INSTALL_SHA" ]; then echo "ERROR: bun install script checksum mismatch" >&2 echo " expected: $BUN_INSTALL_SHA" >&2 diff --git a/qa/SKILL.md b/qa/SKILL.md index aa5a60782..9d456f1dd 100644 --- a/qa/SKILL.md +++ b/qa/SKILL.md @@ -543,7 +543,13 @@ If `NEEDS_SETUP`: BUN_INSTALL_SHA="bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd" tmpfile=$(mktemp) curl -fsSL "https://bun.sh/install" -o "$tmpfile" - actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}') + # shasum is macOS/perl; coreutils-only Linux ships sha256sum instead — + # resolve whichever exists so the verify never fails on a missing tool. + if command -v sha256sum >/dev/null 2>&1; then + actual_sha=$(sha256sum "$tmpfile" | awk '{print $1}') + else + actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}') + fi if [ "$actual_sha" != "$BUN_INSTALL_SHA" ]; then echo "ERROR: bun install script checksum mismatch" >&2 echo " expected: $BUN_INSTALL_SHA" >&2 diff --git a/scripts/resolvers/browse.ts b/scripts/resolvers/browse.ts index cff6db107..e04e3487e 100644 --- a/scripts/resolvers/browse.ts +++ b/scripts/resolvers/browse.ts @@ -138,7 +138,13 @@ If \`NEEDS_SETUP\`: BUN_INSTALL_SHA="bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd" tmpfile=$(mktemp) curl -fsSL "https://bun.sh/install" -o "$tmpfile" - actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}') + # shasum is macOS/perl; coreutils-only Linux ships sha256sum instead — + # resolve whichever exists so the verify never fails on a missing tool. + if command -v sha256sum >/dev/null 2>&1; then + actual_sha=$(sha256sum "$tmpfile" | awk '{print $1}') + else + actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}') + fi if [ "$actual_sha" != "$BUN_INSTALL_SHA" ]; then echo "ERROR: bun install script checksum mismatch" >&2 echo " expected: $BUN_INSTALL_SHA" >&2 diff --git a/test/user-slug-fallback.test.ts b/test/user-slug-fallback.test.ts index 82e733aa9..9021b9203 100644 --- a/test/user-slug-fallback.test.ts +++ b/test/user-slug-fallback.test.ts @@ -15,7 +15,8 @@ */ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdtempSync, existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'fs'; +import { mkdtempSync, existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, symlinkSync } from 'fs'; +import { createHash } from 'crypto'; import { join } from 'path'; import { tmpdir } from 'os'; import { spawnSync } from 'child_process'; @@ -112,6 +113,114 @@ describe('resolve-user-slug fallback chain', () => { }); }); +describe('sha8_of portable hash (sha256sum → shasum fallback)', () => { + // sha8_of must work on coreutils-only Linux (no shasum: the exit-127 + // regression) AND on stock macOS (no sha256sum). The ambient PATH decides + // which branch runs, so a plain subprocess call only ever covers one branch + // per platform. Pin BOTH deterministically: extract the real function text + // from bin/gstack-config (no drift-prone copy) and run it under /bin/sh + // with a shim-only PATH that makes exactly one hasher visible. The wrong + // branch exits 127 (its tool is absent from the shim dir), so branch + // selection is asserted structurally, not inferred. + const EXPECTED = '2cf24dba'; // sha256("hello") = 2cf24dba5fb0a30e2… + + function sha8FnSource(): string { + const src = readFileSync(CONFIG_BIN, 'utf-8'); + const m = src.match(/^sha8_of\(\) \{\n[\s\S]*?\n\}/m); + if (!m) throw new Error('sha8_of() not found in bin/gstack-config'); + return m[0]; + } + + /** Absolute-path sha256 pipeline for shims (host has sha256sum OR shasum). */ + function realHasherLine(): string { + const sha256sum = Bun.which('sha256sum'); + if (sha256sum) return `exec ${sha256sum} "$@"`; + const shasum = Bun.which('shasum'); + if (shasum) return `exec ${shasum} -a 256 "$@"`; + throw new Error('neither sha256sum nor shasum available on this host'); + } + + function runSha8(shimDir: string) { + const result = spawnSync('/bin/sh', ['-c', `${sha8FnSource()}\nsha8_of "hello"`], { + encoding: 'utf-8', + env: { PATH: shimDir }, // ONLY the shim dir: absent tools are really absent + timeout: 5000, + }); + return { stdout: (result.stdout || '').trim(), status: result.status ?? -1, stderr: result.stderr || '' }; + } + + function makeShimDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'gstack-sha8-shim-')); + const cut = Bun.which('cut'); + if (!cut) throw new Error('cut not on PATH'); + symlinkSync(cut, join(dir, 'cut')); + return dir; + } + + test('coreutils-only PATH (sha256sum present, shasum absent) — the Linux exit-127 regression', () => { + const shim = makeShimDir(); + try { + writeFileSync(join(shim, 'sha256sum'), `#!/bin/sh\n${realHasherLine()}\n`, { mode: 0o755 }); + const result = runSha8(shim); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(result.stdout).toBe(EXPECTED); + } finally { + rmSync(shim, { recursive: true, force: true }); + } + }); + + test('sha256sum absent falls back to `shasum -a 256` with identical output (macOS branch)', () => { + const shim = makeShimDir(); + try { + // Arg-validating shasum shim: wrong/missing `-a 256` exits 64, which + // would surface as a failed pipeline — pins the exact invocation. + writeFileSync( + join(shim, 'shasum'), + `#!/bin/sh\n[ "$1" = "-a" ] && [ "$2" = "256" ] || exit 64\nshift 2\n${realHasherLine()}\n`, + { mode: 0o755 }, + ); + const result = runSha8(shim); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(result.stdout).toBe(EXPECTED); // same vector ⇒ branches are equivalent + } finally { + rmSync(shim, { recursive: true, force: true }); + } + }); +}); + +describe('endpoint-hash collision escalation (sha8 → sha16)', () => { + // endpoint_hash_with_collision_check's sha16 arm carries its own portable + // hash pipeline (sha256sum → shasum). This drives it end-to-end through the + // real binary: a gbrain MCP URL in $HOME/.claude.json plus config keys at + // BOTH the sha8 and sha16 namespaces is the recorded-collision evidence + // that makes `endpoint-hash` emit the 16-char hash. Skipped where jq is + // absent (the script itself degrades to 'local' there). + test('emits sha8 normally, sha16 when a stored sha16-namespaced key exists', () => { + if (!Bun.which('jq')) return; // endpoint_hash requires jq; degrades to 'local' without it + const url = 'https://gbrain.example.test/mcp'; + const hex = createHash('sha256').update(url).digest('hex'); + const sha8 = hex.slice(0, 8); + const sha16 = hex.slice(0, 16); + writeFileSync(join(TMP_HOME, '.claude.json'), JSON.stringify({ mcpServers: { gbrain: { url } } })); + + // No collision evidence yet → plain sha8. + const plain = runConfig(['endpoint-hash'], { GSTACK_HOME: TMP_HOME }); + expect(plain.status).toBe(0); + expect(plain.stdout.trim()).toBe(sha8); + + // Keys stored at both namespaces → escalate to sha16. + writeFileSync( + join(TMP_HOME, 'config.yaml'), + `brain_trust_policy@${sha8}: personal\nbrain_trust_policy@${sha16}: shared\n`, + ); + const escalated = runConfig(['endpoint-hash'], { GSTACK_HOME: TMP_HOME }); + expect(escalated.status).toBe(0); + expect(escalated.stdout.trim()).toBe(sha16); + }); +}); + describe('brain_trust_policy@ namespace', () => { test('default value is "unset"', () => { const result = runConfig(['get', 'brain_trust_policy@deadbeef'], { GSTACK_HOME: TMP_HOME });