@kelceyp/caw-server 1.0.280 → 1.0.282

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