@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.
- package/dist/main.js +406 -398
- package/dist/pty-wrapper.mjs +240 -41
- package/dist/public_html/main.js +74 -74
- package/package.json +2 -2
package/dist/pty-wrapper.mjs
CHANGED
|
@@ -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.
|