@kelceyp/caw-server 1.0.290 → 1.1.0
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/claude-config-dir-guard.mjs +2 -3
- package/dist/main.js +975 -729
- package/dist/public_html/main.js +317 -317
- package/package.json +3 -5
- package/dist/prompt-rejection-detector.mjs +0 -158
- package/dist/pty-deferred-teardown.mjs +0 -17
- package/dist/pty-emit-result.mjs +0 -204
- package/dist/pty-non-interactive-setup.mjs +0 -319
- package/dist/pty-wrapper.mjs +0 -875
- package/dist/teardownReasons.mjs +0 -100
- package/dist/transcript-tailer.mjs +0 -206
|
@@ -1,319 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pre-seed ~/.claude.json to avoid interactive dialogs.
|
|
3
|
-
*
|
|
4
|
-
* Extracted from `pty-wrapper.mjs` as Commit A of story As1050 (H6 first-init
|
|
5
|
-
* write serialisation). The wrapper is currently this module's only caller;
|
|
6
|
-
* Task 3 hoists the call to `claude-session.js` and gates it on
|
|
7
|
-
* `--skip-non-interactive-setup`.
|
|
8
|
-
*
|
|
9
|
-
* Fail-fast / atomic / idempotent write contract (As1016):
|
|
10
|
-
* - `CLAUDE_CONFIG_DIR` refused → before any filesystem access, throw the
|
|
11
|
-
* As1065 guard's own error (`assertClaudeConfigDirForwardable` in
|
|
12
|
-
* `claude-config-dir-guard.mjs`), whose message starts with
|
|
13
|
-
* `CLAUDE_CONFIG_DIR_REFUSED (<RULE>): ...`. This module does not own
|
|
14
|
-
* the config-dir check; it delegates to the guard, the same predicate
|
|
15
|
-
* the claude strategies' direct-mode `validatePrerequisites` apply.
|
|
16
|
-
* - Absent → seed with data = {} and mode 0o644, then atomic write.
|
|
17
|
-
* - Present but stat/read/parse fails (with one-shot SyntaxError retry) →
|
|
18
|
-
* throw a typed error whose message starts with
|
|
19
|
-
* `CLAUDE_JSON_UNREADABLE (<code>): <path>. ...`. No write.
|
|
20
|
-
* - The wrapper's discriminating catch (the `ensureNonInteractiveSetup`
|
|
21
|
-
* call site in `pty-wrapper.mjs`) classifies both prefixes,
|
|
22
|
-
* `CLAUDE_JSON_UNREADABLE` and `CLAUDE_CONFIG_DIR_REFUSED`: it re-emits
|
|
23
|
-
* `err.message + '\n'` to stderr and calls `process.exit(1)` —
|
|
24
|
-
* byte-for-byte identical to today's inline behaviour. Every other
|
|
25
|
-
* error is rethrown by the wrapper.
|
|
26
|
-
* - Present and read succeeded → apply the guarded mutations; short-circuit
|
|
27
|
-
* via the idempotent guard when both onboarding and every trust-set entry
|
|
28
|
-
* are already recorded.
|
|
29
|
-
* - Writes go via unique `<target>.tmp.<pid>.<counter>` in the SAME
|
|
30
|
-
* directory (`renameSync` must stay on one filesystem), preserving the
|
|
31
|
-
* target's existing mode (belt-and-braces `chmodSync` against umask),
|
|
32
|
-
* cleaning up (`unlinkSync` + rethrow the original error) on any
|
|
33
|
-
* temp-write / chmod / rename failure so the underlying cause is not
|
|
34
|
-
* masked.
|
|
35
|
-
* - A dangling symlink materialises the file at the link's own target;
|
|
36
|
-
* `lstatSync` classifies the nominal path (single-hop only).
|
|
37
|
-
* - At function top, sweeps `<basename>.tmp.*` siblings older than 60 s
|
|
38
|
-
* from `<dirname>` so a crashed prior writer never accumulates cruft;
|
|
39
|
-
* sweep failures are logged (via the caller-supplied `log`, if any) and
|
|
40
|
-
* ignored (never the caller's failure).
|
|
41
|
-
*
|
|
42
|
-
* `CLAUDE_JSON_UNREADABLE_PREFIX` is carried in-line here rather than
|
|
43
|
-
* imported from `shared/jobErrorPrefixes.js` — `shared/` is not published
|
|
44
|
-
* from `server/dist/`, and the wrapper carries its own inline copy for the
|
|
45
|
-
* same reason. Three co-located literals (`shared/jobErrorPrefixes.js`,
|
|
46
|
-
* `pty-wrapper.mjs`, this module) must stay in sync per As1016 D8 (extended
|
|
47
|
-
* from dual- to triple-literal by this commit). The
|
|
48
|
-
* `CLAUDE_CONFIG_DIR_REFUSED` prefix is not carried here: it lives in the
|
|
49
|
-
* guard (literal copy 2 of 2; copy 1 is in `shared/jobErrorPrefixes.js`).
|
|
50
|
-
*/
|
|
51
|
-
|
|
52
|
-
import {
|
|
53
|
-
readFileSync,
|
|
54
|
-
existsSync,
|
|
55
|
-
writeFileSync,
|
|
56
|
-
realpathSync,
|
|
57
|
-
statSync,
|
|
58
|
-
chmodSync,
|
|
59
|
-
renameSync,
|
|
60
|
-
unlinkSync,
|
|
61
|
-
lstatSync,
|
|
62
|
-
readlinkSync,
|
|
63
|
-
readdirSync,
|
|
64
|
-
} from 'fs';
|
|
65
|
-
import { join, dirname, basename, resolve, isAbsolute } from 'path';
|
|
66
|
-
import { assertClaudeConfigDirForwardable } from './claude-config-dir-guard.mjs';
|
|
67
|
-
|
|
68
|
-
export const CLAUDE_JSON_UNREADABLE_PREFIX = 'CLAUDE_JSON_UNREADABLE';
|
|
69
|
-
|
|
70
|
-
// Module-local counter so each write produces a unique temp path
|
|
71
|
-
// (`<target>.tmp.<pid>.<counter>`, first temp is `.tmp.<pid>.1`).
|
|
72
|
-
let claudeJsonTempCounter = 0;
|
|
73
|
-
|
|
74
|
-
const noopLog = () => {};
|
|
75
|
-
|
|
76
|
-
export const ensureNonInteractiveSetup = (cwd, { home, configDir, log = noopLog } = {}) => {
|
|
77
|
-
// Fail-fast home validation. On invalid `home` the module throws a typed
|
|
78
|
-
// error whose message starts with `CLAUDE_JSON_UNREADABLE` so the
|
|
79
|
-
// wrapper's discriminating catch classifies it identically to today's
|
|
80
|
-
// filesystem-level fail-fast branches. The wrapper always passes
|
|
81
|
-
// `homedir()`, so this branch is not exercised by the seven existing
|
|
82
|
-
// integration tests; it protects future callers (Task 3's hoisted
|
|
83
|
-
// `claude-session.js` call) from silently misconfiguring the file.
|
|
84
|
-
if (typeof home !== 'string' || home.length === 0 || !isAbsolute(home)) {
|
|
85
|
-
throw new Error(
|
|
86
|
-
`${CLAUDE_JSON_UNREADABLE_PREFIX} (HOME_UNSET): resolved home directory is missing or not absolute (got ${JSON.stringify(home)}). Set HOME or pass an absolute path, then retry.`
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// As1050 AC 19 — `CLAUDE_CONFIG_DIR` precondition. When the variable is
|
|
91
|
-
// set, Claude Code 2.1.245+ relocates `.claude.json` (and `projects/`,
|
|
92
|
-
// `sessions/`, `backups/`) into that directory; a seed written to
|
|
93
|
-
// `$HOME/.claude.json` under that regime lands in a file the child does
|
|
94
|
-
// not consult, silently defeating both the setup and the mutex that
|
|
95
|
-
// serialises writes to it. Route the seed through `$CLAUDE_CONFIG_DIR`
|
|
96
|
-
// when it is present, else fall back to `$HOME` (Task 3 pre-AC-19
|
|
97
|
-
// behaviour).
|
|
98
|
-
//
|
|
99
|
-
// The value check belongs to the As1065 guard
|
|
100
|
-
// (`assertClaudeConfigDirForwardable`), the same predicate the claude
|
|
101
|
-
// strategies' direct-mode `validatePrerequisites` apply. A refused value
|
|
102
|
-
// throws the guard's own `CLAUDE_CONFIG_DIR_REFUSED (<RULE>): ...` error,
|
|
103
|
-
// unwrapped, before anything below touches the filesystem; the wrapper's
|
|
104
|
-
// discriminating catch classifies that prefix alongside
|
|
105
|
-
// `CLAUDE_JSON_UNREADABLE`. `configDir` is `string | undefined`: a
|
|
106
|
-
// non-string, including `null`, throws the guard's `TypeError` (As1065
|
|
107
|
-
// KD5).
|
|
108
|
-
assertClaudeConfigDirForwardable(configDir);
|
|
109
|
-
const baseDir = configDir === undefined ? home : configDir;
|
|
110
|
-
|
|
111
|
-
const nominal = join(baseDir, '.claude.json');
|
|
112
|
-
|
|
113
|
-
// D15: classify the nominal path via lstatSync (not existsSync).
|
|
114
|
-
// - not-a-symlink or absent → target = nominal
|
|
115
|
-
// - live symlink → target = realpathSync(nominal)
|
|
116
|
-
// - dangling symlink → target = resolve(dirname(nominal), readlinkSync(nominal))
|
|
117
|
-
// Single-hop only. Dangling symlink materialises the file at the link's
|
|
118
|
-
// OWN target rather than replacing the link with a regular file.
|
|
119
|
-
let target = nominal;
|
|
120
|
-
try {
|
|
121
|
-
const linkSt = lstatSync(nominal);
|
|
122
|
-
if (linkSt.isSymbolicLink()) {
|
|
123
|
-
try {
|
|
124
|
-
target = realpathSync(nominal);
|
|
125
|
-
} catch {
|
|
126
|
-
const linkText = readlinkSync(nominal);
|
|
127
|
-
target = resolve(dirname(nominal), linkText);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
} catch (err) {
|
|
131
|
-
if (err.code !== 'ENOENT') {
|
|
132
|
-
// A stat failure on the nominal path itself that is not "absent"
|
|
133
|
-
// (EACCES on the parent dir, ELOOP, ENOTDIR, ...) is unclassifiable
|
|
134
|
-
// — fail-fast rather than write blindly.
|
|
135
|
-
throw new Error(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${err.code}): ${nominal}. Restore from ~/.claude/backups/ or repair the file, then retry.`);
|
|
136
|
-
}
|
|
137
|
-
// ENOENT on the nominal path is the absent case; target stays nominal.
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// D17: sweep stale <basename>.tmp.* siblings older than 60 s. Best-effort
|
|
141
|
-
// cleanup; failures are logged and never surfaced (a broken sweep must
|
|
142
|
-
// not fail the wrapper turn).
|
|
143
|
-
try {
|
|
144
|
-
const parentDir = dirname(target);
|
|
145
|
-
const prefix = `${basename(target)}.tmp.`;
|
|
146
|
-
const now = Date.now();
|
|
147
|
-
for (const name of readdirSync(parentDir)) {
|
|
148
|
-
if (!name.startsWith(prefix)) continue;
|
|
149
|
-
const stalePath = join(parentDir, name);
|
|
150
|
-
try {
|
|
151
|
-
const staleSt = statSync(stalePath);
|
|
152
|
-
if (now - staleSt.mtimeMs > 60_000) {
|
|
153
|
-
unlinkSync(stalePath);
|
|
154
|
-
}
|
|
155
|
-
} catch (sweepErr) {
|
|
156
|
-
log(`Warning: stale temp sweep of ${stalePath} failed: ${sweepErr.message}`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
} catch (sweepErr) {
|
|
160
|
-
log(`Warning: stale temp sweep of ${dirname(target)} failed: ${sweepErr.message}`);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Classified read. `statSync(target)` classifies ENOENT as absent
|
|
164
|
-
// (data = {}, existingMode = 0o644); any other stat error fails fast.
|
|
165
|
-
// On SyntaxError from the initial JSON.parse a single re-stat +
|
|
166
|
-
// re-readFileSync + re-JSON.parse retry runs with no sleep (reclassifies
|
|
167
|
-
// to absent if the re-stat returns ENOENT). Other read failures fail
|
|
168
|
-
// immediately with no retry.
|
|
169
|
-
let data;
|
|
170
|
-
let existingMode;
|
|
171
|
-
let readSucceeded = false;
|
|
172
|
-
|
|
173
|
-
let st;
|
|
174
|
-
try {
|
|
175
|
-
st = statSync(target);
|
|
176
|
-
} catch (err) {
|
|
177
|
-
if (err.code === 'ENOENT') {
|
|
178
|
-
data = {};
|
|
179
|
-
existingMode = 0o644;
|
|
180
|
-
} else {
|
|
181
|
-
throw new Error(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${err.code}): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.`);
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
if (st) {
|
|
186
|
-
existingMode = st.mode & 0o777;
|
|
187
|
-
let raw;
|
|
188
|
-
try {
|
|
189
|
-
raw = readFileSync(target, 'utf-8');
|
|
190
|
-
} catch (err) {
|
|
191
|
-
throw new Error(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${err.code}): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.`);
|
|
192
|
-
}
|
|
193
|
-
try {
|
|
194
|
-
data = JSON.parse(raw);
|
|
195
|
-
readSucceeded = true;
|
|
196
|
-
} catch {
|
|
197
|
-
// D1: one-shot SyntaxError retry (no sleep). A concurrent
|
|
198
|
-
// in-flight write from a peer can momentarily present a partial
|
|
199
|
-
// JSON snapshot; a single re-stat + re-read gives it a chance to
|
|
200
|
-
// land. Deliberately untested per D1 ("best-effort insurance for
|
|
201
|
-
// the deploy, not a standing guarantee").
|
|
202
|
-
let retrySt;
|
|
203
|
-
try {
|
|
204
|
-
retrySt = statSync(target);
|
|
205
|
-
} catch (retryErr) {
|
|
206
|
-
if (retryErr.code === 'ENOENT') {
|
|
207
|
-
data = {};
|
|
208
|
-
existingMode = 0o644;
|
|
209
|
-
} else {
|
|
210
|
-
throw new Error(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${retryErr.code}): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.`);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
if (retrySt) {
|
|
214
|
-
existingMode = retrySt.mode & 0o777;
|
|
215
|
-
let retryRaw;
|
|
216
|
-
try {
|
|
217
|
-
retryRaw = readFileSync(target, 'utf-8');
|
|
218
|
-
} catch (retryErr) {
|
|
219
|
-
throw new Error(`${CLAUDE_JSON_UNREADABLE_PREFIX} (${retryErr.code}): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.`);
|
|
220
|
-
}
|
|
221
|
-
try {
|
|
222
|
-
data = JSON.parse(retryRaw);
|
|
223
|
-
readSucceeded = true;
|
|
224
|
-
} catch {
|
|
225
|
-
throw new Error(`${CLAUDE_JSON_UNREADABLE_PREFIX} (SyntaxError): ${target}. Restore from ~/.claude/backups/ or repair the file, then retry.`);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Trust both the raw and real cwd paths (handles macOS /tmp symlink).
|
|
232
|
-
// The inner try/ignore on realpathSync(cwd) is preserved — a stat failure
|
|
233
|
-
// on cwd is a defensible reduction of the trust set, not the anti-pattern
|
|
234
|
-
// rewritten out above.
|
|
235
|
-
const paths = new Set([cwd]);
|
|
236
|
-
try { paths.add(realpathSync(cwd)); } catch { /* ignore */ }
|
|
237
|
-
|
|
238
|
-
// D3/D4 idempotent guard — predicated on a SUCCESSFUL read, never on the
|
|
239
|
-
// shape of `data` alone.
|
|
240
|
-
//
|
|
241
|
-
// Closes: steady-state lost updates — the reproduced mechanism where two
|
|
242
|
-
// concurrent turns race a read-modify-write when neither had any change
|
|
243
|
-
// to make in the first place (e.g. `mcpServers` emptied for one project
|
|
244
|
-
// with onboarding intact).
|
|
245
|
-
// Closes (As1050 Commit B, this call routed through the server-side
|
|
246
|
-
// `claude-session#prepareDirectHost` hook under the module-level
|
|
247
|
-
// `Mutex.js` singleton): concurrent first initialization within one
|
|
248
|
-
// CAW server process — N direct-mode `claude-session` turns spawned
|
|
249
|
-
// from the same server now serialise on this call site, so at most
|
|
250
|
-
// one enters the read-modify-write window at a time. The Docker path
|
|
251
|
-
// still invokes this function inside the in-container wrapper (the
|
|
252
|
-
// server-side `--skip-non-interactive-setup` gate is not emitted on
|
|
253
|
-
// `getDockerConfig`'s `commandParts`), but each container has its own
|
|
254
|
-
// file-system view, so its writer set is per-container by construction.
|
|
255
|
-
// Does NOT close: trust-entry loss under uncoordinated writers outside
|
|
256
|
-
// the one-server serialisation region. Four writer populations remain
|
|
257
|
-
// uncoordinated even post-Commit-B:
|
|
258
|
-
// (i) other CAW server processes on the same host writing to the
|
|
259
|
-
// same `~/.claude.json` (each has its own in-process singleton;
|
|
260
|
-
// the mutex does not cross processes);
|
|
261
|
-
// (ii) the user's interactive terminal `claude` on the same host,
|
|
262
|
-
// which reads and rewrites `~/.claude.json` on trust and
|
|
263
|
-
// onboarding transitions;
|
|
264
|
-
// (iii) CAW-spawned `claude` child processes (Task-adjudicated turns
|
|
265
|
-
// that run `claude` themselves after this seed lands);
|
|
266
|
-
// (iv) `scripts/probes/claude-code-hooks/run-probe.sh`, which
|
|
267
|
-
// invokes `claude` outside the JobRunner path entirely.
|
|
268
|
-
// Only per-session config isolation (`CLAUDE_CONFIG_DIR` per turn) can
|
|
269
|
-
// close the residual — the mutex closes only the CAW-vs-CAW half of
|
|
270
|
-
// H6, not the cross-actor half.
|
|
271
|
-
// Note: `CLAUDE_CONFIG_DIR` isolation is empirically verified on Claude
|
|
272
|
-
// Code 2.1.245 (relocates `.claude.json`, `projects/`, `sessions/`,
|
|
273
|
-
// `backups/`) rather than a published contract; future upstream
|
|
274
|
-
// changes are breaking evidence to re-verify.
|
|
275
|
-
if (readSucceeded && data.hasCompletedOnboarding) {
|
|
276
|
-
const alreadyTrusted = [...paths].every(
|
|
277
|
-
(p) => data.projects?.[p]?.hasTrustDialogAccepted
|
|
278
|
-
);
|
|
279
|
-
if (alreadyTrusted) {
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Guarded mutations — preserved exactly:
|
|
285
|
-
// (i) onboarding is set ONCE; an already-onboarded seed's
|
|
286
|
-
// `lastOnboardingVersion` is not re-stamped.
|
|
287
|
-
// (ii) per-project trust is set ONCE; the spread preserves any
|
|
288
|
-
// pre-existing keys (e.g. `mcpServers`) on the project's entry.
|
|
289
|
-
if (!data.hasCompletedOnboarding) {
|
|
290
|
-
data.hasCompletedOnboarding = true;
|
|
291
|
-
data.lastOnboardingVersion = '9.9.9';
|
|
292
|
-
}
|
|
293
|
-
data.projects = data.projects || {};
|
|
294
|
-
for (const p of paths) {
|
|
295
|
-
if (!data.projects[p]?.hasTrustDialogAccepted) {
|
|
296
|
-
data.projects[p] = {
|
|
297
|
-
...(data.projects[p] || {}),
|
|
298
|
-
hasTrustDialogAccepted: true,
|
|
299
|
-
projectOnboardingSeenCount: 10
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// D5/D6/D7/D19: atomic mode-preserving temp+rename.
|
|
305
|
-
// Unique temp name per (pid, counter). Same directory as `target` so
|
|
306
|
-
// `renameSync` stays on one filesystem. Belt-and-braces `chmodSync`
|
|
307
|
-
// against umask. On any error on write / chmod / rename, cleanup and
|
|
308
|
-
// rethrow the ORIGINAL error so the underlying cause is not masked.
|
|
309
|
-
const tmp = `${target}.tmp.${process.pid}.${++claudeJsonTempCounter}`;
|
|
310
|
-
try {
|
|
311
|
-
writeFileSync(tmp, JSON.stringify(data), { mode: existingMode });
|
|
312
|
-
chmodSync(tmp, existingMode);
|
|
313
|
-
renameSync(tmp, target);
|
|
314
|
-
} catch (err) {
|
|
315
|
-
try { unlinkSync(tmp); } catch { /* temp may not exist; ignore */ }
|
|
316
|
-
throw err;
|
|
317
|
-
}
|
|
318
|
-
log(`Non-interactive setup complete for: ${cwd}`);
|
|
319
|
-
};
|