diff --git a/README.md b/README.md index a0d9c40e3..3d6eff08f 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bin/gstack-spawned-session-status b/bin/gstack-spawned-session-status new file mode 100755 index 000000000..8f646661f --- /dev/null +++ b/bin/gstack-spawned-session-status @@ -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 diff --git a/scripts/resolvers/preamble/generate-completion-status.ts b/scripts/resolvers/preamble/generate-completion-status.ts index 0c50da1c7..109f25fd9 100644 --- a/scripts/resolvers/preamble/generate-completion-status.ts +++ b/scripts/resolvers/preamble/generate-completion-status.ts @@ -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. diff --git a/scripts/resolvers/preamble/generate-preamble-bash.ts b/scripts/resolvers/preamble/generate-preamble-bash.ts index d8552421a..3acc27546 100644 --- a/scripts/resolvers/preamble/generate-preamble-bash.ts +++ b/scripts/resolvers/preamble/generate-preamble-bash.ts @@ -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) diff --git a/test/gen-skill-docs.test.ts b/test/gen-skill-docs.test.ts index ffe6ed7d6..fcc8733a3 100644 --- a/test/gen-skill-docs.test.ts +++ b/test/gen-skill-docs.test.ts @@ -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'); diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts index 96bdf54c7..fe0e638d1 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry.test.ts @@ -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'); }); });