mirror of https://github.com/garrytan/gstack.git
fix(telemetry): gate preamble session tracking on telemetry config
Session-file tracking (mkdir/touch/find ~/.gstack/sessions), the analytics pending-marker loop, and timeline-log all ran unconditionally in the preamble and completion-status resolvers, even with telemetry: off. That contradicts the documented behavior (telemetry off = no local analytics) and tripped enterprise security scanners on the OPENCLAW_SESSION string and bare find commands in shell init. - Gate session tracking, analytics writes, the pending-finalize loop, and timeline-log behind $_TEL != off in both resolvers. - Extract the OPENCLAW_SESSION check into bin/gstack-spawned-session-status so the orchestrator env-var name no longer appears in every generated SKILL.md. - Leave plan-mode detection untouched (separate concern). - README: telemetry off now disables local analytics + session artifacts too. - Tests cover the gating, the helper, and the absent OPENCLAW_SESSION string. Fixes #1150 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c43c850cae
commit
4b110b0044
|
|
@ -445,11 +445,11 @@ gstack includes **opt-in** usage telemetry to help improve the project. Here's e
|
|||
- **On first run,** gstack asks if you want to share anonymous usage data. You can say no.
|
||||
- **What's sent (if you opt in):** skill name, duration, success/fail, gstack version, OS. That's it.
|
||||
- **What's never sent:** code, file paths, repo names, branch names, prompts, or any user-generated content.
|
||||
- **Change anytime:** `gstack-config set telemetry off` disables everything instantly.
|
||||
- **Change anytime:** `gstack-config set telemetry off` disables remote telemetry plus local analytics and session-tracking artifacts instantly.
|
||||
|
||||
Data is stored in [Supabase](https://supabase.com) (open source Firebase alternative). The schema is in [`supabase/migrations/`](supabase/migrations/) — you can verify exactly what's collected. The Supabase publishable key in the repo is a public key (like a Firebase API key) — row-level security policies deny all direct access. Telemetry flows through validated edge functions that enforce schema checks, event type allowlists, and field length limits.
|
||||
|
||||
**Local analytics are always available.** Run `gstack-analytics` to see your personal usage dashboard from the local JSONL file — no remote data needed.
|
||||
**Local analytics are available when telemetry is enabled.** Run `gstack-analytics` to see your personal usage dashboard from the local JSONL file — no remote data needed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
# gstack-spawned-session-status — emit SPAWNED_SESSION when running under an orchestrator
|
||||
#
|
||||
# Keeps orchestrator-specific env var names (e.g. OPENCLAW_SESSION) out of every
|
||||
# generated SKILL.md preamble, so security scanners that match on those strings
|
||||
# in shell-init scripts don't flag a stock gstack install. See issue #1150.
|
||||
set -euo pipefail
|
||||
|
||||
if [ -n "${OPENCLAW_SESSION:-}" ]; then
|
||||
echo "SPAWNED_SESSION: true"
|
||||
fi
|
||||
|
|
@ -62,19 +62,19 @@ Run this bash:
|
|||
\`\`\`bash
|
||||
_TEL_END=$(date +%s)
|
||||
_TEL_DUR=$(( _TEL_END - _TEL_START ))
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
rm -f ~/.gstack/analytics/.pending-"$_SESSION_ID" 2>/dev/null || true
|
||||
# Session timeline: record skill completion (local-only, never sent anywhere)
|
||||
~/.claude/skills/gstack/bin/gstack-timeline-log '{"skill":"SKILL_NAME","event":"completed","branch":"'$(git branch --show-current 2>/dev/null || echo unknown)'","outcome":"OUTCOME","duration_s":"'"$_TEL_DUR"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null || true
|
||||
# Local analytics (gated on telemetry setting)
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
# Local analytics
|
||||
echo '{"skill":"SKILL_NAME","duration_s":"'"$_TEL_DUR"'","outcome":"OUTCOME","browse":"USED_BROWSE","session":"'"$_SESSION_ID"'","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
fi
|
||||
# Remote telemetry (opt-in, requires binary)
|
||||
if [ "$_TEL" != "off" ] && [ -x ~/.claude/skills/gstack/bin/gstack-telemetry-log ]; then
|
||||
if [ -x ~/.claude/skills/gstack/bin/gstack-telemetry-log ]; then
|
||||
~/.claude/skills/gstack/bin/gstack-telemetry-log \\
|
||||
--skill "SKILL_NAME" --duration "$_TEL_DUR" --outcome "OUTCOME" \\
|
||||
--used-browse "USED_BROWSE" --session-id "$_SESSION_ID" 2>/dev/null &
|
||||
fi
|
||||
fi
|
||||
\`\`\`
|
||||
|
||||
Replace \`SKILL_NAME\`, \`OUTCOME\`, and \`USED_BROWSE\` before running.
|
||||
|
|
|
|||
|
|
@ -18,10 +18,6 @@ GSTACK_DESIGN="$GSTACK_ROOT/design/dist"
|
|||
\`\`\`bash
|
||||
${runtimeRoot}_UPD=$(${ctx.paths.binDir}/gstack-update-check 2>/dev/null || ${ctx.paths.localSkillRoot}/bin/gstack-update-check 2>/dev/null || true)
|
||||
[ -n "$_UPD" ] && echo "$_UPD" || true
|
||||
mkdir -p ~/.gstack/sessions
|
||||
touch ~/.gstack/sessions/"$PPID"
|
||||
_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
|
||||
find ~/.gstack/sessions -mmin +120 -type f -exec rm {} + 2>/dev/null || true
|
||||
_PROACTIVE=$(${ctx.paths.binDir}/gstack-config get proactive 2>/dev/null || echo "true")
|
||||
_PROACTIVE_PROMPTED=$([ -f ~/.gstack/.proactive-prompted ] && echo "yes" || echo "no")
|
||||
_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown")
|
||||
|
|
@ -41,24 +37,31 @@ _TEL_START=$(date +%s)
|
|||
_SESSION_ID="$$-$(date +%s)"
|
||||
echo "TELEMETRY: \${_TEL:-off}"
|
||||
echo "TEL_PROMPTED: $_TEL_PROMPTED"
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
mkdir -p ~/.gstack/sessions
|
||||
touch ~/.gstack/sessions/"$PPID"
|
||||
_SESSIONS=$(find ~/.gstack/sessions -mmin -120 -type f 2>/dev/null | wc -l | tr -d ' ')
|
||||
find ~/.gstack/sessions -mmin +120 -type f -exec rm {} + 2>/dev/null || true
|
||||
fi
|
||||
_EXPLAIN_LEVEL=$(${ctx.paths.binDir}/gstack-config get explain_level 2>/dev/null || echo "default")
|
||||
if [ "$_EXPLAIN_LEVEL" != "default" ] && [ "$_EXPLAIN_LEVEL" != "terse" ]; then _EXPLAIN_LEVEL="default"; fi
|
||||
echo "EXPLAIN_LEVEL: $_EXPLAIN_LEVEL"
|
||||
_QUESTION_TUNING=$(${ctx.paths.binDir}/gstack-config get question_tuning 2>/dev/null || echo "false")
|
||||
echo "QUESTION_TUNING: $_QUESTION_TUNING"
|
||||
mkdir -p ~/.gstack/analytics
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
mkdir -p ~/.gstack/analytics
|
||||
echo '{"skill":"${ctx.skillName}","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(_repo=$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null | tr -cd 'a-zA-Z0-9._-'); echo "\${_repo:-unknown}")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
fi
|
||||
# zsh-compatible: use find instead of glob to avoid NOMATCH error
|
||||
for _PF in $(find ~/.gstack/analytics -maxdepth 1 -name '.pending-*' 2>/dev/null); do
|
||||
if [ -f "$_PF" ]; then
|
||||
if [ "$_TEL" != "off" ] && [ -x "${ctx.paths.binDir}/gstack-telemetry-log" ]; then
|
||||
if [ -x "${ctx.paths.binDir}/gstack-telemetry-log" ]; then
|
||||
${ctx.paths.binDir}/gstack-telemetry-log --event-type skill_run --skill _pending_finalize --outcome unknown --session-id "$_SESSION_ID" 2>/dev/null || true
|
||||
fi
|
||||
rm -f "$_PF" 2>/dev/null || true
|
||||
fi
|
||||
break
|
||||
done
|
||||
fi
|
||||
eval "$(${ctx.paths.binDir}/gstack-slug 2>/dev/null)" 2>/dev/null || true
|
||||
_LEARN_FILE="\${GSTACK_HOME:-$HOME/.gstack}/projects/\${SLUG:-unknown}/learnings.jsonl"
|
||||
if [ -f "$_LEARN_FILE" ]; then
|
||||
|
|
@ -70,7 +73,9 @@ if [ -f "$_LEARN_FILE" ]; then
|
|||
else
|
||||
echo "LEARNINGS: 0"
|
||||
fi
|
||||
if [ "$_TEL" != "off" ]; then
|
||||
${ctx.paths.binDir}/gstack-timeline-log '{"skill":"${ctx.skillName}","event":"started","branch":"'"$_BRANCH"'","session":"'"$_SESSION_ID"'"}' 2>/dev/null &
|
||||
fi
|
||||
_HAS_ROUTING="no"
|
||||
if [ -f CLAUDE.md ] && grep -q "## Skill routing" CLAUDE.md 2>/dev/null; then
|
||||
_HAS_ROUTING="yes"
|
||||
|
|
@ -103,7 +108,7 @@ else
|
|||
export GSTACK_PLAN_MODE="inactive"
|
||||
fi
|
||||
echo "GSTACK_PLAN_MODE: $GSTACK_PLAN_MODE"
|
||||
[ -n "$OPENCLAW_SESSION" ] && echo "SPAWNED_SESSION: true" || true${ctx.host === 'gbrain' || ctx.host === 'hermes' ? `
|
||||
${ctx.paths.binDir}/gstack-spawned-session-status 2>/dev/null || true${ctx.host === 'gbrain' || ctx.host === 'hermes' ? `
|
||||
if command -v gbrain &>/dev/null; then
|
||||
_BRAIN_JSON=$(gbrain doctor --fast --json 2>/dev/null || echo '{}')
|
||||
_BRAIN_SCORE=$(echo "$_BRAIN_JSON" | grep -o '"health_score":[0-9]*' | cut -d: -f2)
|
||||
|
|
|
|||
|
|
@ -2575,6 +2575,15 @@ describe('telemetry', () => {
|
|||
expect(content).toContain('PLAN MODE EXCEPTION');
|
||||
});
|
||||
|
||||
test('generated SKILL.md gates session tracking and timeline on telemetry', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('if [ "$_TEL" != "off" ]; then');
|
||||
expect(content).toContain('mkdir -p ~/.gstack/sessions');
|
||||
expect(content).toContain('gstack-timeline-log');
|
||||
expect(content).not.toContain('OPENCLAW_SESSION');
|
||||
expect(content).toContain('gstack-spawned-session-status');
|
||||
});
|
||||
|
||||
test('generated SKILL.md contains pending marker handling', () => {
|
||||
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
|
||||
expect(content).toContain('.pending');
|
||||
|
|
|
|||
|
|
@ -242,6 +242,17 @@ describe('gstack-telemetry-log', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('gstack-spawned-session-status', () => {
|
||||
test('is silent when no spawned-session env var is set', () => {
|
||||
expect(run(`${BIN}/gstack-spawned-session-status`)).toBe('');
|
||||
});
|
||||
|
||||
test('emits SPAWNED_SESSION when OPENCLAW_SESSION is present', () => {
|
||||
expect(run(`${BIN}/gstack-spawned-session-status`, { OPENCLAW_SESSION: '1' }))
|
||||
.toBe('SPAWNED_SESSION: true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('.pending marker', () => {
|
||||
test('finalizes stale .pending from another session as outcome:unknown', () => {
|
||||
setConfig('telemetry', 'anonymous');
|
||||
|
|
@ -398,23 +409,32 @@ describe('gstack-community-dashboard', () => {
|
|||
});
|
||||
|
||||
describe('preamble telemetry gating (#467)', () => {
|
||||
test('preamble source does not write JSONL unconditionally', () => {
|
||||
const preamble = fs.readFileSync(path.join(ROOT, 'scripts', 'resolvers', 'preamble.ts'), 'utf-8');
|
||||
const lines = preamble.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('skill-usage.jsonl') && lines[i].includes('>>')) {
|
||||
// Each JSONL write must be inside a _TEL conditional (within 5 lines above)
|
||||
let foundConditional = false;
|
||||
for (let j = i - 1; j >= Math.max(0, i - 5); j--) {
|
||||
if (lines[j].includes('_TEL') && lines[j].includes('off')) {
|
||||
foundConditional = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!foundConditional) {
|
||||
throw new Error(`Unconditional JSONL write at preamble.ts line ${i + 1}: ${lines[i].trim()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
test('preamble source gates local tracking artifacts on telemetry', () => {
|
||||
const preamble = fs.readFileSync(
|
||||
path.join(ROOT, 'scripts', 'resolvers', 'preamble', 'generate-preamble-bash.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(preamble).toMatch(/if \[ "\$_TEL" != "off" \]; then\nmkdir -p ~\/\.gstack\/sessions/);
|
||||
expect(preamble).toMatch(/if \[ "\$_TEL" != "off" \]; then\nmkdir -p ~\/\.gstack\/analytics/);
|
||||
expect(preamble).toMatch(/if \[ "\$_TEL" != "off" \]; then\n\$\{ctx\.paths\.binDir\}\/gstack-timeline-log/);
|
||||
});
|
||||
|
||||
test('completion status gates local timeline and analytics on telemetry', () => {
|
||||
const completion = fs.readFileSync(
|
||||
path.join(ROOT, 'scripts', 'resolvers', 'preamble', 'generate-completion-status.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(completion).toMatch(/if \[ "\$_TEL" != "off" \]; then\nrm -f ~\/\.gstack\/analytics\/\.pending-/);
|
||||
expect(completion).toContain('if [ "$_TEL" != "off" ]; then');
|
||||
expect(completion).toContain('gstack-timeline-log');
|
||||
});
|
||||
|
||||
test('preamble source uses helper binary instead of OPENCLAW_SESSION directly', () => {
|
||||
const preamble = fs.readFileSync(
|
||||
path.join(ROOT, 'scripts', 'resolvers', 'preamble', 'generate-preamble-bash.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
expect(preamble).toContain('gstack-spawned-session-status');
|
||||
expect(preamble).not.toContain('OPENCLAW_SESSION');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue