@stravica-ai/digital-operator-pa 0.1.29 → 0.1.31
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.
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { createHash, randomUUID } from 'node:crypto';
|
|
15
15
|
import { hostname } from 'node:os';
|
|
16
|
-
import { relative,
|
|
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
|
|
@@ -49,21 +90,37 @@ export function guessKindFromPath(sourcePath) {
|
|
|
49
90
|
}
|
|
50
91
|
|
|
51
92
|
/**
|
|
52
|
-
* Build
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
93
|
+
* Build an EventInput envelope for one file.
|
|
94
|
+
*
|
|
95
|
+
* The envelope construction is uniform across every kind (clientEventId,
|
|
96
|
+
* occurredAt, producer, tier, provenance, contentSha). Only the
|
|
97
|
+
* `payload` sub-object is per-kind: `knowledge.doc.delete` carries
|
|
98
|
+
* `{path, source?}` (deletion — no content field; the ingest schema
|
|
99
|
+
* refuses `content` as an additionalProperty for this kind, spec §3.1 +
|
|
100
|
+
* `remote-library` `src/schema/payloads.ts`), while every other kind
|
|
101
|
+
* currently ships as `{path, content}` (content-bearing). This mapping
|
|
102
|
+
* is intentionally narrow: only the delete branch differs. Every
|
|
103
|
+
* non-delete kind's payload shape is preserved byte-for-byte from the
|
|
104
|
+
* pre-cure encoding, so nothing else can regress (asserted by the
|
|
105
|
+
* envelope regression test).
|
|
106
|
+
*
|
|
107
|
+
* For the `knowledge.observation.staged` default the payload carries
|
|
108
|
+
* `path` (destination hint — the worker's apply function decides the
|
|
109
|
+
* actual write path) and the raw markdown body. `frontmatter` is left
|
|
110
|
+
* absent; the worker parses it if present in the body.
|
|
57
111
|
*
|
|
58
112
|
* @param {object} args
|
|
59
113
|
* @param {string} args.repoRoot
|
|
60
114
|
* @param {string} args.sourcePath absolute or repo-relative
|
|
61
|
-
* @param {string} args.content
|
|
115
|
+
* @param {string} [args.content] ignored for `knowledge.doc.delete`
|
|
62
116
|
* @param {object} args.producer { operator, agent, host?, session?, subkeyId }
|
|
63
117
|
* @param {string} [args.kind] overrides guess; must be in KNOWN_KINDS
|
|
64
118
|
* @param {string} [args.tier] default "auto" sentinel (spec §2.3)
|
|
65
119
|
* @param {'staging'|'flush'|'role-output'|'manual'|'synthesis'} [args.sourceKind]
|
|
66
120
|
* default "manual"; `library flush` passes "flush"
|
|
121
|
+
* @param {string} [args.source] optional routing hint; only encoded
|
|
122
|
+
* into the payload for `knowledge.doc.delete` (mirrors the schema's
|
|
123
|
+
* optional `source` field for the delete kind — spec §3.1 shape).
|
|
67
124
|
* @param {() => Date} [args.clock] test seam
|
|
68
125
|
* @returns {object} EventInput ready for POST /events
|
|
69
126
|
*/
|
|
@@ -75,11 +132,25 @@ export function buildEnvelope({
|
|
|
75
132
|
kind,
|
|
76
133
|
tier = 'auto',
|
|
77
134
|
sourceKind = 'manual',
|
|
135
|
+
source,
|
|
78
136
|
clock = () => new Date(),
|
|
79
137
|
}) {
|
|
138
|
+
// Repo-relative resolution + refusal for anything that would encode as
|
|
139
|
+
// a `..`-segment or otherwise escape the repo root. This is the root
|
|
140
|
+
// cause cure for the Stream 5 re-verify Scope C poison-event wedge
|
|
141
|
+
// (w-2026-08-24-dave-025). Before this cure `relative(repoRoot, abs)`
|
|
142
|
+
// silently produced `../../etc/...` for files sitting outside the
|
|
143
|
+
// repo and the envelope was posted with a payload path the worker's
|
|
144
|
+
// apply gate would later refuse — halting single-writer propagation
|
|
145
|
+
// for the whole estate. Refusing here (client-side, at encoding time)
|
|
146
|
+
// stops the poison at source; SA14's ingest-side check is the second
|
|
147
|
+
// line; the worker's apply gate remains the third.
|
|
80
148
|
const relPath = sourcePath
|
|
81
149
|
? isAbsolute(sourcePath) ? relative(repoRoot, sourcePath) : sourcePath
|
|
82
150
|
: undefined;
|
|
151
|
+
if (relPath !== undefined) {
|
|
152
|
+
assertRepoRelativeSafe(relPath, sourcePath, repoRoot);
|
|
153
|
+
}
|
|
83
154
|
|
|
84
155
|
const resolvedKind = kind ?? guessKindFromPath(relPath ?? sourcePath ?? '') ?? null;
|
|
85
156
|
if (!resolvedKind) {
|
|
@@ -98,10 +169,27 @@ export function buildEnvelope({
|
|
|
98
169
|
}
|
|
99
170
|
|
|
100
171
|
const now = clock();
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
172
|
+
// Per-kind payload construction (spec §3.1 + `remote-library` payload
|
|
173
|
+
// schema). Fix Wave E (w-2026-08-24-dave-029): before this branch the
|
|
174
|
+
// envelope encoded `{path, content}` for every kind, and the ingest
|
|
175
|
+
// schema for `knowledge.doc.delete` refused the `content` key with
|
|
176
|
+
// 422 (additionalProperties:false, required=["path"]). Result: the
|
|
177
|
+
// delete verb structurally could not succeed via the CLI — the same
|
|
178
|
+
// deferred-defect class the retirement programme is here to close.
|
|
179
|
+
// The delete branch omits `content` and (optionally) attaches
|
|
180
|
+
// `source`. Every other kind keeps the pre-cure `{path, content}`
|
|
181
|
+
// shape byte-for-byte; the regression test in envelope.test.mjs
|
|
182
|
+
// asserts that determinism against a frozen contentSha.
|
|
183
|
+
const payload =
|
|
184
|
+
resolvedKind === 'knowledge.doc.delete'
|
|
185
|
+
? {
|
|
186
|
+
path: relPath ?? null,
|
|
187
|
+
...(source !== undefined ? { source } : {}),
|
|
188
|
+
}
|
|
189
|
+
: {
|
|
190
|
+
path: relPath ?? null,
|
|
191
|
+
content,
|
|
192
|
+
};
|
|
105
193
|
|
|
106
194
|
const envelope = {
|
|
107
195
|
clientEventId: randomUUID(),
|
|
@@ -26,7 +26,14 @@ export async function runPut({ config, args, fetchImpl, log = defaultLog }) {
|
|
|
26
26
|
log('usage: library put <path> [--kind <kind>] [--tier auto|<tier>]');
|
|
27
27
|
return 1;
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
// Delete events carry no content by spec (§3.1 payload schema); the
|
|
30
|
+
// operator can and typically will have removed the file locally before
|
|
31
|
+
// asking the tier remote to catch up. Reading a file that does not
|
|
32
|
+
// exist would be a spurious ENOENT — so we skip the read entirely for
|
|
33
|
+
// delete. Every other kind still reads the file to populate
|
|
34
|
+
// payload.content. Fix Wave E (w-2026-08-24-dave-029) partner cure to
|
|
35
|
+
// the envelope kind-aware branch.
|
|
36
|
+
const content = kind === 'knowledge.doc.delete' ? undefined : await readFile(path, 'utf8');
|
|
30
37
|
|
|
31
38
|
const producer = producerFromPreferences(config);
|
|
32
39
|
const envelope = buildEnvelope({
|
package/package.json
CHANGED