@kelceyp/caw-server 1.0.286 → 1.0.289

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,105 @@
1
+ /**
2
+ * CLAUDE_CONFIG_DIR refusal guard (story As1065).
3
+ *
4
+ * Claude Code does no shell expansion on `CLAUDE_CONFIG_DIR`. Some values
5
+ * therefore name an unintended directory: the vendor builds a fresh config
6
+ * tree there (`.claude.json`, `projects/`, `sessions/`, `backups/`), usually
7
+ * inside the job's working directory, and quietly stops using the real one
8
+ * (upstream #78988). Probe evidence, Claude Code 2.1.281 (2026-09-25):
9
+ * `~/x`, `$HOME/x` and `rel-cfg` land under the cwd; `/abs/$USER/cfg` creates
10
+ * a literal `$USER` directory; `/abs/x/~/y` creates a literal `~` directory;
11
+ * an empty value makes the cwd itself the config dir.
12
+ *
13
+ * This module refuses such a value; it never repairs, expands or rewrites
14
+ * it. The predicate (ratified 2026-09-25, As1065 scope AC 1):
15
+ * - `undefined` is absent: nothing happens.
16
+ * - any other non-string (including `null`) throws `TypeError`; env values
17
+ * are always `string | undefined`, so this is a programming error.
18
+ * - a string is checked against `CLAUDE_CONFIG_DIR_REFUSAL_RULES` in order
19
+ * EMPTY, NOT_ABSOLUTE, CONTAINS_DOLLAR, TILDE_SEGMENT; the first match is
20
+ * reported. "Absolute" is Node's `path.isAbsolute`, as in
21
+ * `pty-non-interactive-setup.mjs`. A `~` counts only at the start of a
22
+ * `/`-separated segment, so `/a/b~c` passes.
23
+ *
24
+ * Dependency-free by design: the only import is the `path` builtin, there is
25
+ * no logger (an absent variable must produce no new log line) and nothing
26
+ * runs at load. `shared/` is not published from `server/dist/`, so this
27
+ * module carries its own copy of the prefix literal.
28
+ *
29
+ * `CLAUDE_CONFIG_DIR_REFUSED_PREFIX` is literal copy 2 of 2; copy 1 is
30
+ * `CLAUDE_CONFIG_DIR_REFUSED_PREFIX` in `shared/jobErrorPrefixes.js`. The
31
+ * prefix-drift block of
32
+ * `server/test/unit/core/agents/claude-config-dir-guard.test.js` asserts the
33
+ * two are equal.
34
+ */
35
+
36
+ import { isAbsolute } from 'path';
37
+
38
+ export const CLAUDE_CONFIG_DIR_REFUSED_PREFIX = 'CLAUDE_CONFIG_DIR_REFUSED';
39
+
40
+ export const CLAUDE_CONFIG_DIR_REFUSAL_RULES = Object.freeze({
41
+ EMPTY: 'EMPTY',
42
+ NOT_ABSOLUTE: 'NOT_ABSOLUTE',
43
+ CONTAINS_DOLLAR: 'CONTAINS_DOLLAR',
44
+ TILDE_SEGMENT: 'TILDE_SEGMENT'
45
+ });
46
+
47
+ // Per-rule `<rule reason>` and `<rule consequence>` of the message contract.
48
+ const RULE_TEXT = Object.freeze({
49
+ EMPTY: Object.freeze({
50
+ reason: 'is set but empty',
51
+ consequence: "use the job's working directory itself as its config directory"
52
+ }),
53
+ NOT_ABSOLUTE: Object.freeze({
54
+ reason: 'is not an absolute path',
55
+ consequence: "create a new config tree relative to the job's working directory"
56
+ }),
57
+ CONTAINS_DOLLAR: Object.freeze({
58
+ reason: 'contains "$"',
59
+ consequence: 'create a directory with a literal "$" in its name'
60
+ }),
61
+ TILDE_SEGMENT: Object.freeze({
62
+ reason: 'has a path segment starting with "~"',
63
+ consequence: 'create a directory whose name starts with a literal "~"'
64
+ })
65
+ });
66
+
67
+ /**
68
+ * Classify a `CLAUDE_CONFIG_DIR` value.
69
+ *
70
+ * @param {string|undefined} value
71
+ * @returns {string|null} the first matching rule code, or `null` when the
72
+ * value is absent or passes
73
+ * @throws {TypeError} if `value` is neither a string nor `undefined`
74
+ */
75
+ export const classifyClaudeConfigDir = (value) => {
76
+ if (value === undefined) return null;
77
+ if (typeof value !== 'string') {
78
+ throw new TypeError(`CLAUDE_CONFIG_DIR must be a string or undefined (got ${value === null ? 'null' : typeof value})`);
79
+ }
80
+ if (value === '') return CLAUDE_CONFIG_DIR_REFUSAL_RULES.EMPTY;
81
+ if (!isAbsolute(value)) return CLAUDE_CONFIG_DIR_REFUSAL_RULES.NOT_ABSOLUTE;
82
+ if (value.includes('$')) return CLAUDE_CONFIG_DIR_REFUSAL_RULES.CONTAINS_DOLLAR;
83
+ if (value.split('/').some((segment) => segment.startsWith('~'))) return CLAUDE_CONFIG_DIR_REFUSAL_RULES.TILDE_SEGMENT;
84
+ return null;
85
+ };
86
+
87
+ /**
88
+ * Throw if a `CLAUDE_CONFIG_DIR` value must not be forwarded to Claude Code.
89
+ * The refusal is a plain `Error`; its message prefix is the contract.
90
+ *
91
+ * @param {string|undefined} value
92
+ * @throws {Error} `CLAUDE_CONFIG_DIR_REFUSED (<RULE>): ...` for a refused value
93
+ * @throws {TypeError} if `value` is neither a string nor `undefined`
94
+ */
95
+ export const assertClaudeConfigDirForwardable = (value) => {
96
+ const rule = classifyClaudeConfigDir(value);
97
+ if (rule === null) return;
98
+ const { reason, consequence } = RULE_TEXT[rule];
99
+ throw new Error(
100
+ `${CLAUDE_CONFIG_DIR_REFUSED_PREFIX} (${rule}): CLAUDE_CONFIG_DIR is ${JSON.stringify(value)}, which ${reason}. ` +
101
+ `Claude Code does not expand "~" or "$VARIABLES" in this value, so it would ${consequence} and stop using your real config directory (upstream #78988). ` +
102
+ 'Fix: unset CLAUDE_CONFIG_DIR, or set it to an absolute path with no "$" and no path segment starting with "~", ' +
103
+ "in the environment that launches Claude Code (for direct jobs, the CAW server's environment - restart the server after changing it). Then retry."
104
+ );
105
+ };