diff --git a/package.json b/package.json index 77a7de2d..6ee6e7ea 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "sdk:test": "jest --testPathIgnorePatterns /node_modules/ tests/e2e/ --passWithNoTests", "check:egress": "node scripts/check-egress.cjs", "check:doc-version": "node scripts/check-doc-version.cjs", + "check:workspace": "node scripts/check-workspace.cjs", "test:prod-bundle-key": "node scripts/check-prod-supabase-bundle.mjs", "verify": "tsc --noEmit && jest --testPathIgnorePatterns /node_modules/ tests/e2e/ --passWithNoTests", "test:wrapper-readiness": "node tests/e2e/wrapper-reach-preflight.mjs && jest tests/e2e/wrapper-readiness.test.ts", diff --git a/scripts/check-workspace.cjs b/scripts/check-workspace.cjs new file mode 100644 index 00000000..6effbf21 --- /dev/null +++ b/scripts/check-workspace.cjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +'use strict'; +/** + * Refuse repo-specific work in the WRONG checkout — a durable guard for the launcher-misrouting defect + * (DECISION 3, #679 sprint). The session launcher has bound a TrustShell task to the old `ihh-ica` project + * while the working dir was the `trustshell-tl` worktree, so `start_session` resolved the wrong repo and + * work risked landing (or silently failing) against the wrong history. The session-binding itself is + * app-level config this script cannot change; this is the in-repo assertion so any loop or session can gate + * its own work and fail LOUDLY instead of silently misrouting: + * + * node scripts/check-workspace.cjs # expects origin = DealAppSeo/trustshell + * node scripts/check-workspace.cjs owner/repo # expects that repo + * TRUSTSHELL_EXPECT_REPO=owner/repo node scripts/check-workspace.cjs + * + * Exit 0 when origin matches, 1 (with a loud message) otherwise. No deps; safe to call at task startup. + */ +const { execSync } = require('node:child_process'); + +/** PURE. Does a git remote URL (https or ssh, optional `.git` / trailing slash) point at `owner/repo`? */ +function workspaceMatches(remoteUrl, expected) { + const m = String(remoteUrl) + .trim() + .match(/github\.com[/:]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i); + return m ? m[1].toLowerCase() === String(expected).toLowerCase() : false; +} + +/** Resolve the current origin + branch and assert it is `expected`. Returns the process exit code. */ +function main(argv, env, run) { + const expected = argv[2] || env.TRUSTSHELL_EXPECT_REPO || 'DealAppSeo/trustshell'; + const sh = run || ((cmd) => execSync(cmd, { encoding: 'utf8' }).trim()); + let remote = ''; + let branch = ''; + try { remote = sh('git remote get-url origin'); } catch { /* no remote */ } + try { branch = sh('git rev-parse --abbrev-ref HEAD'); } catch { /* detached / no repo */ } + if (!workspaceMatches(remote, expected)) { + process.stderr.write( + `WORKSPACE MISROUTED: origin is "${remote || '(none)'}", expected ${expected}. ` + + `Refusing repo-specific work in the wrong checkout — cd to the ${expected} worktree first.\n`, + ); + return 1; + } + process.stderr.write(`workspace OK: ${expected}${branch ? ` @ ${branch}` : ''}\n`); + return 0; +} + +module.exports = { workspaceMatches, main }; + +if (require.main === module) process.exit(main(process.argv, process.env)); diff --git a/tests/check-workspace.test.ts b/tests/check-workspace.test.ts new file mode 100644 index 00000000..f33eaae3 --- /dev/null +++ b/tests/check-workspace.test.ts @@ -0,0 +1,38 @@ +/** + * The workspace guard refuses repo-specific work in the wrong checkout (DECISION 3, #679 sprint): the launcher + * bound a TrustShell task to the old ihh-ica project. `workspaceMatches` is pure; `main` is run with an injected + * shell so the test asserts both exit paths without depending on the checkout it runs in. + */ +const { workspaceMatches, main } = require('../scripts/check-workspace.cjs') as { + workspaceMatches: (remoteUrl: string, expected: string) => boolean; + main: (argv: string[], env: NodeJS.ProcessEnv, run?: (cmd: string) => string) => number; +}; + +describe('check-workspace: refuse the wrong checkout (DECISION 3, #679)', () => { + it('matches https and ssh forms of the expected repo, with or without .git / case / trailing slash', () => { + for (const u of [ + 'https://github.com/DealAppSeo/trustshell.git', + 'https://github.com/DealAppSeo/trustshell', + 'git@github.com:DealAppSeo/trustshell.git', + 'https://github.com/dealappseo/TrustShell/', + ]) { + expect(workspaceMatches(u, 'DealAppSeo/trustshell')).toBe(true); + } + }); + + it('rejects the ihh-ica misroute, a different repo, and empty/garbage remotes', () => { + expect(workspaceMatches('https://github.com/DealAppSeo/ihh-ica.git', 'DealAppSeo/trustshell')).toBe(false); + expect(workspaceMatches('git@github.com:DealAppSeo/repid-engine.git', 'DealAppSeo/trustshell')).toBe(false); + expect(workspaceMatches('', 'DealAppSeo/trustshell')).toBe(false); + expect(workspaceMatches('not-a-url', 'DealAppSeo/trustshell')).toBe(false); + }); + + it('main() exits 0 on a matching origin and 1 (loudly) on a misroute — injected shell, no real git', () => { + const asTrustshell = (cmd: string) => (cmd.includes('remote get-url') ? 'https://github.com/DealAppSeo/trustshell.git' : 'main'); + const asIhhIca = (cmd: string) => (cmd.includes('remote get-url') ? 'https://github.com/DealAppSeo/ihh-ica.git' : 'ica-build'); + expect(main(['node', 'check-workspace.cjs'], {} as NodeJS.ProcessEnv, asTrustshell)).toBe(0); + expect(main(['node', 'check-workspace.cjs'], {} as NodeJS.ProcessEnv, asIhhIca)).toBe(1); + // explicit expected-repo arg wins + expect(main(['node', 'check-workspace.cjs', 'DealAppSeo/repid-engine'], {} as NodeJS.ProcessEnv, (cmd) => (cmd.includes('remote get-url') ? 'git@github.com:DealAppSeo/repid-engine.git' : 'main'))).toBe(0); + }); +});