mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
W4: gstack-config validates transcript_ingest_mode; set rejects empty and multi-line values
transcript_ingest_mode accepts only recent|all|off and leaves the stored value unchanged otherwise. set rejects an empty value or one containing a newline for every key instead of truncating it. The config header documents the key; lookup_default keeps off.
This commit is contained in:
1 parent
cece11076e
commit
4222e13a68
2 files changed
+97
-4
No files matched your search
+40
-4
@@ -89,6 +89,20 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne
|
||||
# # Set to true once the privacy gate has asked the user.
|
||||
# # Flip back to false to be re-prompted.
|
||||
#
|
||||
# ─── Transcript ingest (gbrain memory) ───────────────────────────────
|
||||
# transcript_ingest_mode: off # recent | all | off — consent for /sync-gbrain
|
||||
# # to ingest Claude Code and Codex session
|
||||
# # transcripts (every project repo policy allows)
|
||||
# # into your brain (local or Supabase).
|
||||
# # recent — last 90 days
|
||||
# # all — all history
|
||||
# # off — never (other memory still syncs)
|
||||
# # Absent, legacy (A-E, incremental) or unknown
|
||||
# # values are not consent: transcripts are skipped
|
||||
# # and /sync-gbrain asks once. An invalid value is
|
||||
# # REJECTED and the stored value kept.
|
||||
# # Docs: setup-gbrain/memory.md#transcripts
|
||||
#
|
||||
# ─── Timeline Stop hook ──────────────────────────────────────────────
|
||||
# timeline_stop_hook: yes # Controls whether ./setup registers the timeline
|
||||
# # Stop hook (closes dangling session entries).
|
||||
@@ -195,7 +209,7 @@ lookup_default() {
|
||||
# the callers already assume in their own `|| echo "<default>"` fallback.
|
||||
question_tuning) echo "false" ;;
|
||||
team_mode) echo "false" ;;
|
||||
transcript_ingest_mode) echo "off" ;;
|
||||
transcript_ingest_mode) echo "off" ;; # recent | all | off — transcript consent; absent = not consented (use `has`)
|
||||
# repo_mode: EMPTY is load-bearing — gstack-repo-mode treats any non-empty
|
||||
# answer as a user override and skips its own classification entirely, so
|
||||
# a synthesized "unknown" default turns the classifier into dead code.
|
||||
@@ -390,7 +404,23 @@ case "${1:-}" in
|
||||
;;
|
||||
set)
|
||||
KEY="${2:?Usage: gstack-config set <key> <value>}"
|
||||
VALUE="${3:?Usage: gstack-config set <key> <value>}"
|
||||
if [ "$#" -lt 3 ]; then
|
||||
echo "Usage: gstack-config set <key> <value>" >&2
|
||||
exit 1
|
||||
fi
|
||||
VALUE="$3"
|
||||
# A config value is one non-empty line. Reject rather than truncate, so a
|
||||
# bad value never replaces the stored one.
|
||||
if [ -z "$VALUE" ]; then
|
||||
echo "Error: $KEY value is empty. Existing value left unchanged." >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$VALUE" in
|
||||
*$'\n'*)
|
||||
echo "Error: $KEY value contains a newline. Existing value left unchanged." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# Validate key (alphanumeric + underscore + optional @<endpoint-id> suffix).
|
||||
# Accepts hex hashes and the literal "local" from endpoint_hash.
|
||||
if ! printf '%s' "$KEY" | LC_ALL=C grep -qE '^[a-zA-Z0-9_]+(@[a-zA-Z0-9]+)?$'; then
|
||||
@@ -467,6 +497,12 @@ case "${1:-}" in
|
||||
echo "Error: cross_project_learnings '$VALUE' not recognized. Valid values: true, false. Existing value left unchanged." >&2
|
||||
exit 1
|
||||
fi
|
||||
# transcript_ingest_mode is a CONSENT key: recent/all let /sync-gbrain
|
||||
# ingest coding-agent transcripts. Reject a typo; never coerce or store it.
|
||||
if [ "$KEY" = "transcript_ingest_mode" ] && [ "$VALUE" != "recent" ] && [ "$VALUE" != "all" ] && [ "$VALUE" != "off" ]; then
|
||||
echo "Error: transcript_ingest_mode '$VALUE' not recognized. Valid values: recent, all, off. Existing value left unchanged." >&2
|
||||
exit 1
|
||||
fi
|
||||
# memorable_recall is a CONSENT key: `on` lets a Claude Code hook hand every
|
||||
# prompt to a third-party binary. Reject like codex_reviews -- a typo must
|
||||
# never flip consent in either direction, so nothing is coerced or stored.
|
||||
@@ -479,8 +515,8 @@ case "${1:-}" in
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
printf '%s' "$CONFIG_HEADER" > "$CONFIG_FILE"
|
||||
fi
|
||||
# Drop embedded newlines, then escape sed replacement metacharacters.
|
||||
SAFE_VALUE="$(printf '%s' "$VALUE" | head -1)"
|
||||
# Escape sed replacement metacharacters (newlines were rejected above).
|
||||
SAFE_VALUE="$VALUE"
|
||||
ESC_VALUE="$(printf '%s' "$SAFE_VALUE" | sed 's/[&/\]/\\&/g')"
|
||||
if grep -qE "^${KEY}:" "$CONFIG_FILE" 2>/dev/null; then
|
||||
# Portable in-place edit (BSD sed uses -i '', GNU sed uses -i without arg)
|
||||
|
||||
@@ -223,3 +223,60 @@ describe('design_detector_install_prompted (true|false, rejecting validator)', (
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('transcript_ingest_mode (recent|all|off, rejecting validator)', () => {
|
||||
const env = { ...process.env, GSTACK_STATE_ROOT: STATE };
|
||||
const set = (key: string, value: string) =>
|
||||
spawnSync('bash', [CONFIG_BIN, 'set', key, value], { encoding: 'utf-8', timeout: 30_000, env });
|
||||
const has = (key: string) => spawnSync('bash', [CONFIG_BIN, 'has', key], { encoding: 'utf-8', timeout: 30_000, env }).status;
|
||||
const file = path.join(STATE, 'config.yaml');
|
||||
const snapshot = () => (fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null);
|
||||
|
||||
test('accepts recent, all and off', () => {
|
||||
for (const value of ['recent', 'all', 'off']) {
|
||||
expect(set('transcript_ingest_mode', value).status).toBe(0);
|
||||
expect(get('transcript_ingest_mode').out).toBe(value);
|
||||
}
|
||||
expect(has('transcript_ingest_mode')).toBe(0);
|
||||
});
|
||||
|
||||
test('rejects legacy letters and unknown values with the file unchanged', () => {
|
||||
set('transcript_ingest_mode', 'recent');
|
||||
for (const value of ['A', 'incremental', 'new-only', 'yes', 'Recent']) {
|
||||
const before = snapshot();
|
||||
const r = set('transcript_ingest_mode', value);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toContain(
|
||||
`Error: transcript_ingest_mode '${value}' not recognized. Valid values: recent, all, off. Existing value left unchanged.`,
|
||||
);
|
||||
expect(snapshot()).toBe(before);
|
||||
}
|
||||
expect(get('transcript_ingest_mode').out).toBe('recent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('set rejects empty and multi-line values for every key', () => {
|
||||
const env = { ...process.env, GSTACK_STATE_ROOT: STATE };
|
||||
const file = path.join(STATE, 'config.yaml');
|
||||
const snapshot = () => (fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null);
|
||||
|
||||
test('an empty value exits 1 and leaves the file unchanged', () => {
|
||||
for (const key of ['workspace_root', 'transcript_ingest_mode', 'some_free_form_key']) {
|
||||
const before = snapshot();
|
||||
const r = spawnSync('bash', [CONFIG_BIN, 'set', key, ''], { encoding: 'utf-8', timeout: 30_000, env });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/empty/i);
|
||||
expect(snapshot()).toBe(before);
|
||||
}
|
||||
});
|
||||
|
||||
test('a value containing a newline exits 1 and leaves the file unchanged', () => {
|
||||
for (const key of ['workspace_root', 'transcript_ingest_mode', 'some_free_form_key']) {
|
||||
const before = snapshot();
|
||||
const r = spawnSync('bash', [CONFIG_BIN, 'set', key, 'recent\nall'], { encoding: 'utf-8', timeout: 30_000, env });
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/newline/i);
|
||||
expect(snapshot()).toBe(before);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user