@kelceyp/caw-server 1.0.288 → 1.0.290

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.
@@ -14,30 +14,136 @@
14
14
  * `hookData`; a null / falsy `hookData` is a no-op, mirroring the wrapper's
15
15
  * pre-extraction `if (!existsSync(sentinelPath)) return;` guard one level up.
16
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.
17
+ * Wire. This function writes at most one line, of one of two shapes:
18
+ * - success line (Stop):
19
+ * `{ type: 'result', result, metadata, stopFallback }`.
20
+ * - error line (StopFailure, `hookData.error` truthy):
21
+ * `{ type: 'error', error, message[, error_details], metadata, stopFallback }`
22
+ * (As1071 design S4). `error_details` is the StopFailure payload's own
23
+ * `error_details`, copied verbatim (any JSON type, `null` included) and
24
+ * only when the payload has the key; it is diagnostic evidence, never a
25
+ * reset-time source. The success line never carries it.
26
+ * Both lines get `metadata` from the one builder, `buildMetadata()`, so the two
27
+ * cannot drift: `{ sessionId, transcriptPath, permissionMode[, durationMs][, teardownReason] }`
28
+ * (`durationMs` when both timestamps are given, `teardownReason` when the
29
+ * wrapper passes one, omitted rather than `null` otherwise; fallback chains
30
+ * `hookData.session_id || effectiveSessionId`,
31
+ * `hookData.transcript_path || transcriptPath || null`,
32
+ * `hookData.permission_mode || null`). On a real StopFailure turn the wrapper
33
+ * passes `teardownReason: 'stop-failure'` and both timestamps, so the error
34
+ * line meets `teardownReasons.mjs` contract point 2 too.
35
+ * `stopFallback` (As1072) is appended as the LAST top-level key of both lines
36
+ * and is not a `metadata` key. The success line is the pre-As1072 line with
37
+ * that one key added before its closing brace. The no-op paths
38
+ * (`alreadyEmitted`, a falsy `hookData`, and the `catch` below) write no line
39
+ * at all.
25
40
  *
26
41
  * The double-emit guard (previously `if (resultEmitted || failureEmitted) return;`
27
42
  * at `pty-wrapper.mjs:401`) is preserved via the `alreadyEmitted` input: the
28
43
  * caller passes the current flag state, and the function no-ops when true.
29
44
  * The return value `{ emitted: 'result' | 'error' | null }` lets the caller
30
45
  * 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
46
+ * `failureEmitted` flags. This is not the wrapper's only writer of
47
+ * `{ type: 'error', ... }` lines: As1080's `prompt-rejection-detector.mjs`
48
+ * writes its own `prompt_too_long` line, which does not pass through here. A
49
+ * vendor StopFailure reaches this function only when the Stop-hook sentinel
35
50
  * carries a structured `error` field.
36
51
  */
37
52
 
