G3: give every logging-bin mktemp a ${TMPDIR:-/tmp} template

Bare mktemp, mktemp -t and literal /tmp templates in the learnings,
question and preference logs (plus jsonl-merge, distill, the two
dashboards, /ship's PR body and /document-release's run dir) now use
"${TMPDIR:-/tmp}/<name>.XXXXXX", the form the INV-3 lint accepts. When
the temp file cannot be created, the learnings, question and preference
logs now say the entry was not recorded and how to fix TMPDIR instead of
exiting with only mktemp's own error. Three tests that pinned the bare
PR_BODY_FILE=$(mktemp) and one that pinned the /tmp run dir migrate.

GNU and BSD mktemp already honor TMPDIR without a template, so the
macOS-sandbox drop in #2952 is unverified here; the template makes the
temp location explicit on every mktemp.

Co-authored-by: Davon Johnson <d.johnson@dayofshow.net>
This commit is contained in:
garrytanandDavon Johnson committed 2026-10-03 20:48:58 +00:00
1 parent 214cf0b364
commit 3e55fb8d8a
14 files changed
+77 -14

No files matched your search

+1 -1
View File
@@ -36,7 +36,7 @@ fi
# ─── Fetch aggregated stats from edge function ────────────────
# HTTP status captured (#1947): a backend failure must read as "unknown",
# never as a healthy "Weekly active installs: 0".
TMPBODY="$(mktemp)"
TMPBODY="$(mktemp "${TMPDIR:-/tmp}/gstack-community.XXXXXX")"
trap 'rm -f "$TMPBODY"' EXIT
SUPA_HOST="${SUPABASE_URL#*://}"; SUPA_HOST="${SUPA_HOST%%/*}"
HTTP_CODE="$(_receipted_curl open community-dashboard "$SUPA_HOST" community-pulse-fetch "user-invoked dashboard" --no-payload \
+1 -1
View File
@@ -129,7 +129,7 @@ fi
# Heredoc into temp file (avoids $(cat <<'PROMPT'...) which choked the
# bash parser on apostrophes elsewhere in the script).
DISTILL_PROMPT_FILE=$(mktemp)
DISTILL_PROMPT_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-distill.XXXXXX")
trap 'rm -f "$DISTILL_PROMPT_FILE"' EXIT
cat > "$DISTILL_PROMPT_FILE" <<'PROMPT'
You are gstack dream-cycle distiller. Below are free-text responses the
+1 -1
View File
@@ -45,7 +45,7 @@ BASE="$1"
OURS="$2"
THEIRS="$3"
TMP=$(mktemp /tmp/gstack-jsonl-merge.XXXXXX) || exit 1
TMP=$(mktemp "${TMPDIR:-/tmp}/gstack-jsonl-merge.XXXXXX") || exit 1
trap 'rm -f "$TMP" 2>/dev/null || true' EXIT
python3 - "$BASE" "$OURS" "$THEIRS" > "$TMP" <<'PYEOF'
+1 -1
View File
@@ -24,7 +24,7 @@ INPUT="$1"
# Validate and sanitize input. Errors surface (#1950): stderr is captured and
# printed on failure instead of swallowed — a silent exit 1 here cost Windows
# users every AI-logged learning.
TMPERR=$(mktemp)
TMPERR=$(mktemp "${TMPDIR:-/tmp}/gstack-learnings-log.XXXXXX") || { echo "gstack-learnings-log: could not create a temp file in ${TMPDIR:-/tmp}, so the learning was not recorded. Fix: point TMPDIR at a writable directory." >&2; exit 1; }
trap 'rm -f "$TMPERR"' EXIT
set +e
VALIDATED=$(printf '%s' "$INPUT" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" bun -e "
+1 -1
View File
@@ -43,7 +43,7 @@ mkdir -p "$GSTACK_HOME/projects/$SLUG"
INPUT="$1"
# Validate and enrich from registry.
TMPERR=$(mktemp)
TMPERR=$(mktemp "${TMPDIR:-/tmp}/gstack-question-log.XXXXXX") || { echo "gstack-question-log: could not create a temp file in ${TMPDIR:-/tmp}, so the question was not logged. Fix: point TMPDIR at a writable directory." >&2; exit 1; }
trap 'rm -f "$TMPERR"' EXIT
set +e
VALIDATED=$(printf '%s' "$INPUT" | GSTACK_LIB_DIR="$SCRIPT_DIR/../lib" bun -e "
+1 -1
View File
@@ -143,7 +143,7 @@ do_write() {
fi
ensure_file
local TMPERR
TMPERR=$(mktemp)
TMPERR=$(mktemp "${TMPDIR:-/tmp}/gstack-question-preference.XXXXXX") || { echo "gstack-question-preference: could not create a temp file in ${TMPDIR:-/tmp}, so the preference was not saved. Fix: point TMPDIR at a writable directory." >&2; return 1; }
# Use function-local cleanup via RETURN trap so variable lookup only happens
# while the function is on the stack (avoids EXIT-trap unbound-var race).
trap "rm -f '$TMPERR'" RETURN
+1 -1
View File
@@ -47,7 +47,7 @@ fi
# Fetch with the HTTP status captured (#1947). A backend failure must read
# as "unknown", never as a healthy "0 attacks" — fake zeros on a security
# surface are indistinguishable from good news.
TMPBODY="$(mktemp)"
TMPBODY="$(mktemp "${TMPDIR:-/tmp}/gstack-security.XXXXXX")"
trap 'rm -f "$TMPBODY"' EXIT
SUPA_HOST="${SUPABASE_URL#*://}"; SUPA_HOST="${SUPA_HOST%%/*}"
HTTP_CODE="$(_receipted_curl open security-dashboard "$SUPA_HOST" community-pulse-fetch "user-invoked dashboard" --no-payload \
@@ -245,7 +245,7 @@ near the write-back.
1. Create a private run directory, then replace **every** `<run-dir>` below with its printed absolute path. This literal path survives separate shell calls; do not substitute `$$`.
```bash
mktemp -d /tmp/gstack-doc-release-XXXXXXXX
mktemp -d "${TMPDIR:-/tmp}/gstack-doc-release-XXXXXXXX"
```
Fetch the existing PR/MR body using the platform from the shared Step 0. If no PR/MR exists, skip body/title updates and continue to the summary.
+1 -1
View File
@@ -119,7 +119,7 @@ saved section file path as `DOCS_SECTION_FILE`.
REDACT_VIS=$(~/.claude/skills/gstack/bin/gstack-config get redact_repo_visibility 2>/dev/null)
[ -z "$REDACT_VIS" ] && REDACT_VIS=$(gh repo view --json visibility -q .visibility 2>/dev/null | tr 'A-Z' 'a-z')
REDACT_VIS="${REDACT_VIS:-unknown}"
PR_BODY_FILE=$(mktemp) || { echo "ERROR: mktemp failed — cannot scan the PR body; refusing to create the PR unscanned." >&2; exit 1; }
PR_BODY_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-pr-body.XXXXXX") || { echo "ERROR: mktemp failed — cannot scan the PR body; refusing to create the PR unscanned." >&2; exit 1; }
{ cat <<'PR_BODY_EOF'
<PR body from above, through the "## Documentation" heading line>
PR_BODY_EOF
+1 -1
View File
@@ -35,7 +35,7 @@ describe("/document-release redaction", () => {
expect(RELEASE).toMatch(/exit 3 \(HIGH\).*do NOT edit/i);
});
test("separate shell calls share an explicit run directory and never re-read raw tracker text", () => {
expect(RELEASE).toContain('mktemp -d /tmp/gstack-doc-release-XXXXXXXX');
expect(RELEASE).toContain('mktemp -d "${TMPDIR:-/tmp}/gstack-doc-release-XXXXXXXX"');
expect(RELEASE).not.toContain('/tmp/gstack-pr-body-$$');
expect(RELEASE).not.toContain('<paste the file contents here>');
expect(RELEASE).toContain('pathlib.Path(sys.argv[1]).read_text()');
+33
View File
@@ -321,3 +321,36 @@ describe('gstack-learnings-search edge cases', () => {
expect(output).toContain('confidence: 0/10');
});
});
// G3 (#2952): the temp file honors TMPDIR, and a temp-file failure says the
// learning was not recorded instead of exiting with only mktemp's own error.
describe('G3: learnings-log temp file and TMPDIR', () => {
test('logs through a private TMPDIR', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-learn-tmpdir-'));
try {
execSync(`${BIN}/gstack-learnings-log '{"skill":"review","type":"pitfall","key":"tmpdir-ok","insight":"x","confidence":5,"source":"observed"}'`, {
cwd: ROOT, env: { ...process.env, GSTACK_HOME: tmpDir, TMPDIR: tmp }, encoding: 'utf-8', timeout: 15000,
});
expect(fs.readFileSync(findLearningsFile()!, 'utf-8')).toContain('tmpdir-ok');
expect(fs.readdirSync(tmp)).toEqual([]);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('an unusable TMPDIR fails loudly and names the dropped learning', () => {
let status = 0;
let stderr = '';
try {
execSync(`${BIN}/gstack-learnings-log '{"skill":"review","type":"pitfall","key":"tmpdir-bad","insight":"x","confidence":5,"source":"observed"}'`, {
cwd: ROOT, env: { ...process.env, GSTACK_HOME: tmpDir, TMPDIR: path.join(tmpDir, 'does-not-exist') }, encoding: 'utf-8', stdio: 'pipe', timeout: 15000,
});
} catch (e: any) {
status = e.status;
stderr = String(e.stderr);
}
expect(status).not.toBe(0);
expect(stderr).toContain('the learning was not recorded');
expect(stderr).toContain('TMPDIR');
});
});
@@ -75,9 +75,10 @@ describe("#2679: skill-content mktemp guards", () => {
expect(rendered).toMatch(/REDACT_FILE=\$\(mktemp\)\s*\|\|\s*\{[^}]*exit 1/);
});
test("ship pr-body template guards PR_BODY_FILE=$(mktemp) with a loud exit", () => {
test("ship pr-body template guards PR_BODY_FILE=$(mktemp ...) with a loud exit", () => {
const body = readScript("ship/sections/pr-body.md.tmpl");
expect(body).toMatch(/PR_BODY_FILE=\$\(mktemp\)\s*\|\|\s*\{[^}]*exit 1/);
// G3 (#2952): the mktemp now carries a ${TMPDIR:-/tmp} template.
expect(body).toMatch(/PR_BODY_FILE=\$\(mktemp "\$\{TMPDIR:-\/tmp\}\/[^"]+"\)\s*\|\|\s*\{[^}]*exit 1/);
});
test("ship pr-body GitLab path sends the SCANNED file, never a re-rendered heredoc", () => {
@@ -151,3 +152,32 @@ describe("PR #1169 bug #5: supabase/verify-rls.sh mktemp fallback", () => {
expect(guard).not.toBeNull();
});
});
// G3 (#2952): bare `mktemp`, `mktemp -t` and literal `/tmp/` templates ignore
// the TMPDIR a sandboxed agent shell sets, and a failed mktemp under `set -e`
// silently dropped the learning or question being logged.
describe("G3: mktemp honors TMPDIR in logging bins and lane-owned skill bash", () => {
const FILES = [
"bin/gstack-learnings-log",
"bin/gstack-question-log",
"bin/gstack-question-preference",
"bin/gstack-jsonl-merge",
"bin/gstack-distill-free-text",
"bin/gstack-community-dashboard",
"bin/gstack-security-dashboard",
"ship/sections/pr-body.md.tmpl",
"document-release/sections/release-body.md.tmpl",
];
for (const rel of FILES) {
test(`${rel}: every temp-dir mktemp uses a \${TMPDIR:-/tmp} template`, () => {
const calls = [...readScript(rel).matchAll(/(?:\$\(|^[ \t]*)(mktemp\b[^\n)]*)/gm)].map((m) => m[1]);
expect(calls.length).toBeGreaterThan(0);
for (const call of calls) {
expect(call).not.toMatch(/^mktemp\s*$/);
expect(call).not.toMatch(/^mktemp\s+(-d\s+)?-t\b/);
expect(call).not.toMatch(/^mktemp\s+(-d\s+)?["']?\/tmp\//);
expect(call).toMatch(/\$\{TMPDIR:-\/tmp\}\//);
}
});
}
});
+1 -1
View File
@@ -52,7 +52,7 @@ exec ${JSON.stringify(process.execPath)} ${JSON.stringify(scanner)} "$@"
test('publication composes the saved documentation section unchanged and refuses to scan without it', () => {
const block = template.match(/```bash\n(: "\$\{NEW_TITLE:[\s\S]*?)\n```/)?.[1]!;
const compose = block.slice(block.indexOf('PR_BODY_FILE=$(mktemp)'), block.indexOf('~/.claude/skills/gstack/bin/gstack-redact --from-file'));
const compose = block.slice(block.indexOf('PR_BODY_FILE=$(mktemp'), block.indexOf('~/.claude/skills/gstack/bin/gstack-redact --from-file'));
const root = mkdtempSync(join(tmpdir(), 'ship-compose-'));
try {
const section = '**Status:** current — no edits.\n\n- Diagram drift: none (no diagrams in any doc).';
+1 -1
View File
@@ -108,7 +108,7 @@ describe('workflow judge excerpts', () => {
test('ship publishes existing PRs only after shared body composition and scan', () => {
const text = readWorkflowExcerpt('ship/SKILL.md', '# Ship:', '## Important Rules');
const publish = text.slice(text.indexOf('## Step 19:'), text.indexOf('## Step 20:'));
const compose = publish.indexOf('PR_BODY_FILE=$(mktemp)');
const compose = publish.indexOf('PR_BODY_FILE=$(mktemp');
const scan = publish.indexOf('gstack-redact --from-file "$PR_BODY_FILE"');
const edit = publish.indexOf('gh pr edit --body-file');
expect(compose).toBeGreaterThan(0);