fix(gstack-config): accept @local endpoint ids for brain_trust_policy

endpoint-hash returns "local" for stdio/PGLite, but validators only
allowed hex suffixes, so setup-gbrain could not persist trust policy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sina
2026-07-10 11:21:19 -07:00
parent 7c9df1c568
commit db149a6162
3 changed files with 37 additions and 15 deletions
+14 -11
View File
@@ -134,12 +134,12 @@ lookup_default() {
redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection redact_repo_visibility) echo "" ;; # empty → fall through to gh/glab detection
redact_prepush_hook) echo "false" ;; redact_prepush_hook) echo "false" ;;
# Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline: # Brain-aware planning (v1.48 / T5+T10+T16). Defaults documented inline:
# brain_trust_policy@<hash> — unset on fresh install; setup-gbrain # brain_trust_policy@<endpoint-id> — unset on fresh install; setup-gbrain
# writes 'personal' for local engines, # writes 'personal' for local engines,
# asks the user for remote-ambiguous. # asks the user for remote-ambiguous.
# salience_allowlist — empty falls through to # salience_allowlist — empty falls through to
# SALIENCE_DEFAULT_ALLOWLIST (D9). # SALIENCE_DEFAULT_ALLOWLIST (D9).
# user_slug_at_<hash> — empty triggers resolve-user-slug # user_slug_at_<endpoint-id> — empty triggers resolve-user-slug
# fallback chain (D4 A3) on first call. # fallback chain (D4 A3) on first call.
brain_trust_policy*) echo "unset" ;; brain_trust_policy*) echo "unset" ;;
salience_allowlist) echo "" ;; salience_allowlist) echo "" ;;
@@ -260,14 +260,16 @@ resolve_user_slug() {
case "${1:-}" in case "${1:-}" in
get) get)
KEY="${2:?Usage: gstack-config get <key>}" KEY="${2:?Usage: gstack-config get <key>}"
# Validate key (alphanumeric + underscore + optional @<hash> suffix for # Validate key (alphanumeric + underscore + optional @<endpoint-id> suffix for
# endpoint-namespaced keys introduced by the brain-aware planning layer) # endpoint-namespaced keys introduced by the brain-aware planning layer).
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then # Endpoint ids are sha8/sha16 hex for remote MCP URLs, or the literal
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<hex-hash> suffix" >&2 # "local" for stdio/PGLite engines (see endpoint_hash).
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<endpoint-id> suffix" >&2
exit 1 exit 1
fi fi
# Use literal match for keys containing @ (sha hashes), regex otherwise # Use literal match for keys containing @ (endpoint ids), regex otherwise
VALUE=$(grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | grep -E "^${KEY%@*}(@[a-f0-9]+)?:" | grep -F "${KEY}:" | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true) VALUE=$(grep -F "${KEY}:" "$CONFIG_FILE" 2>/dev/null | grep -E "^${KEY%@*}(@[a-zA-Z0-9]+)?:" | grep -F "${KEY}:" | tail -1 | awk '{print $2}' | tr -d '[:space:]' || true)
if [ -z "$VALUE" ]; then if [ -z "$VALUE" ]; then
VALUE=$(lookup_default "$KEY") VALUE=$(lookup_default "$KEY")
fi fi
@@ -276,9 +278,10 @@ case "${1:-}" in
set) set)
KEY="${2:?Usage: gstack-config set <key> <value>}" KEY="${2:?Usage: gstack-config set <key> <value>}"
VALUE="${3:?Usage: gstack-config set <key> <value>}" VALUE="${3:?Usage: gstack-config set <key> <value>}"
# Validate key (alphanumeric + underscore + optional @<hash> suffix) # Validate key (alphanumeric + underscore + optional @<endpoint-id> suffix).
if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-f0-9]+)?$'; then # Accepts hex hashes and the literal "local" from endpoint_hash.
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<hex-hash> suffix" >&2 if ! printf '%s' "$KEY" | grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then
echo "Error: key must contain only alphanumeric characters, underscores, and an optional @<endpoint-id> suffix" >&2
exit 1 exit 1
fi fi
# Validate brain_trust_policy value domain (D4 / D11) # Validate brain_trust_policy value domain (D4 / D11)
+13
View File
@@ -108,6 +108,13 @@ describe('gstack-config', () => {
expect(existsSync(join(nestedDir, 'config.yaml'))).toBe(true); expect(existsSync(join(nestedDir, 'config.yaml'))).toBe(true);
}); });
test('brain trust policy accepts local endpoint suffix', () => {
const { exitCode, stderr } = run(['set', 'brain_trust_policy@local', 'personal']);
expect(exitCode).toBe(0);
expect(stderr).toBe('');
expect(run(['get', 'brain_trust_policy@local']).stdout).toBe('personal');
});
// ─── list ───────────────────────────────────────────────── // ─── list ─────────────────────────────────────────────────
test('list shows all keys', () => { test('list shows all keys', () => {
writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\nupdate_check: false\n'); writeFileSync(join(stateDir, 'config.yaml'), 'auto_upgrade: true\nupdate_check: false\n');
@@ -139,6 +146,12 @@ describe('gstack-config', () => {
expect(stderr).toContain('alphanumeric'); expect(stderr).toContain('alphanumeric');
}); });
test('set rejects endpoint suffix with punctuation', () => {
const { exitCode, stderr } = run(['set', 'brain_trust_policy@local-dev', 'personal']);
expect(exitCode).toBe(1);
expect(stderr).toContain('endpoint-id');
});
test('set preserves value with sed special chars', () => { test('set preserves value with sed special chars', () => {
run(['set', 'test_special', 'a/b&c\\d']); run(['set', 'test_special', 'a/b&c\\d']);
const { stdout } = run(['get', 'test_special']); const { stdout } = run(['get', 'test_special']);
+10 -4
View File
@@ -8,7 +8,7 @@
* 3. sha8($(git config user.email)) * 3. sha8($(git config user.email))
* 4. anonymous-<sha8(hostname)> * 4. anonymous-<sha8(hostname)>
* *
* Result is persisted under user_slug_at_<endpoint-hash> for stability. * Result is persisted under user_slug_at_<endpoint-id> for stability.
* Test isolation via GSTACK_HOME and HOME env overrides. * Test isolation via GSTACK_HOME and HOME env overrides.
* *
* Gate-tier, free, ~50ms. * Gate-tier, free, ~50ms.
@@ -87,12 +87,12 @@ describe('resolve-user-slug fallback chain', () => {
expect(slug).toMatch(/^(email-|anonymous-)[a-f0-9]+$|^[a-zA-Z0-9-]+$/); expect(slug).toMatch(/^(email-|anonymous-)[a-f0-9]+$|^[a-zA-Z0-9-]+$/);
}); });
test('persists resolution to user_slug_at_<hash> on first call', () => { test('persists resolution to user_slug_at_<endpoint-id> on first call', () => {
runConfig(['resolve-user-slug'], { GSTACK_HOME: TMP_HOME, USER: 'persisttest' }); runConfig(['resolve-user-slug'], { GSTACK_HOME: TMP_HOME, USER: 'persisttest' });
const configFile = join(TMP_HOME, 'config.yaml'); const configFile = join(TMP_HOME, 'config.yaml');
expect(existsSync(configFile)).toBe(true); expect(existsSync(configFile)).toBe(true);
const content = readFileSync(configFile, 'utf-8'); const content = readFileSync(configFile, 'utf-8');
expect(content).toMatch(/^user_slug_at_[a-f0-9]+:\s+persisttest/m); expect(content).toMatch(/^user_slug_at_(local|[a-f0-9]{8}|[a-f0-9]{16}):\s+persisttest/m);
}); });
test('subsequent calls return same slug (stable across sessions)', () => { test('subsequent calls return same slug (stable across sessions)', () => {
@@ -104,7 +104,7 @@ describe('resolve-user-slug fallback chain', () => {
}); });
}); });
describe('brain_trust_policy@<hash> namespace', () => { describe('brain_trust_policy@<endpoint-id> namespace', () => {
test('default value is "unset"', () => { test('default value is "unset"', () => {
const result = runConfig(['get', 'brain_trust_policy@deadbeef'], { GSTACK_HOME: TMP_HOME }); const result = runConfig(['get', 'brain_trust_policy@deadbeef'], { GSTACK_HOME: TMP_HOME });
expect(result.status).toBe(0); expect(result.status).toBe(0);
@@ -158,4 +158,10 @@ describe('key validation', () => {
const result = runConfig(['get', 'brain_trust_policy@abc123ff'], { GSTACK_HOME: TMP_HOME }); const result = runConfig(['get', 'brain_trust_policy@abc123ff'], { GSTACK_HOME: TMP_HOME });
expect(result.status).toBe(0); expect(result.status).toBe(0);
}); });
test('accepts @local suffix on key', () => {
const result = runConfig(['get', 'brain_trust_policy@local'], { GSTACK_HOME: TMP_HOME });
expect(result.status).toBe(0);
expect(result.stdout).toBe('unset');
});
}); });