53
+ // --- stopFallback vocabulary (As1072) ---
54
+ //
55
+ // What happened to the Stop-hook `last_assistant_message` fallback on a turn.
56
+ // The wrapper writes the value on its own terminal line, so the component that
57
+ // holds `hookData` is the one that decides it:
58
+ // - `present` / `empty` / `absent` / `invalid` — written by `emitResult()` on
59
+ // both lines, from `deriveStopFallback()`;
60
+ // - `suppressed` — written by `emitResult()` on the success line only, when
61
+ // the CAW_PTY_SUPPRESS_STOP_FALLBACK test override is set;
62
+ // - `not-emitted` — NEVER written by the wrapper. No terminal line exists to
63
+ // carry it, so only `claude-session.js` derives it, from the absence of a
64
+ // `result` / `error` line.
65
+ // `unknown` is deliberately in NEITHER export below. It is the record layer's
66
+ // sentinel (`sentinels.js`) with one writer, `JobService.updateJobMetadata`,
67
+ // which uses it when no value reached the record. Adding it here would create
68
+ // a second writer.
69
+ // The vocabulary lives in this module, the only wrapper-side writer, so the
70
+ // wrapper's import closure (and with it `build:all` and `Dockerfile.session`)
71
+ // does not grow.
72
+
73
+ export const STOP_FALLBACK = Object.freeze({
74
+ PRESENT: 'present',
75
+ EMPTY: 'empty',
76
+ ABSENT: 'absent',
77
+ INVALID: 'invalid',
78
+ SUPPRESSED: 'suppressed',
79
+ NOT_EMITTED: 'not-emitted', // derived by claude-session.js; never written by the wrapper
80
+ });
81
+
82
+ // Exactly the values a wrapper line may carry; claude-session.js accepts only these.
83
+ // A frozen array, not a Set: Object.freeze does not stop Set.prototype.add/delete.
84
+ export const STOP_FALLBACK_WIRE_VALUES = Object.freeze([
85
+ STOP_FALLBACK.PRESENT,
86
+ STOP_FALLBACK.EMPTY,
87
+ STOP_FALLBACK.ABSENT,
88
+ STOP_FALLBACK.INVALID,
89
+ STOP_FALLBACK.SUPPRESSED,
90
+ ]);
91
+
92
+ export const isStopFallbackWireValue = (value) =>
93
+ typeof value === 'string' && STOP_FALLBACK_WIRE_VALUES.includes(value);
94
+
95
+ /**
96
+ * Classify the Stop / StopFailure payload's `last_assistant_message` by own-key
97
+ * presence, not truthiness. First match wins:
98
+ * 1. `suppressFallback` set → `suppressed`
99
+ * 2. not an own key, or own with value `undefined` → `absent`
100
+ * 3. not a string (`null` included) → `invalid`
101
+ * 4. `''` → `empty`
102
+ * 5. any other string (whitespace-only included) → `present`
103
+ *
104
+ * An own `undefined` is `absent` (As1072 KD3): `hookData` is parsed JSON, which
105
+ * cannot carry one, and the vendor serialises an `undefined` value as a missing
106
+ * key. Total: an omitted options argument means no override; a `hookData` that
107
+ * cannot hold the own key (`undefined`, `null`, a primitive, an array) is
108
+ * `absent`; it never throws and only returns wire values. `emitResult()` passes
109
+ * `suppressFallback` on the success branch only.
110
+ */
111
+ export const deriveStopFallback = (hookData, options) => {
112
+ if (options?.suppressFallback) return STOP_FALLBACK.SUPPRESSED;
113
+ if (hookData === null || hookData === undefined) return STOP_FALLBACK.ABSENT;
114
+ if (!Object.hasOwn(hookData, 'last_assistant_message')) return STOP_FALLBACK.ABSENT;
115
+ const value = hookData.last_assistant_message;
116
+ if (value === undefined) return STOP_FALLBACK.ABSENT;
117
+ if (typeof value !== 'string') return STOP_FALLBACK.INVALID;
118
+ if (value === '') return STOP_FALLBACK.EMPTY;
119
+ return STOP_FALLBACK.PRESENT;
120
+ };
121
+
38
122
  const defaultWrite = (line) => process.stdout.write(line);
39
123
  const defaultLog = () => {};
40
124
 
