From e2cf27ff5e6a9b18d8494b3ae02f45acd5872d13 Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 3 Oct 2026 21:13:53 +0000 Subject: [PATCH] G6: context-save computes session duration on Linux (#2704) The duration step parsed 'ps -o lstart=' with BSD-only 'date -jf', so on Linux session_duration_s was always unknown. It now falls back to GNU 'date -d', and never parses an empty start (GNU reads that as midnight). Ported onto the current block instead of #2704's ~/.gstack/sessions mtime, which bypassed the state root. Co-authored-by: Evgenii Lopatin --- context-save/SKILL.md.tmpl | 5 ++++- test/context-save-hardening.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/context-save/SKILL.md.tmpl b/context-save/SKILL.md.tmpl index d9bfef977..d34aa3c22 100644 --- a/context-save/SKILL.md.tmpl +++ b/context-save/SKILL.md.tmpl @@ -94,7 +94,10 @@ Try to determine how long this session has been active: if [ -n "$_TEL_START" ]; then START_EPOCH="$_TEL_START" elif [ -n "$PPID" ]; then - START_EPOCH=$(ps -o lstart= -p $PPID 2>/dev/null | xargs -I{} date -jf "%c" "{}" "+%s" 2>/dev/null || echo "") + _LSTART=$(ps -o lstart= -p "$PPID" 2>/dev/null) + # BSD date (macOS) parses with -jf; GNU date (Linux) with -d. Never parse an + # empty start: GNU date -d "" means today at midnight. + [ -n "$_LSTART" ] && START_EPOCH=$(date -jf "%c" "$_LSTART" "+%s" 2>/dev/null || date -d "$_LSTART" "+%s" 2>/dev/null || echo "") fi if [ -n "$START_EPOCH" ]; then NOW=$(date +%s) diff --git a/test/context-save-hardening.test.ts b/test/context-save-hardening.test.ts index 11f9c725c..11bc66242 100644 --- a/test/context-save-hardening.test.ts +++ b/test/context-save-hardening.test.ts @@ -432,3 +432,29 @@ describe('migration v1.1.3.0: HOME guard', () => { expect(result.stdout.toString().trim()).toBe(''); }); }); + +// G6 rider #2704: the duration block parsed `ps -o lstart=` with BSD-only +// `date -jf`, so on Linux session_duration_s was always "unknown". Runs the +// block extracted from the template, so it cannot drift from the skill. +describe('context-save session duration (#2704)', () => { + const tmpl = fs.readFileSync(path.join(ROOT, 'context-save', 'SKILL.md.tmpl'), 'utf-8'); + const step = tmpl.slice(tmpl.indexOf('### Step 3: Compute session duration')); + const block = step.match(/```bash\n([\s\S]*?)\n```/)![1]; + const psWorks = spawnSync('bash', ['-c', 'ps -o lstart= -p $$'], { encoding: 'utf-8', timeout: 5000 }).stdout.trim() !== ''; + + test.skipIf(!psWorks)('computes a numeric duration from the parent process start time', () => { + const r = spawnSync('bash', ['-c', block], { env: { PATH: process.env.PATH! }, encoding: 'utf-8', timeout: 10_000 }); + expect(r.stdout).toMatch(/^SESSION_DURATION_S=\d+$/m); + }); + + test('an unreadable parent start says unknown instead of inventing midnight', () => { + const shim = fs.mkdtempSync(path.join(os.tmpdir(), 'ctxsave-ps-')); + try { + fs.writeFileSync(path.join(shim, 'ps'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); + const r = spawnSync('bash', ['-c', block], { env: { PATH: `${shim}:${process.env.PATH}` }, encoding: 'utf-8', timeout: 10_000 }); + expect(r.stdout.trim()).toBe('SESSION_DURATION_S=unknown'); + } finally { + fs.rmSync(shim, { recursive: true, force: true }); + } + }); +});