@kelceyp/caw-server 1.0.289 → 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.
@@ -1,98 +0,0 @@
1
- /**
2
- * Side-effect-free extraction of `emitResult()` from `pty-wrapper.mjs`.
3
- *
4
- * The wrapper's `onExit` handler reads the Stop-hook sentinel file, then hands
5
- * the parsed payload to this function to build either the `type:"result"` or
6
- * `type:"error"` stdout line. Splitting the branch construction out of the
7
- * wrapper mirrors `pty-deferred-teardown.mjs` (already extracts
8
- * `readSentinelPayload` and `hasPendingWork`) so the sentinel-success and
9
- * error branches can be unit-tested without spawning a real PTY.
10
- *
11
- * Inputs are function arguments only — no module-level state, no top-level
12
- * side effects, no file IO. The caller (the wrapper's delegator) reads the
13
- * sentinel via `readSentinelPayload()` and passes the parsed payload in as
14
- * `hookData`; a null / falsy `hookData` is a no-op, mirroring the wrapper's
15
- * pre-extraction `if (!existsSync(sentinelPath)) return;` guard one level up.
16
- *
17
- * Wire is preserved byte-for-byte relative to the pre-extraction wrapper:
18
- * - success line: `{ type: 'result', result, metadata }` with
19
- * `metadata = { sessionId, transcriptPath, permissionMode[, durationMs] }`
20
- * — same field set, same field order, same fallback chains
21
- * (`hookData.transcript_path || transcriptPath || null`,
22
- * `hookData.session_id || effectiveSessionId`, `hookData.permission_mode || null`).
23
- * - error line: `{ type: 'error', error, message }` — same shape, no
24
- * `metadata` field at all.
25
- *
26
- * The double-emit guard (previously `if (resultEmitted || failureEmitted) return;`
27
- * at `pty-wrapper.mjs:401`) is preserved via the `alreadyEmitted` input: the
28
- * caller passes the current flag state, and the function no-ops when true.
29
- * The return value `{ emitted: 'result' | 'error' | null }` lets the caller
30
- * translate a successful call back into its module-level `resultEmitted` /
31
- * `failureEmitted` flags. After As1080 Increment A the wrapper no longer has
32
- * any text-based prompt-rejection branch, so this delegator is the sole
33
- * emitter of `{ type: 'error', ... }` lines on the wrapper's stdout — a
34
- * genuine API-level rejection reaches it only when the Stop-hook sentinel
35
- * carries a structured `error` field.
36
- */
37
-
38
- const defaultWrite = (line) => process.stdout.write(line);
39
- const defaultLog = () => {};
40
-
41
- export function emitResult({
42
- hookData,
43
- transcriptPath = null,
44
- effectiveSessionId = null,
45
- startedAt = null,
46
- endedAt = null,
47
- teardownReason = null,
48
- alreadyEmitted = false,
49
- suppressFallback = false,
50
- write = defaultWrite,
51
- log = defaultLog
52
- } = {}) {
53
- if (alreadyEmitted) return { emitted: null };
54
- if (!hookData) return { emitted: null };
55
-
56
- try {
57
- if (hookData.error) {
58
- write(JSON.stringify({
59
- type: 'error',
60
- error: hookData.error,
61
- message: hookData.last_assistant_message || ''
62
- }) + '\n');
63
- return { emitted: 'error' };
64
- }
65
-
66
- const metadata = {
67
- sessionId: hookData.session_id || effectiveSessionId,
68
- transcriptPath: hookData.transcript_path || transcriptPath || null,
69
- permissionMode: hookData.permission_mode || null
70
- };
71
- if (startedAt !== null && endedAt !== null) {
72
- metadata.durationMs = endedAt - startedAt;
73
- }
74
- // As1033 Task 6: append teardownReason ('stop' | 'watchdog') so the
75
- // downstream stream handler (Task 5 landed the capture) and the
76
- // JobRunner classifier gate (Task 7) can distinguish a clean Stop
77
- // finish from a watchdog force-kill. Field order of the four
78
- // pre-existing fields is preserved byte-for-byte.
79
- if (teardownReason !== null) {
80
- metadata.teardownReason = teardownReason;
81
- }
82
- // D11 test-only override: when the wrapper's CAW_PTY_SUPPRESS_STOP_FALLBACK
83
- // gate is set, blank the `result` field on the success branch only. The
84
- // wire is deliberately identical to a real empty-message turn — that IS
85
- // the point of the override. Metadata, field order, and everything else
86
- // stay byte-for-byte identical (Task 3's guarantee).
87
- const result = suppressFallback ? '' : (hookData.last_assistant_message || '');
88
- write(JSON.stringify({
89
- type: 'result',
90
- result,
91
- metadata
92
- }) + '\n');
93
- return { emitted: 'result' };
94
- } catch (err) {
95
- log(`Error reading sentinel: ${err.message}`);
96
- return { emitted: null };
97
- }
98
- }
@@ -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
- };