125
+ // The one `metadata` builder both lines use (As1071 S4). Field order is part
126
+ // of the success line's wire, which must stay byte-identical.
127
+ const buildMetadata = ({ hookData, transcriptPath, effectiveSessionId, startedAt, endedAt, teardownReason }) => {
128
+ const metadata = {
129
+ sessionId: hookData.session_id || effectiveSessionId,
130
+ transcriptPath: hookData.transcript_path || transcriptPath || null,
131
+ permissionMode: hookData.permission_mode || null
132
+ };
133
+ if (startedAt !== null && endedAt !== null) {
134
+ metadata.durationMs = endedAt - startedAt;
135
+ }
136
+ // As1033 Task 6: append teardownReason ('stop' | 'watchdog' |
137
+ // 'stop-failure' | ...) so the downstream stream handler and the
138
+ // JobRunner classifier gate can distinguish a clean Stop finish from a
139
+ // watchdog force-kill. Omitted, never `null`, when the wrapper passes
140
+ // none (`teardownReasons.mjs` contract point 3).
141
+ if (teardownReason !== null) {
142
+ metadata.teardownReason = teardownReason;
143
+ }
144
+ return metadata;
145
+ };
146
+
41
147
  export function emitResult({
42
148
  hookData,
43
149
  transcriptPath = null,
@@ -54,41 +160,41 @@ export function emitResult({
54
160
  if (!hookData) return { emitted: null };
55
161
 
56
162
  try {
163
+ const metadataInputs = { hookData, transcriptPath, effectiveSessionId, startedAt, endedAt, teardownReason };
164
+
57
165
  if (hookData.error) {
58
- write(JSON.stringify({
166
+ const line = {
59
167
  type: 'error',
60
168
  error: hookData.error,
61
169
  message: hookData.last_assistant_message || ''
62
- }) + '\n');
170
+ };
171
+ // Present-only and verbatim (As1071 S4): own-key presence, not
172
+ // truthiness, so `null`, `false`, `0` and `''` are copied as-is.
173
+ if (Object.hasOwn(hookData, 'error_details')) {
174
+ line.error_details = hookData.error_details;
175
+ }
176
+ line.metadata = buildMetadata(metadataInputs);
177
+ // No override on this branch (KD7): the line records the payload's
178
+ // real state, never `suppressed`.
179
+ line.stopFallback = deriveStopFallback(hookData);
180
+ write(JSON.stringify(line) + '\n');
63
181
  return { emitted: 'error' };
64
182
  }
65
183
 
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
- }
184
+ const metadata = buildMetadata(metadataInputs);
82
185
  // D11 test-only override: when the wrapper's CAW_PTY_SUPPRESS_STOP_FALLBACK
83
186
  // 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).
187
+ // line still declares itself (As1072): `stopFallback: 'suppressed'`,
188
+ // whatever the payload held, so the record can tell the override from a
189
+ // real payload. `result` is still `''`, so the As1005 D11 transcript-tail
190
+ // + override combination still reaches the loud empty-turn failure
191
+ // downstream. `metadata` is untouched.
87
192
  const result = suppressFallback ? '' : (hookData.last_assistant_message || '');
88
193
  write(JSON.stringify({
89
194
  type: 'result',
90
195
  result,
91
- metadata
196
+ metadata,
197
+ stopFallback: deriveStopFallback(hookData, { suppressFallback })
92
198
  }) + '\n');
93
199
  return { emitted: 'result' };
94
200
  } catch (err) {
@@ -7,14 +7,22 @@
7
7
  * `--skip-non-interactive-setup`.
8
8
  *
9
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.
10
16
  * - Absent → seed with data = {} and mode 0o644, then atomic write.
11
17
  * - Present but stat/read/parse fails (with one-shot SyntaxError retry) →
12
18
  * 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.
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.
18
26
  * - Present and read succeeded → apply the guarded mutations; short-circuit
19
27
  * via the idempotent guard when both onboarding and every trust-set entry
20
28
  * are already recorded.
@@ -36,7 +44,9 @@
36
44
  * from `server/dist/`, and the wrapper carries its own inline copy for the
37
45
  * same reason. Three co-located literals (`shared/jobErrorPrefixes.js`,
38
46
  * `pty-wrapper.mjs`, this module) must stay in sync per As1016 D8 (extended
39
- * from dual- to triple-literal by this commit).
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`).
40
50
  */
41
51
 
42
52
  import {
@@ -53,6 +63,7 @@ import {
53
63
  readdirSync,
54
64
  } from 'fs';
55
65
  import { join, dirname, basename, resolve, isAbsolute } from 'path';
66
+ import { assertClaudeConfigDirForwardable } from './claude-config-dir-guard.mjs';
56
67
 
57
68
  export const CLAUDE_JSON_UNREADABLE_PREFIX = 'CLAUDE_JSON_UNREADABLE';
58
69
 
@@ -83,18 +94,19 @@ export const ensureNonInteractiveSetup = (cwd, { home, configDir, log = noopLog
83
94
  // not consult, silently defeating both the setup and the mutex that
84
95
  // serialises writes to it. Route the seed through `$CLAUDE_CONFIG_DIR`
85
96
  // 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
- }
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;
98
110
 
99
111
  const nominal = join(baseDir, '.claude.json');
100
112
 
@@ -27,6 +27,7 @@ import { readSentinelPayload, hasPendingWork } from './pty-deferred-teardown.mjs
27
27
  import { emitResult as emitResultLine } from './pty-emit-result.mjs';
28
28
  import { TEARDOWN_REASONS } from './teardownReasons.mjs';
29
29
  import { ensureNonInteractiveSetup } from './pty-non-interactive-setup.mjs';
30
+ import { CLAUDE_CONFIG_DIR_REFUSED_PREFIX } from './claude-config-dir-guard.mjs';
30
31
  import { createTranscriptTailer } from './transcript-tailer.mjs';
31
32
  import { createPromptRejectionDetector } from './prompt-rejection-detector.mjs';
32
33
 
@@ -91,8 +92,10 @@ try {
91
92
  // (`claude-session.js#getDockerConfig`'s `commandParts` omits it by
92
93
  // design), so the in-container wrapper still runs the setup itself. This
93
94
  // wrapper's local call site preserves the same discriminating catch that
94
- // re-emits classified `CLAUDE_JSON_UNREADABLE` errors byte-for-byte with
95
- // today's stderr + exit contract and rethrows every other error. See As1016
95
+ // re-emits classified errors byte-for-byte with today's stderr + exit
96
+ // contract and rethrows every other error. Two prefixes are classified:
97
+ // `CLAUDE_JSON_UNREADABLE` (As1016) and `CLAUDE_CONFIG_DIR_REFUSED` (As1065,
98
+ // the guard's refusal of the container's own `CLAUDE_CONFIG_DIR`). See As1016
96
99
  // for the fail-fast / atomic / idempotent invariants that module preserves.
97
100
  //
98
101
  // The invocation stays here (post log()/stdin-drain/claude-binary,
@@ -117,6 +120,12 @@ try {
117
120
  // `server/dist/`, and the wrapper keeps this local const so the
118
121
  // discriminating catch below can classify without importing the sibling's
119
122
  // export. Keep all three literals in sync per As1016 D8.
123
+ //
124
+ // The second classified prefix, `CLAUDE_CONFIG_DIR_REFUSED_PREFIX`, is NOT
125
+ // carried as a literal here: it is imported above from
126
+ // `./claude-config-dir-guard.mjs`, which is already in this wrapper's
127
+ // closure via `pty-non-interactive-setup.mjs`. It has exactly two literals
128
+ // (`shared/jobErrorPrefixes.js` and the guard; As1065 KD4).
120
129
  const CLAUDE_JSON_UNREADABLE_PREFIX = 'CLAUDE_JSON_UNREADABLE';
121
130
 
122
131
  // As1050 Commit B (C3) — the `--skip-non-interactive-setup` CLI arg is
@@ -138,14 +147,20 @@ if (!hasFlag('--skip-non-interactive-setup')) {
138
147
  log
139
148
  });
140
149
  } catch (err) {
141
- // Discriminating catch: only classified `CLAUDE_JSON_UNREADABLE`-prefixed
142
- // throws are re-emitted to stderr as a fail-fast (matching today's
150
+ // Discriminating catch: only throws with one of the two classified
151
+ // prefixes are re-emitted to stderr as a fail-fast (matching today's
143
152
  // process.stderr.write + process.exit(1) contract byte-for-byte; the
144
153
  // extracted module's `.message` omits the trailing `\n` that the wrapper
145
- // concatenates here). All other errors are RETHROWN — the wrapper does
146
- // NOT swallow them, so today's uncaught-exception behaviour on
147
- // non-`CLAUDE_JSON_UNREADABLE` failures is preserved.
148
- if (err && typeof err.message === 'string' && err.message.startsWith(CLAUDE_JSON_UNREADABLE_PREFIX)) {
154
+ // concatenates here):
155
+ // - `CLAUDE_JSON_UNREADABLE` — the As1016 file-level and `HOME_UNSET`
156
+ // fail-fast branches;
157
+ // - `CLAUDE_CONFIG_DIR_REFUSED` — the As1065 guard refusing the
158
+ // container's own `CLAUDE_CONFIG_DIR` (`ensureNonInteractiveSetup`
159
+ // delegates its config-dir check to `assertClaudeConfigDirForwardable`).
160
+ // All other errors are RETHROWN — the wrapper does NOT swallow them, so
161
+ // today's uncaught-exception behaviour on unclassified failures is
162
+ // preserved.
163
+ if (err && typeof err.message === 'string' && (err.message.startsWith(CLAUDE_JSON_UNREADABLE_PREFIX) || err.message.startsWith(CLAUDE_CONFIG_DIR_REFUSED_PREFIX))) {
149
164
  process.stderr.write(err.message + '\n');
150
165
  process.exit(1);
151
166
  }
@@ -204,7 +219,12 @@ const WATCHDOG_TIMEOUT_MS = Number.parseInt(process.env.CAW_PTY_WATCHDOG_TIMEOUT
204
219
  // fallback consumes `result` instead of the missing transcript stream.
205
220
  // CAW_PTY_SUPPRESS_STOP_FALLBACK: blank the emitted `result` field on the
206
221
  // success branch of pty-emit-result (only when hookData is non-null and
207
- // the success branch runs). Used to prove the AC-5 gate downstream.
222
+ // the success branch runs). The line declares the override as
223
+ // `stopFallback: 'suppressed'` (As1072), so it is not mistaken for a real
224
+ // empty-message turn; the error branch is never affected. Used to prove the
225
+ // AC-5 gate downstream: `result` is still `''`, so with
226
+ // CAW_PTY_DISABLE_TRANSCRIPT_TAIL the turn still reaches the loud empty-turn
227
+ // failure.
208
228
  const DISABLE_TRANSCRIPT_TAIL = process.env.CAW_PTY_DISABLE_TRANSCRIPT_TAIL === '1';
209
229
  const SUPPRESS_STOP_FALLBACK = process.env.CAW_PTY_SUPPRESS_STOP_FALLBACK === '1';
210
230