specrails-desktop 2.24.0 → 2.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specrails-desktop",
3
- "version": "2.24.0",
3
+ "version": "2.24.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,26 +36,35 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.claudeConfigPath = claudeConfigPath;
37
37
  exports.markProjectsTrusted = markProjectsTrusted;
38
38
  exports.ensureClaudeTrusted = ensureClaudeTrusted;
39
- exports.__resetClaudeTrustMemoForTest = __resetClaudeTrustMemoForTest;
40
39
  /**
41
40
  * Pre-trust specrails-managed spawn directories in the user's `~/.claude.json`
42
- * so a HEADLESS claude rail/job spawn honours the `.claude/settings.json`
43
- * `permissions.allow` list the framework overlay places in the worktree.
44
- *
45
- * Why: claude ignores a workspace's `permissions.allow` entries until that
46
- * project directory has been "trusted" (the interactive trust dialog, or
47
- * `projects[<dir>].hasTrustDialogAccepted: true` in `~/.claude.json`). specrails
48
- * spawns claude headlessly in FRESH per-run worktrees / workspaces that were
49
- * never opened interactively, so every isolated rail run logged
41
+ * so a HEADLESS claude rail/job spawn does NOT log the noisy
50
42
  * "Ignoring N permissions.allow entries … this workspace has not been trusted"
51
- * and ran with its pre-approved permissions silently dropped. We manage those
52
- * directories, so pre-marking them trusted is correct and safe.
43
+ * warning.
44
+ *
45
+ * IMPORTANT — this is cosmetic, not load-bearing. Every claude spawn already
46
+ * carries `--dangerously-skip-permissions` (see `claude-adapter.ts` COMMON_FLAGS),
47
+ * which bypasses the permission engine entirely — so a workspace's
48
+ * `.claude/settings.json` `permissions.allow` list is moot on these spawns
49
+ * whether the dir is trusted or not. Marking the dir trusted only silences the
50
+ * warning. (It WOULD become functionally load-bearing if we ever dropped
51
+ * `--dangerously-skip-permissions` and relied on the allow-list.)
52
+ *
53
+ * Why re-assert on EVERY spawn (no persistent memo): `~/.claude.json` is a
54
+ * single ~200KB file holding ALL projects that many concurrent claude processes
55
+ * rewrite wholesale (each carries its own in-memory snapshot). Our surgical
56
+ * `true` for one dir is routinely clobbered by another process writing the whole
57
+ * file back with a stale `false` — a lost update. A one-shot per-process memo
58
+ * (the old design) therefore left the flag stuck `false` for the rest of the
59
+ * process lifetime after the first clobber. Re-asserting immediately before each
60
+ * spawn re-flips a clobbered `false → true` so this spawn reads `true` at
61
+ * startup. It cannot fully win the race against concurrent whole-file writers,
62
+ * but the failure mode is only a cosmetic warning, so best-effort is enough.
53
63
  *
54
64
  * Surgical + best-effort: read → set only `hasTrustDialogAccepted` on the
55
65
  * relevant `projects[<realpath>]` keys → atomic temp+rename. Never throws, never
56
66
  * touches any other field, and only writes when something actually changed.
57
- * claude-only (the trust/allow model is a claude concept). Memoized per path so
58
- * a multi-step run does the I/O once, before the first spawn in that dir.
67
+ * claude-only (the trust/allow model is a claude concept).
59
68
  */
60
69
  const fs = __importStar(require("fs"));
61
70
  const path = __importStar(require("path"));
@@ -122,17 +131,21 @@ function markProjectsTrusted(configPath, dirs) {
122
131
  }
123
132
  return changed;
124
133
  }
125
- // Per-path memo so a multi-step run writes at most once per unique dir.
126
- const _trusted = new Set();
127
134
  /**
128
- * Ensure the claude spawn directories are trusted, once per process per dir.
129
- * No-op for non-claude providers and best-effort otherwise (a failure only
130
- * means the pre-approved permissions stay dropped never blocks a spawn).
135
+ * Ensure the claude spawn directories are trusted, RE-ASSERTED on every call
136
+ * (no persistent memo see the module header for why: concurrent whole-file
137
+ * writers clobber our flag back to `false`, and a one-shot memo would leave it
138
+ * stuck). No-op for non-claude providers and best-effort otherwise (a failure
139
+ * only leaves the cosmetic trust warning — never blocks a spawn).
140
+ *
141
+ * `markProjectsTrusted` reads the current on-disk value and writes ONLY when a
142
+ * dir is missing / `false`, so a call where the flag is already `true` is a
143
+ * cheap read with no write.
131
144
  */
