@kelceyp/caw-server 1.0.276 → 1.0.278

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.
@@ -19,8 +19,8 @@
19
19
  */
20
20
 
21
21
  import * as pty from 'node-pty';
22
- import { mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync, realpathSync } from 'fs';
23
- import { join } from 'path';
22
+ import { mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync, realpathSync, statSync, chmodSync, renameSync, unlinkSync, lstatSync, readlinkSync, readdirSync } from 'fs';
23
+ import { join, dirname, basename, resolve } from 'path';
24
24
  import { tmpdir, homedir } from 'os';
25
25
  import { execSync } from 'child_process';
26
26
  import { readSentinelPayload, hasPendingWork } from './pty-deferred-teardown.mjs';
@@ -76,6 +76,244 @@ try {
76
76
  process.exit(1);
77
77
  }
78
78
 
79
+ // --- Pre-seed ~/.claude.json to avoid interactive dialogs ---
80
+ // Sets hasCompletedOnboarding and trusts the working directory.
81
+ // Uses realpathSync to handle macOS /tmp → /private/tmp symlinks.
82
+ //
83
+ // Fail-fast / atomic / idempotent write contract (As1016):
84
+ // - Absent → seed with data = {} and mode 0o644, then atomic write.
85
+ // - Present but stat/read/parse fails (with one-shot SyntaxError retry) →
86
+ // print classified `CLAUDE_JSON_UNREADABLE (<code>): <path>. ...` to
87
+ // stderr and exit 1. No `[pty-wrapper]` prefix. No write.
88
+ // - Present and read succeeded → apply the guarded mutations; short-circuit
89
+ // via the idempotent guard when both onboarding and every trust-set entry
90
+ // are already recorded.
91
+ // - Writes go via unique `<target>.tmp.<pid>.<counter>` in the SAME
92
+ // directory (`renameSync` must stay on one filesystem), preserving the
93
+ // target's existing mode (belt-and-braces `chmodSync` against umask),
94
+ // cleaning up (`unlinkSync` + rethrow the original error) on any
95
+ // temp-write / chmod / rename failure so the underlying cause is not
96
+ // masked.
97
+ // - A dangling symlink materialises the file at the link's own target;
98
+ // `lstatSync` classifies the nominal path (single-hop only).
99
+ // - At function top, sweeps `<basename>.tmp.*` siblings older than 60 s
100
+ // from `<dirname>` so a crashed prior writer never accumulates cruft;
101
+ // sweep failures are logged and ignored (never the caller's failure).
102
+ //
103
+ // Declared here (post log()/stdin-drain/claude-binary, immediately before
104
+ // mkdtempSync) so an early `process.exit(1)` cannot EPIPE the parent's
105
+ // stdin.write at JobRunner.js:747/:1145 (no 'error' listener there).
106
+ // Relocating only the invocation and not the declaration would trip the
107
+ // const binding's TDZ — `ReferenceError: Cannot access
108
+ // 'ensureNonInteractiveSetup' before initialization` on every direct PTY
109
+ // turn.
110
+ //
111
+ // Matched by shared/jobErrorPrefixes.js's `CLAUDE_JSON_UNREADABLE_PREFIX`
112
+ // export (not imported here — `shared/` is not published from
113
+ // `server/dist/`; keep the two literals in sync).
114
+ const CLAUDE_JSON_UNREADABLE_PREFIX = 'CLAUDE_JSON_UNREADABLE';
115
+
116
+ // Module-local counter so each write produces a unique temp path
117
+ // (`<target>.tmp.<pid>.<counter>`, first temp is `.tmp.<pid>.1`).
118
+ let claudeJsonTempCounter = 0;
119
+
120
+ const ensureNonInteractiveSetup = (cwd) => {
121
+ const nominal = join(homedir(), '.claude.json');
122
+
123
+ // D15: classify the nominal path via lstatSync (not existsSync).
124
+ // - not-a-symlink or absent → target = nominal
125
+ // - live symlink → target = realpathSync(nominal)
126
+ // - dangling symlink → target = resolve(dirname(nominal), readlinkSync(nominal))
127
+ // Single-hop only. Dangling symlink materialises the file at the link's
128
+ // OWN target rather than replacing the link with a regular file.
129
+ let target = nominal;
130
+ try {
131
+ const linkSt = lstatSync(nominal);
132
+ if (linkSt.isSymbolicLink()) {
133
+ try {
134
+ target = realpathSync(nominal);
135
+ } catch {
136
+ const linkText = readlinkSync(nominal);
137
+ target = resolve(dirname(nominal), linkText);
138
+ }
139
+ }
140
+ } catch (err) {
141
+ if (err.code !== 'ENOENT') {
142
+ // A stat failure on the nominal path itself that is not "absent"
143
+ // (EACCES on the parent dir, ELOOP, ENOTDIR, ...) is unclassifiable
144
+ // — fail-fast rather than write blindly.
145
+ process.stderr.write(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${err.code}): ${nominal}. Restore from ~/.claude/backups/ or repair the file, then retry.\n`);
146
+ process.exit(1);
147
+ }
148
+ // ENOENT on the nominal path is the absent case; target stays nominal.
149
+ }
150
+
151
+ // D17: sweep stale <basename>.tmp.* siblings older than 60 s. Best-effort
152
+ // cleanup; failures are logged and never surfaced (a broken sweep must
153
+ // not fail the wrapper turn).
154
+ try {
155
+ const parentDir = dirname(target);
156
+ const prefix = `${basename(target)}.tmp.`;
157
+ const now = Date.now();
158
+ for (const name of readdirSync(parentDir)) {
159
+ if (!name.startsWith(prefix)) continue;
160
+ const stalePath = join(parentDir, name);
161
+ try {
162
+ const staleSt = statSync(stalePath);
163
+ if (now - staleSt.mtimeMs > 60_000) {
164
+ unlinkSync(stalePath);
165
+ }
166
+ } catch (sweepErr) {
167
+ log(`Warning: stale temp sweep of ${stalePath} failed: ${sweepErr.message}`);
168
+ }
169
+ }
170
+ } catch (sweepErr) {
171
+ log(`Warning: stale temp sweep of ${dirname(target)} failed: ${sweepErr.message}`);
172
+ }
173
+
174
+ // Classified read. `statSync(target)` classifies ENOENT as absent
175
+ // (data = {}, existingMode = 0o644); any other stat error fails fast.
176
+ // On SyntaxError from the initial JSON.parse a single re-stat +
177
+ // re-readFileSync + re-JSON.parse retry runs with no sleep (reclassifies
178
+ // to absent if the re-stat returns ENOENT). Other read failures fail
179
+ // immediately with no retry.
180
+ let data;
181
+ let existingMode;
182
+ let readSucceeded = false;
183
+
184
+ let st;
185
+ try {
186
+ st = statSync(target);
187
+ } catch (err) {
188
+ if (err.code === 'ENOENT') {
189
+ data = {};
190
+ existingMode = 0o644;
191
+ } else {
192
+ process.stderr.write(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${err.code}): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.\n`);
193
+ process.exit(1);
194
+ }
195
+ }
196
+
197
+ if (st) {
198
+ existingMode = st.mode & 0o777;
199
+ let raw;
200
+ try {
201
+ raw = readFileSync(target, 'utf-8');
202
+ } catch (err) {
203
+ process.stderr.write(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${err.code}): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.\n`);
204
+ process.exit(1);
205
+ }
206
+ try {
207
+ data = JSON.parse(raw);
208
+ readSucceeded = true;
209
+ } catch {
210
+ // D1: one-shot SyntaxError retry (no sleep). A concurrent
211
+ // in-flight write from a peer can momentarily present a partial
212
+ // JSON snapshot; a single re-stat + re-read gives it a chance to
213
+ // land. Deliberately untested per D1 ("best-effort insurance for
214
+ // the deploy, not a standing guarantee").
215
+ let retrySt;
216
+ try {
217
+ retrySt = statSync(target);
218
+ } catch (retryErr) {
219
+ if (retryErr.code === 'ENOENT') {
220
+ data = {};
221
+ existingMode = 0o644;
222
+ } else {
223
+ process.stderr.write(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${retryErr.code}): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.\n`);
224
+ process.exit(1);
225
+ }
226
+ }
227
+ if (retrySt) {
228
+ existingMode = retrySt.mode & 0o777;
229
+ let retryRaw;
230
+ try {
231
+ retryRaw = readFileSync(target, 'utf-8');
232
+ } catch (retryErr) {
233
+ process.stderr.write(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${retryErr.code}): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.\n`);
234
+ process.exit(1);
235
+ }
236
+ try {
237
+ data = JSON.parse(retryRaw);
238
+ readSucceeded = true;
239
+ } catch (retryErr) {
240
+ process.stderr.write(`${CLAUDE_JSON_UNREADABLE_PREFIX} (SyntaxError): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.\n`);
241
+ process.exit(1);
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ // Trust both the raw and real cwd paths (handles macOS /tmp symlink).
248
+ // The inner try/ignore on realpathSync(cwd) is preserved — a stat failure
249
+ // on cwd is a defensible reduction of the trust set, not the anti-pattern
250
+ // rewritten out above.
251
+ const paths = new Set([cwd]);
252
+ try { paths.add(realpathSync(cwd)); } catch { /* ignore */ }
253
+
254
+ // D3/D4 idempotent guard — predicated on a SUCCESSFUL read, never on the
255
+ // shape of `data` alone.
256
+ //
257
+ // Closes: steady-state lost updates — the reproduced mechanism where two
258
+ // concurrent turns race a read-modify-write when neither had any change
259
+ // to make in the first place (e.g. `mcpServers` emptied for one project
260
+ // with onboarding intact).
261
+ // Does NOT close: concurrent first initialization. If N wrapper turns
262
+ // start against a fresh cwd before any one has completed its write,
263
+ // all N read the pre-setup state and all N write; only locking or
264
+ // per-session config isolation can close that residual.
265
+ // Note: `CLAUDE_CONFIG_DIR` isolation is empirically verified on Claude
266
+ // Code 2.1.245 (relocates `.claude.json`, `projects/`, `sessions/`,
267
+ // `backups/`) rather than a published contract; future upstream
268
+ // changes are breaking evidence to re-verify.
269
+ if (readSucceeded && data.hasCompletedOnboarding) {
270
+ const alreadyTrusted = [...paths].every(
271
+ (p) => data.projects?.[p]?.hasTrustDialogAccepted
272
+ );
273
+ if (alreadyTrusted) {
274
+ return;
275
+ }
276
+ }
277
+
278
+ // Guarded mutations — preserved exactly:
279
+ // (i) onboarding is set ONCE; an already-onboarded seed's
280
+ // `lastOnboardingVersion` is not re-stamped.
281
+ // (ii) per-project trust is set ONCE; the spread preserves any
282
+ // pre-existing keys (e.g. `mcpServers`) on the project's entry.
283
+ if (!data.hasCompletedOnboarding) {
284
+ data.hasCompletedOnboarding = true;
285
+ data.lastOnboardingVersion = '9.9.9';
286
+ }
287
+ data.projects = data.projects || {};
288
+ for (const p of paths) {
289
+ if (!data.projects[p]?.hasTrustDialogAccepted) {
290
+ data.projects[p] = {
291
+ ...(data.projects[p] || {}),
292
+ hasTrustDialogAccepted: true,
293
+ projectOnboardingSeenCount: 10
294
+ };
295
+ }
296
+ }
297
+
298
+ // D5/D6/D7/D19: atomic mode-preserving temp+rename.
299
+ // Unique temp name per (pid, counter). Same directory as `target` so
300
+ // `renameSync` stays on one filesystem. Belt-and-braces `chmodSync`
301
+ // against umask. On any error on write / chmod / rename, cleanup and
302
+ // rethrow the ORIGINAL error so the underlying cause is not masked.
303
+ const tmp = `${target}.tmp.${process.pid}.${++claudeJsonTempCounter}`;
304
+ try {
305
+ writeFileSync(tmp, JSON.stringify(data), { mode: existingMode });
306
+ chmodSync(tmp, existingMode);
307
+ renameSync(tmp, target);
308
+ } catch (err) {
309
+ try { unlinkSync(tmp); } catch { /* temp may not exist; ignore */ }
310
+ throw err;
311
+ }
312
+ log(`Non-interactive setup complete for: ${cwd}`);
313
+ };
314
+
315
+ ensureNonInteractiveSetup(process.cwd());
316
+
79
317
  // --- Temp directory for settings, hook script, and sentinel file ---
80
318
 
81
319
  const tempDir = mkdtempSync(join(tmpdir(), 'caw-pty-'));
@@ -112,45 +350,6 @@ const settings = {
112
350
  };
113
351
  writeFileSync(settingsPath, JSON.stringify(settings));
114
352
 
115
- // --- Pre-seed ~/.claude.json to avoid interactive dialogs ---
116
- // Sets hasCompletedOnboarding and trusts the working directory.
117
- // Uses realpathSync to handle macOS /tmp → /private/tmp symlinks.
118
-
119
- const ensureNonInteractiveSetup = (cwd) => {
120
- const claudeJsonPath = join(homedir(), '.claude.json');
121
- try {
122
- let data = {};
123
- try { data = JSON.parse(readFileSync(claudeJsonPath, 'utf-8')); } catch { /* file may not exist */ }
124
-
125
- if (!data.hasCompletedOnboarding) {
126
- data.hasCompletedOnboarding = true;
127
- data.lastOnboardingVersion = '9.9.9';
128
- }
129
-
130
- // Trust both the raw and real cwd paths (handles macOS /tmp symlink)
131
- const paths = new Set([cwd]);
132
- try { paths.add(realpathSync(cwd)); } catch { /* ignore */ }
133
-
134
- data.projects = data.projects || {};
135
- for (const p of paths) {
136
- if (!data.projects[p]?.hasTrustDialogAccepted) {
137
- data.projects[p] = {
138
- ...(data.projects[p] || {}),
139
- hasTrustDialogAccepted: true,
140
- projectOnboardingSeenCount: 10
141
- };
142
- }
143
- }
144
-
145
- writeFileSync(claudeJsonPath, JSON.stringify(data));
146
- log(`Non-interactive setup complete for: ${cwd}`);
147
- } catch (err) {
148
- log(`Warning: non-interactive setup failed: ${err.message}`);
149
- }
150
- };
151
-
152
- ensureNonInteractiveSetup(process.cwd());
153
-
154
353
  // --- Deferred teardown state ---
155
354
 
156
355
  // 90 minutes — covers full ScheduleWakeup range (up to 3600s) with 30m headroom.