@stravica-ai/digital-operator-pa 0.1.28 → 0.1.30

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.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -15,15 +15,89 @@
15
15
  // - Consumers pass `{ repoRoot }` or the resolved paths; both surfaces
16
16
  // are seams for tests.
17
17
 
18
- import { readFileSync } from 'node:fs';
18
+ import { existsSync, readFileSync } from 'node:fs';
19
19
  import { resolve, join, dirname } from 'node:path';
20
20
  import { fileURLToPath } from 'node:url';
21
21
  import { execFileSync } from 'node:child_process';
22
22
 
23
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
24
 
25
- // operator/extensions/tools/library-cli/src/ → up five levels to the repo root.
26
- export const DEFAULT_REPO_ROOT = resolve(__dirname, '..', '..', '..', '..', '..');
25
+ // Legacy in-tree assumption: operator/extensions/tools/library-cli/src/ up
26
+ // five levels to the repo root. That assumption held while this CLI lived
27
+ // inside the operator repo tree; once shipped through
28
+ // `@stravica-ai/digital-operator-pa`'s bundled `dist/`, the same five-up walk
29
+ // lands INSIDE node_modules and preferences.json is never found — the
30
+ // consumer silently boots SIMPLE and every put/flush is refused (see
31
+ // w-2026-08-24-dave-021, Stream 5 P0). Kept only as the fallback for the
32
+ // in-tree/test invocation shape.
33
+ const IN_TREE_FIVE_UP = resolve(__dirname, '..', '..', '..', '..', '..');
34
+
35
+ /**
36
+ * Layered repo-root resolution. Called each time a default is needed so
37
+ * the answer reflects the CURRENT environment / cwd, never a value frozen
38
+ * at module import.
39
+ *
40
+ * Order:
41
+ * (a) DAVE_ROOT env override (estate standard — CLAUDE.md Bucket 7 rule 8)
42
+ * (b) LIBRARY_REPO_ROOT env override (explicit CLI-scoped opt-in)
43
+ * (c) Walk upward from process.cwd() until a directory containing
44
+ * operator/config/preferences.json is found — this is the shape that
45
+ * proves we are inside an operator repo, and works whether the CLI
46
+ * ships from node_modules or lives in-tree.
47
+ * (d) Legacy five-up from the module directory (in-tree / test fallback).
48
+ *
49
+ * Injectable {@link opts} exists so unit tests can pin cwd + env without
50
+ * mutating the surrounding process.
51
+ *
52
+ * @param {object} [opts]
53
+ * @param {NodeJS.ProcessEnv} [opts.env] defaults to process.env
54
+ * @param {string} [opts.cwd] defaults to process.cwd()
55
+ * @returns {string} resolved repo root
56
+ */
57
+ export function resolveDefaultRepoRoot(opts = {}) {
58
+ const env = opts.env ?? process.env;
59
+ const cwd = opts.cwd ?? process.cwd();
60
+
61
+ // (a) DAVE_ROOT — the estate-standard project-root env (CLAUDE.md B7 R8).
62
+ const daveRoot = env.DAVE_ROOT;
63
+ if (typeof daveRoot === 'string' && daveRoot.trim() !== '') {
64
+ return resolve(daveRoot);
65
+ }
66
+
67
+ // (b) LIBRARY_REPO_ROOT — subsystem-scoped override.
68
+ const libRoot = env.LIBRARY_REPO_ROOT;
69
+ if (typeof libRoot === 'string' && libRoot.trim() !== '') {
70
+ return resolve(libRoot);
71
+ }
72
+
73
+ // (c) Walk up from cwd looking for operator/config/preferences.json.
74
+ let dir = resolve(cwd);
75
+ // Belt-and-braces guard: 64 levels is far beyond any real filesystem depth.
76
+ for (let i = 0; i < 64; i += 1) {
77
+ if (existsSync(join(dir, 'operator', 'config', 'preferences.json'))) {
78
+ return dir;
79
+ }
80
+ const parent = dirname(dir);
81
+ if (parent === dir) break;
82
+ dir = parent;
83
+ }
84
+
85
+ // (d) Legacy in-tree five-up — kept for the checkout-and-run test shape.
86
+ return IN_TREE_FIVE_UP;
87
+ }
88
+
89
+ /**
90
+ * Deprecated export retained for backwards compatibility. Points at the
91
+ * legacy in-tree five-up value — which is exactly the shape that fails
92
+ * once shipped from `node_modules`. New callers MUST use
93
+ * {@link resolveDefaultRepoRoot} (live) or pass an explicit `repoRoot`.
94
+ *
95
+ * Left in place because dropping the export would surface as an
96
+ * ImportError on any consumer that pinned the old name; the retained
97
+ * value never reaches the CLI's default-parameter path any more — those
98
+ * now call `resolveDefaultRepoRoot()` at invocation time.
99
+ */
100
+ export const DEFAULT_REPO_ROOT = IN_TREE_FIVE_UP;
27
101
 