132
145
  function ensureClaudeTrusted(provider, dirs, home) {
133
146
  if (provider !== 'claude')
134
147
  return;
135
- const todo = dirs.filter((d) => !!d && !_trusted.has(canonical(d)));
148
+ const todo = dirs.filter((d) => !!d);
136
149
  if (todo.length === 0)
137
150
  return;
138
151
  try {
@@ -141,10 +154,4 @@ function ensureClaudeTrusted(provider, dirs, home) {
141
154
  catch {
142
155
  /* best-effort */
143
156
  }
144
- for (const d of todo)
145
- _trusted.add(canonical(d));
146
- }
147
- /** Test-only: clear the per-path memo. */
148
- function __resetClaudeTrustMemoForTest() {
149
- _trusted.clear();
150
157
  }
@@ -1,9 +1,57 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FETCH_ORIGIN_TTL_MS = void 0;
4
+ exports.__resetFetchOriginCache = __resetFetchOriginCache;
5
+ exports.fetchOrigin = fetchOrigin;
3
6
  exports.repoDefaultBranch = repoDefaultBranch;
4
7
  exports.currentBranch = currentBranch;
5
8
  exports.isValidBranchName = isValidBranchName;
6
9
  exports.resolveIntegrationBranch = resolveIntegrationBranch;
10
+ exports.resolveWorktreeBaseRef = resolveWorktreeBaseRef;
11
+ /** How long a fetch outcome (success OR failure) is reused for the same repo
12
+ * before a fresh `git fetch origin` is attempted again. Exists so a burst of
13
+ * near-simultaneous launches against the SAME repo — e.g. a "Launch all"
14
+ * batch, which is N independent HTTP requests with no shared server-side
15
+ * transaction (see design.md Decision 2) — performs one real fetch instead of
16
+ * one per rail. */
17
+ exports.FETCH_ORIGIN_TTL_MS = 15_000;
18
+ const fetchCache = new Map();
19
+ /** Test-only: clear the fetch dedup cache so tests never leak state across
20
+ * cases (mirrors `__resetRepoLocks` in repo-lock.ts). */
21
+ function __resetFetchOriginCache() {
22
+ fetchCache.clear();
23
+ }
24
+ /**
25
+ * `git fetch origin` against `repoDir` — updates ONLY `refs/remotes/origin/*`,
26
+ * never the checked-out local branch or working tree (Git itself refuses to
27
+ * touch either via a plain fetch). Never throws: any non-zero exit or runner
28
+ * rejection resolves to `{ ok: false, error }` so callers can degrade
29
+ * gracefully instead of failing the launch.
30
+ *
31
+ * De-duped per `repoDir` for `FETCH_ORIGIN_TTL_MS`: a call within the window
32
+ * of the last attempt (success OR failure) for the same repo reuses that
33
+ * outcome instead of spawning a new `git fetch` process. `now` is injectable
34
+ * so tests can control TTL expiry without real timers.
35
+ */
36
+ async function fetchOrigin(git, repoDir, now = Date.now) {
37
+ const cached = fetchCache.get(repoDir);
38
+ const nowMs = now();
39
+ if (cached && nowMs - cached.at < exports.FETCH_ORIGIN_TTL_MS)
40
+ return cached.result;
41
+ const result = (async () => {
42
+ try {
43
+ const r = await git.run(['fetch', 'origin'], repoDir);
44
+ if (r.code === 0)
45
+ return { ok: true };
46
+ return { ok: false, error: r.stderr.trim() || r.stdout.trim() || `exit ${r.code}` };
47
+ }
48
+ catch (err) {
49
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
50
+ }
51
+ })();
52
+ fetchCache.set(repoDir, { at: nowMs, result });
53
+ return result;
54
+ }
7
55
  /** The repo's default branch via `origin/HEAD` → the bare branch name, or null. */
8
56
  async function repoDefaultBranch(git, repoDir) {
9
57
  const r = await git.run(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], repoDir);
@@ -61,3 +109,35 @@ async function resolveIntegrationBranch(git, input) {
61
109
  // Detached / no remote / unresolvable → the literal HEAD (legacy-identical).
62
110
  return { branch: 'HEAD', source: 'head-fallback' };
63
111
  }
112
+ /**
113
+ * Decide the actual `baseRef` a worktree should branch from, given the
114
+ * already-resolved integration branch and whether `fetchOrigin` succeeded.
115
+ *
116
+ * `explicit` is a launch-time override the caller chose on purpose — it is
117
+ * NEVER remote-prefixed and NEVER existence-checked (see proposal.md Out of
118
+ * Scope). Every other source (`repo-default`, `project-setting`,
119
+ * `head-fallback`) uses the fetched remote-tracking ref `origin/<branch>`
120
+ * ONLY when the fetch succeeded AND that remote branch actually exists —
121
+ * guards a `project-setting` branch that was never pushed, which would
122
+ * otherwise turn a working local-only setup into a broken `git worktree add`.
123
+ * Any failure of either check falls back to today's bare local branch name,
124
+ * with a human-readable `warning` the caller can log/broadcast.
125
+ */
126
+ async function resolveWorktreeBaseRef(git, input) {
127
+ const { repoDir, integration, fetchOk } = input;
128
+ if (integration.source === 'explicit') {
129
+ return { baseRef: integration.branch, usedRemote: false };
130
+ }
131
+ if (!fetchOk) {
132
+ return { baseRef: integration.branch, usedRemote: false, warning: 'git fetch origin failed; using local ref' };
133
+ }
134
+ const exists = await git.run(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${integration.branch}`], repoDir);
135
+ if (exists.code === 0) {
136
+ return { baseRef: `origin/${integration.branch}`, usedRemote: true };
137
+ }
138
+ return {
139
+ baseRef: integration.branch,
140
+ usedRemote: false,
141
+ warning: `origin/${integration.branch} not found; using local ref`,
142
+ };
143
+ }
@@ -23,10 +23,18 @@ exports.publishDraftPr = publishDraftPr;
23
23
  */
24
24
  const child_process_1 = require("child_process");
25
25
  const git_guardrails_1 = require("./git-guardrails");
26
+ const win_spawn_1 = require("./util/win-spawn");
26
27
  exports.defaultExec = {
27
28
  run(cmd, args, cwd) {
28
29
  return new Promise((resolve) => {
29
- (0, child_process_1.execFile)(cmd, args, { cwd, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
30
+ // `windowsSpawnEnv()` backfills SystemRoot / ComSpec / USERPROFILE, which a
31
+ // GUI-launched / pkg-stripped Windows sidecar can lack — without them the
32
+ // `git`/`gh` child (and the `sh -c` the `!gh …` credential helper runs) can
33
+ // fail to start. NOT `GIT_EXEC_ENV`: that hardened env disables credentials
34
+ // (GIT_ASKPASS=echo / GIT_TERMINAL_PROMPT=0), which the push must keep. No-op
35
+ // on POSIX (returns process.env), so mac/Linux behaviour is byte-identical.
36
+ const env = (0, win_spawn_1.windowsSpawnEnv)();
37
+ (0, child_process_1.execFile)(cmd, args, { cwd, env, maxBuffer: 16 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
30
38
  const code = err && typeof err.code === 'number' ? err.code : err ? 1 : 0;
31
39
  resolve({ code, stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' });
32
40
  });
@@ -45,7 +53,22 @@ async function publishDraftPr(exec, input) {
45
53
  // Guardrail: never force-push, never push the integration branch itself.
46
54
  const pushArgs = ['push', '-u', remote, branch];
47
55
  (0, git_guardrails_1.assertGitAllowed)('git', pushArgs, { protectedBranch: baseBranch });
48
- const push = await exec.run('git', pushArgs, repoDir);
56
+ let push = await exec.run('git', pushArgs, repoDir);
57
+ if (push.code !== 0 && isCredentialFailure(push)) {
58
+ // The push failed authenticating over HTTPS — typically the machine's
59
+ // configured credential helper (e.g. git-credential-osxkeychain) is not on
60
+ // the app's PATH (GUI-launch PATH divergence: the sidecar inherits launchd's
61
+ // PATH, not the user's login shell). Retry borrowing gh's credentials — the
62
+ // SAME auth we use for `gh pr create` below, so if the PR can be created the
63
+ // push can succeed — instead of depending on a machine credential helper.
64
+ // `-c credential.helper=` first RESETS the (broken) inherited helper list,
65
+ // then adds gh's. Harmless for SSH remotes (git ignores credential.helper).
66
+ // The semantic push (refspec/remote) is unchanged, so the guardrail asserted
67
+ // above still holds; no-op when gh is absent (retry fails the same way).
68
+ const retry = await exec.run('git', ['-c', 'credential.helper=', '-c', 'credential.helper=!gh auth git-credential', ...pushArgs], repoDir);
69
+ if (retry.code === 0)
70
+ push = retry;
71
+ }
49
72
  if (push.code !== 0) {
50
73
  return { state: 'local-only', branch, reason: reasonFrom(push) };
51
74
  }
@@ -66,3 +89,18 @@ function reasonFrom(r) {
66
89
  const msg = (r.stderr.trim() || r.stdout.trim()).split('\n')[0];
67
90
  return msg || `exit ${r.code}`;
68
91
  }
92
+ /**
93
+ * True when a failed `git push` looks like an HTTPS credential/authentication
94
+ * problem (as opposed to a rejected ref, missing remote, network, etc.) — the
95
+ * only class worth retrying through gh's credential helper. Matches the
96
+ * canonical git strings, including the "'credential-<helper>' is not a git
97
+ * command" case that happens when the configured helper binary isn't on PATH.
98
+ */
99
+ function isCredentialFailure(r) {
100
+ const s = `${r.stderr}\n${r.stdout}`.toLowerCase();
101
+ return (s.includes('could not read username') ||
102
+ s.includes('could not read password') ||
103
+ s.includes('authentication failed') ||
104
+ s.includes('credential-') ||
105
+ s.includes('terminal prompts disabled'));
106
+ }
@@ -211,6 +211,17 @@ async function launchIsolatedRail(input, io = {}) {
211
211
  let integration;
212
212
  const allocated = [];
213
213
  await (0, repo_lock_1.withRepoLock)(baseRepo, async () => {
214
+ // Bring the repo's remote-tracking refs up to date BEFORE resolving the
215
+ // integration branch or allocating any worktree — otherwise `git worktree
216
+ // add -b <branch> <path> <bare-name>` resolves against whatever (possibly
217
+ // stale) commit the user's LOCAL branch happens to be at. `fetchOrigin` only
218
+ // ever touches `refs/remotes/origin/*`; it never mutates the checked-out
219
+ // branch/working tree. De-duped per repo for a short TTL so a "Launch all"
220
+ // batch (N independent launch requests for the same repo, serialized by
221
+ // this same withRepoLock) performs one real fetch, not one per rail. A
222
+ // failed fetch (no network / no remote / auth error) never blocks the
223
+ // launch — resolveWorktreeBaseRef below degrades to the local ref.
224
+ const fetchResult = await (0, integration_branch_1.fetchOrigin)(git, baseRepo);
214
225
  // Resolve the project's designated integration branch ONCE, and branch every
215
226
  // ticket's worktree off it (not the ambient HEAD). Empty setting → auto-resolve
216
227
  // (repo default → HEAD fallback). See server/integration-branch.ts.
@@ -218,6 +229,20 @@ async function launchIsolatedRail(input, io = {}) {
218
229
  repoDir: baseRepo,
219
230
  projectSetting: (0, db_1.getProjectSettings)(ctx.db).integrationBranch,
220
231
  });
232
+ // Prefer the freshly-fetched remote-tracking ref (origin/<branch>) over the
233
+ // bare local name for repo-default/project-setting sources — see
234
+ // resolveWorktreeBaseRef for the exact fallback policy. `explicit` is left
235
+ // completely untouched (rare, launch-time-chosen override).
236
+ const worktreeBaseRef = await (0, integration_branch_1.resolveWorktreeBaseRef)(git, {
237
+ repoDir: baseRepo, integration, fetchOk: fetchResult.ok,
238
+ });
239
+ if (worktreeBaseRef.warning) {
240
+ console.warn(`[rail-isolated] ${worktreeBaseRef.warning} (repo ${baseRepo})`);
241
+ try {
242
+ ctx.broadcast({ type: 'rail.fetch_degraded', projectId: ctx.project.id, railIndex, warning: worktreeBaseRef.warning });
243
+ }
244
+ catch { /* non-fatal, mirrors notifyOverlayDegraded's broadcast guard below */ }
245
+ }
221
246
  if (prMode) {
222
247
  prDeliveryId = (0, rail_pr_store_1.createPrDelivery)(ctx.db, {
223
248
  railIndex,
@@ -251,7 +276,7 @@ async function launchIsolatedRail(input, io = {}) {
251
276
  };
252
277
  try {
253
278
  for (const unit of units) {
254
- const handle = await create(git, { repoDir: baseRepo, worktreesRoot, slug, ticketId: unit.ticketId, baseRef: integration.branch, branch: unitBranchName(unit.ticketId) });
279
+ const handle = await create(git, { repoDir: baseRepo, worktreesRoot, slug, ticketId: unit.ticketId, baseRef: worktreeBaseRef.baseRef, branch: unitBranchName(unit.ticketId) });
255
280
  // Per-run overlay: merge-link the framework surface the checkout didn't
256
281
  // bring into the worktree (idempotent; resume-safe via its manifest).
257
282
  let overlayExcludes = [];
@@ -23,3 +23,31 @@ const path_1 = __importDefault(require("path"));
23
23
  if (!process.env.SPECRAILS_REGISTRY_HOME) {
24
24
  process.env.SPECRAILS_REGISTRY_HOME = (0, fs_1.mkdtempSync)(path_1.default.join(os_1.default.tmpdir(), 'specrails-desktop-test-home-'));
25
25
  }
26
+ /**
27
+ * Hermetic env: a test run must NEVER inherit relocation / desktop-mode env
28
+ * vars from the host process. When the suite runs INSIDE a Specrails rail spawn
29
+ * (e.g. a `/specrails:implement` job running the verification step), that parent
30
+ * process exports `SPECRAILS_REPO_DIR` etc., which leak straight through the
31
+ * legacy (non-relocated) spawn path — the managers spread `process.env` into the
32
+ * child env — and break the "LEGACY: no relocation env" assertions in
33
+ * chat-manager / queue-manager tests. No test relies on ambient inheritance: the
34
+ * relocated-branch tests activate relocation via the registry + populated
35
+ * workspace gate, and the path-resolver / setup-prerequisites tests set their
36
+ * own vars per-case in `beforeEach` (which run AFTER this file), so a
37
+ * blanket-delete here is safe and any per-case setter still wins. Only
38
+ * `SPECRAILS_REPO_DIR` breaks a test today; the rest are defense-in-depth (they
39
+ * would leak identically the moment a future test asserts on them). Do NOT
40
+ * delete `SPECRAILS_REGISTRY_HOME` — the block above sets it intentionally.
41
+ */
42
+ for (const k of [
43
+ 'SPECRAILS_REPO_DIR',
44
+ 'SPECRAILS_IS_DESKTOP',
45
+ 'SPECRAILS_BUNDLED_RUNTIMES_PATH',
46
+ 'SPECRAILS_TICKETS_PATH',
47
+ 'SPECRAILS_BACKLOG_CONFIG_PATH',
48
+ 'SPECRAILS_WORKSPACE_DIR',
49
+ 'SPECRAILS_PROFILES_DIR',
50
+ 'SPECRAILS_STATE_DIR',
51
+ ]) {
52
+ delete process.env[k];
53
+ }
@@ -59,10 +59,14 @@ exports.listWorktrees = listWorktrees;
59
59
  */
60
60
  const child_process_1 = require("child_process");
61
61
  const path = __importStar(require("path"));
62
+ const win_spawn_1 = require("./util/win-spawn");
62
63
  exports.defaultGitRunner = {
63
64
  run(args, cwd) {
64
65
  return new Promise((resolve) => {
65
- (0, child_process_1.execFile)('git', args, { cwd, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
66
+ // SystemRoot/ComSpec backfill so worktree + PR-decision git ops don't fail
67
+ // to start under a pkg-stripped Windows sidecar env. No-op on POSIX.
68
+ const env = (0, win_spawn_1.windowsSpawnEnv)();
69
+ (0, child_process_1.execFile)('git', args, { cwd, env, maxBuffer: 16 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
66
70
  const code = err && typeof err.code === 'number' ? err.code : err ? 1 : 0;
67
71
  resolve({ code, stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' });
68
72
  });