28
102
  export class LibraryConfigError extends Error {
29
103
  constructor(message, code) {
@@ -146,15 +220,16 @@ const REMOTE_KEY_REF_RE = /^([a-z0-9-]+)\/([A-Z][A-Z0-9_]*)$/;
146
220
  */
147
221
  export function resolveRemoteKeyRef(
148
222
  refOrLiteral,
149
- { repoRoot = DEFAULT_REPO_ROOT, secretsGet = defaultSecretsGet } = {},
223
+ { repoRoot, secretsGet = defaultSecretsGet } = {},
150
224
  ) {
151
225
  if (typeof refOrLiteral !== 'string') return refOrLiteral;
152
226
  const m = REMOTE_KEY_REF_RE.exec(refOrLiteral);
153
227
  if (!m) return refOrLiteral;
154
228
  const [, scope, key] = m;
229
+ const root = repoRoot ?? resolveDefaultRepoRoot();
155
230
  let resolved;
156
231
  try {
157
- resolved = secretsGet({ scope, key, repoRoot });
232
+ resolved = secretsGet({ scope, key, repoRoot: root });
158
233
  } catch (err) {
159
234
  throw new LibraryConfigError(
160
235
  `library.remoteKey vault reference "${refOrLiteral}" failed to resolve ` +
@@ -215,9 +290,10 @@ function defaultSecretsGet({ scope, key, repoRoot }) {
215
290
  * @param {string} [opts.repoRoot] defaults to DEFAULT_REPO_ROOT
216
291
  * @returns {{mode: 'simple'|'remote', remoteEndpoint?: string, remoteKey?: string, repoRoot: string, preferences: object}}
217
292
  */
218
- export function loadLibraryConfig({ repoRoot = DEFAULT_REPO_ROOT } = {}) {
219
- const base = join(repoRoot, 'operator', 'config', 'preferences.json');
220
- const local = join(repoRoot, 'operator', 'config', 'preferences.local.json');
293
+ export function loadLibraryConfig({ repoRoot } = {}) {
294
+ const root = repoRoot ?? resolveDefaultRepoRoot();
295
+ const base = join(root, 'operator', 'config', 'preferences.json');
296
+ const local = join(root, 'operator', 'config', 'preferences.local.json');
221
297
  const preferences = readMergedPreferences({ base, local });
222
298
  // Vault-ref resolution runs BEFORE resolveLibraryConfig's shape check
223
299
  // so a ref value never reaches the `<subkeyId>:<secret>` validator.
@@ -229,11 +305,11 @@ export function loadLibraryConfig({ repoRoot = DEFAULT_REPO_ROOT } = {}) {
229
305
  typeof preferences.library.remoteKey === 'string'
230
306
  ) {
231
307
  const original = preferences.library.remoteKey;
232
- const resolved = resolveRemoteKeyRef(original, { repoRoot });
308
+ const resolved = resolveRemoteKeyRef(original, { repoRoot: root });
233
309
  if (resolved !== original) {
234
310
  preferences.library = { ...preferences.library, remoteKey: resolved };
235
311
  }
236
312
  }
237
313
  const resolved = resolveLibraryConfig(preferences);
238
- return { ...resolved, repoRoot, preferences };
314
+ return { ...resolved, repoRoot: root, preferences };
239
315
  }
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { createHash, randomUUID } from 'node:crypto';
15
15
  import { hostname } from 'node:os';
16
- import { relative, isAbsolute } from 'node:path';
16
+ import { isAbsolute, normalize, relative, sep } from 'node:path';
17
17
 
18
18
  import { EVENT_KINDS, canonicalJson } from '@stravica-ai/remote-library';
19
19
 
@@ -31,6 +31,47 @@ export function contentShaOf(payload) {
31
31
  return `sha256:${createHash('sha256').update(canonical).digest('hex')}`;
32
32
  }
33
33
 
34
+ /**
35
+ * Refuse a source path that would encode as an escape from the repo root.
36
+ * Root-cause cure for the poison-event wedge Stream 5 re-verify Scope C
37
+ * surfaced (w-2026-08-24-dave-025, SA14). Called by buildEnvelope after
38
+ * relPath is computed. Semantics match the server's assertPathSafe
39
+ * (spec §4 apply gate): reject empty, absolute, NUL, drive-letter,
40
+ * root-backslash, `..`-segments, and any string that normalises to a
41
+ * path starting with `..`. The error names the input sourcePath and
42
+ * repoRoot so an operator can see immediately why the encoding refused.
43
+ *
44
+ * @param {string} relPath candidate repo-relative path
45
+ * @param {string} sourcePath original input (for the error message)
46
+ * @param {string} repoRoot resolved repo root (for the error message)
47
+ * @throws {Error} with exitCode=1 if the path is unsafe
48
+ */
49
+ export function assertRepoRelativeSafe(relPath, sourcePath, repoRoot) {
50
+ const fail = (reason) => {
51
+ const err = new Error(
52
+ `refusing to encode source path outside repo root (${reason}): ` +
53
+ `sourcePath=${JSON.stringify(sourcePath)}, repoRoot=${JSON.stringify(repoRoot)}, ` +
54
+ `resolvedRelative=${JSON.stringify(relPath)}. ` +
55
+ `Move the file into the repo or stage it under a repo-resident path before calling library put.`,
56
+ );
57
+ err.exitCode = 1;
58
+ throw err;
59
+ };
60
+ if (typeof relPath !== 'string') fail('path must be a string');
61
+ if (relPath.length === 0) fail('path resolves empty');
62
+ if (relPath.includes('\0')) fail('path contains NUL byte');
63
+ if (isAbsolute(relPath)) fail('resolved path is absolute');
64
+ if (/^[A-Za-z]:[\\/]/.test(relPath) || relPath.startsWith('\\')) {
65
+ fail('drive-letter or root-backslash path');
66
+ }
67
+ const parts = relPath.split(/[/\\]/);
68
+ if (parts.some((seg) => seg === '..')) fail("'..' segments not permitted");
69
+ const normalised = normalize(relPath);
70
+ if (normalised === '..' || normalised.startsWith('..' + sep) || normalised.startsWith('../')) {
71
+ fail('path normalises outside repo root');
72
+ }
73
+ }
74
+
34
75
  /**
35
76
  * Guess the event kind from a staging file path when the operator did
36
77
  * not supply one via `--kind`. Falls back to
@@ -77,9 +118,22 @@ export function buildEnvelope({
77
118
  sourceKind = 'manual',
78
119
  clock = () => new Date(),
79
120
  }) {
121
+ // Repo-relative resolution + refusal for anything that would encode as
122
+ // a `..`-segment or otherwise escape the repo root. This is the root
123
+ // cause cure for the Stream 5 re-verify Scope C poison-event wedge
124
+ // (w-2026-08-24-dave-025). Before this cure `relative(repoRoot, abs)`
125
+ // silently produced `../../etc/...` for files sitting outside the
126
+ // repo and the envelope was posted with a payload path the worker's
127
+ // apply gate would later refuse — halting single-writer propagation
128
+ // for the whole estate. Refusing here (client-side, at encoding time)
129
+ // stops the poison at source; SA14's ingest-side check is the second
130
+ // line; the worker's apply gate remains the third.
80
131
  const relPath = sourcePath
81
132
  ? isAbsolute(sourcePath) ? relative(repoRoot, sourcePath) : sourcePath
82
133
  : undefined;
134
+ if (relPath !== undefined) {
135
+ assertRepoRelativeSafe(relPath, sourcePath, repoRoot);
136
+ }
83
137
 
84
138
  const resolvedKind = kind ?? guessKindFromPath(relPath ?? sourcePath ?? '') ?? null;
85
139
  if (!resolvedKind) {
@@ -12,8 +12,60 @@ import { fileURLToPath } from 'node:url';
12
12
  import { homedir } from 'node:os';
13
13
 
14
14
  const HERE = dirname(fileURLToPath(import.meta.url));
15
- // operator/extensions/tools/library-tunnel/src/ → up five levels to repo root.
16
- export const DEFAULT_REPO_ROOT = resolve(HERE, '..', '..', '..', '..', '..');
15
+
16
+ // Legacy in-tree assumption: operator/extensions/tools/library-tunnel/src/ up
17
+ // five levels to repo root. Held while the tool lived in-tree; once shipped
18
+ // through `@stravica-ai/digital-operator-pa`, the same walk lands INSIDE
19
+ // node_modules and state files spray into the platform tarball instead of
20
+ // the consumer's .cache/. Same defect class as library-cli
21
+ // (w-2026-08-24-dave-021); layered resolution below fixes both.
22
+ const IN_TREE_FIVE_UP = resolve(HERE, '..', '..', '..', '..', '..');
23
+
24
+ /**
25
+ * Layered repo-root resolution. See library-cli/src/config.mjs for the
26
+ * full rationale; short version: (a) DAVE_ROOT env, (b) LIBRARY_REPO_ROOT
27
+ * env, (c) walk cwd upward until operator/config/preferences.json is
28
+ * found, (d) fall back to the legacy five-up for in-tree/test invocations.
29
+ *
30
+ * @param {object} [opts]
31
+ * @param {NodeJS.ProcessEnv} [opts.env] defaults to process.env
32
+ * @param {string} [opts.cwd] defaults to process.cwd()
33
+ * @returns {string} resolved repo root
34
+ */
35
+ export function resolveDefaultRepoRoot(opts = {}) {
36
+ const env = opts.env ?? process.env;
37
+ const cwd = opts.cwd ?? process.cwd();
38
+
39
+ const daveRoot = env.DAVE_ROOT;
40
+ if (typeof daveRoot === 'string' && daveRoot.trim() !== '') {
41
+ return resolve(daveRoot);
42
+ }
43
+
44
+ const libRoot = env.LIBRARY_REPO_ROOT;
45
+ if (typeof libRoot === 'string' && libRoot.trim() !== '') {
46
+ return resolve(libRoot);
47
+ }
48
+
49
+ let dir = resolve(cwd);
50
+ for (let i = 0; i < 64; i += 1) {
51
+ if (existsSync(join(dir, 'operator', 'config', 'preferences.json'))) {
52
+ return dir;
53
+ }
54
+ const parent = dirname(dir);
55
+ if (parent === dir) break;
56
+ dir = parent;
57
+ }
58
+
59
+ return IN_TREE_FIVE_UP;
60
+ }
61
+
62
+ /**
63
+ * Deprecated export retained for backwards compatibility. Points at the
64
+ * legacy in-tree five-up value (which is exactly the shape that fails
65
+ * once shipped from node_modules). New callers use
66
+ * {@link resolveDefaultRepoRoot} or pass an explicit `repoRoot`.
67
+ */
68
+ export const DEFAULT_REPO_ROOT = IN_TREE_FIVE_UP;
17
69
 
18
70
  function readJsonObject(path) {
19
71
  try {
@@ -30,7 +82,7 @@ function readJsonObject(path) {
30
82
  * @param {string} repoRoot
31
83
  * @returns {string|null}
32
84
  */
33
- export function readPaName(repoRoot = DEFAULT_REPO_ROOT) {
85
+ export function readPaName(repoRoot = resolveDefaultRepoRoot()) {
34
86
  const base = readJsonObject(join(repoRoot, 'operator', 'config', 'preferences.json')) || {};
35
87
  const local = readJsonObject(join(repoRoot, 'operator', 'config', 'preferences.local.json')) || {};
36
88
  const merged = { ...base, ...local };
@@ -65,13 +117,13 @@ export function plistPath(host) {
65
117
  * @param {string} host
66
118
  * @param {string} repoRoot
67
119
  */
68
- export function stateDir(repoRoot = DEFAULT_REPO_ROOT) {
120
+ export function stateDir(repoRoot = resolveDefaultRepoRoot()) {
69
121
  return join(repoRoot, '.cache', 'library-tunnel');
70
122
  }
71
- export function statePath(host, repoRoot = DEFAULT_REPO_ROOT) {
123
+ export function statePath(host, repoRoot = resolveDefaultRepoRoot()) {
72
124
  return join(stateDir(repoRoot), `${host}.json`);
73
125
  }
74
- export function logPath(host, repoRoot = DEFAULT_REPO_ROOT) {
126
+ export function logPath(host, repoRoot = resolveDefaultRepoRoot()) {
75
127
  return join(stateDir(repoRoot), `${host}.log`);
76
128
  }
77
129
 
@@ -88,7 +140,7 @@ export function logPath(host, repoRoot = DEFAULT_REPO_ROOT) {
88
140
  * installedAt: string
89
141
  * }}
90
142
  */
91
- export function readState(host, repoRoot = DEFAULT_REPO_ROOT) {
143
+ export function readState(host, repoRoot = resolveDefaultRepoRoot()) {
92
144
  return readJsonObject(statePath(host, repoRoot));
93
145
  }
94
146
 
@@ -98,7 +150,7 @@ export function readState(host, repoRoot = DEFAULT_REPO_ROOT) {
98
150
  * @param {object} state
99
151
  * @param {string} [repoRoot]
100
152
  */
101
- export function writeState(host, state, repoRoot = DEFAULT_REPO_ROOT) {
153
+ export function writeState(host, state, repoRoot = resolveDefaultRepoRoot()) {
102
154
  const dir = stateDir(repoRoot);
103
155
  mkdirSync(dir, { recursive: true });
104
156
  const path = statePath(host, repoRoot);
@@ -111,7 +163,7 @@ export function writeState(host, state, repoRoot = DEFAULT_REPO_ROOT) {
111
163
  * @param {string} host
112
164
  * @param {string} [repoRoot]
113
165
  */
114
- export function removeState(host, repoRoot = DEFAULT_REPO_ROOT) {
166
+ export function removeState(host, repoRoot = resolveDefaultRepoRoot()) {
115
167
  const path = statePath(host, repoRoot);
116
168
  if (existsSync(path)) unlinkSync(path);
117
169
  }
File without changes
File without changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stravica-ai/digital-operator-pa",
3
- "version": "0.1.28",
4
- "description": "Digital Operator PA — single-bundled public package surface (bins + library) for the platform.",
3
+ "version": "0.1.30",
4
+ "description": "Digital Operator PA \u2014 single-bundled public package surface (bins + library) for the platform.",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
7
7
  "exports": {
@@ -52,10 +52,11 @@
52
52
  "engines": {
53
53
  "node": ">=24"
54
54
  },
55
- "//": "test pins --test-isolation=none: default process-isolation mode silently undercounts (drops passed tests from totals) when a test awaits async work while stdout is patched by a capture() helper — see packages/cli/test/secrets. Counts must never lie.",
56
55
  "scripts": {
57
56
  "build": "node build.mjs",
57
+ "prepack": "node build.mjs",
58
58
  "test": "node --test --test-isolation=none test/*.test.mjs",
59
59
  "dry-publish": "node build.mjs && node scripts/dry-publish.mjs"
60
- }
61
- }
60
+ },
61
+ "//": "test pins --test-isolation=none: default process-isolation mode silently undercounts (drops passed tests from totals) when a test awaits async work while stdout is patched by a capture() helper \u2014 see packages/cli/test/secrets. Counts must never lie."
62
+ }