@rjfan/dsh-presence 0.18.0 → 0.20.1
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.
- package/README.md +170 -16
- package/README.zh-CN.md +114 -15
- package/client.js +1 -1
- package/index.js +139 -84
- package/package.json +1 -1
- package/src/api.js +6 -1
- package/src/core/connection.mjs +125 -31
- package/src/destructive.js +265 -12
- package/src/intercept.js +150 -0
- package/src/job-store.js +292 -0
- package/src/observed-store.js +136 -19
- package/src/pool.js +444 -97
- package/src/tools.js +1458 -258
- package/src/wait.js +352 -0
package/src/destructive.js
CHANGED
|
@@ -8,6 +8,16 @@
|
|
|
8
8
|
// `git clean` -> dry run) so nothing is removed,
|
|
9
9
|
// 3. refuses unset variables, workspace roots/ancestors, home, `/` and `.git`,
|
|
10
10
|
// 4. otherwise returns a report of the resolved targets and requires confirm.
|
|
11
|
+
//
|
|
12
|
+
// 0.19.0 adds two things the P0 feedback asked for:
|
|
13
|
+
// 5. a top-level split of the command line (`;`, `&&`, `||`, newlines outside
|
|
14
|
+
// quotes), so a rejected mixed command can list the segments that are safe
|
|
15
|
+
// to run on their own (`confirm:"partial"`), and
|
|
16
|
+
// 6. a single-file exemption: exactly `rm -f <literal path>` for a path that
|
|
17
|
+
// resolves inside the session workspace runs directly — it cannot remove a
|
|
18
|
+
// tree, which is what the guard exists to stop.
|
|
19
|
+
|
|
20
|
+
import { posix } from 'node:path';
|
|
11
21
|
|
|
12
22
|
export function shq(value) {
|
|
13
23
|
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
@@ -66,6 +76,176 @@ export function shellWords(text) {
|
|
|
66
76
|
return words;
|
|
67
77
|
}
|
|
68
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Split one command line on top-level `;`, `&&`, `||` and newlines.
|
|
81
|
+
*
|
|
82
|
+
* Quotes (`'`, `"`, backtick), backslash escapes and `(...)` / `$(...)` nesting
|
|
83
|
+
* are honoured, so `echo "a; b"` and `echo "$(a; b)"` stay one segment. A lone
|
|
84
|
+
* `&` or `|` is a background/pipeline operator rather than a sequence boundary,
|
|
85
|
+
* so it stays inside its segment; that keeps the unit conservative (the whole
|
|
86
|
+
* pipeline is one destructive or one safe segment).
|
|
87
|
+
*
|
|
88
|
+
* @returns {string[]} trimmed, non-empty segments in command order.
|
|
89
|
+
*/
|
|
90
|
+
export function splitShellSegments(command) {
|
|
91
|
+
const text = String(command ?? '');
|
|
92
|
+
const segments = [];
|
|
93
|
+
let current = '';
|
|
94
|
+
let quote = null;
|
|
95
|
+
let depth = 0;
|
|
96
|
+
const flush = () => {
|
|
97
|
+
const segment = current.trim();
|
|
98
|
+
if (segment) segments.push(segment);
|
|
99
|
+
current = '';
|
|
100
|
+
};
|
|
101
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
102
|
+
const character = text[index];
|
|
103
|
+
if (quote === "'") {
|
|
104
|
+
current += character;
|
|
105
|
+
if (character === "'") quote = null;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (quote === '"' || quote === '`') {
|
|
109
|
+
current += character;
|
|
110
|
+
if (character === '\\' && index + 1 < text.length) {
|
|
111
|
+
current += text[index + 1];
|
|
112
|
+
index += 1;
|
|
113
|
+
} else if (character === quote) {
|
|
114
|
+
quote = null;
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (character === '\\' && index + 1 < text.length) {
|
|
119
|
+
// An escaped operator is a literal character, not a boundary.
|
|
120
|
+
current += character + text[index + 1];
|
|
121
|
+
index += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (character === "'" || character === '"' || character === '`') {
|
|
125
|
+
quote = character;
|
|
126
|
+
current += character;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (character === '(') {
|
|
130
|
+
depth += 1;
|
|
131
|
+
current += character;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (character === ')') {
|
|
135
|
+
if (depth > 0) depth -= 1;
|
|
136
|
+
current += character;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (depth === 0) {
|
|
140
|
+
if (character === ';' || character === '\n' || character === '\r') {
|
|
141
|
+
flush();
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (character === '&' && text[index + 1] === '&') {
|
|
145
|
+
flush();
|
|
146
|
+
index += 1;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (character === '|' && text[index + 1] === '|') {
|
|
150
|
+
flush();
|
|
151
|
+
index += 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
current += character;
|
|
156
|
+
}
|
|
157
|
+
flush();
|
|
158
|
+
return segments;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Characters that make a path non-literal (glob, expansion, redirect, chaining). */
|
|
162
|
+
const NOT_LITERAL_PATH = /[*?[\]$;&|<>`(){}\\~\n\r]/;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The path of an exact `rm -f <one literal path>` command, or null.
|
|
166
|
+
*
|
|
167
|
+
* The exemption is intentionally narrow: a different verb/mount, `-r`, an
|
|
168
|
+
* extra operand, a glob, a variable, a redirect, a pipeline or a second
|
|
169
|
+
* command all keep the normal guard path.
|
|
170
|
+
*/
|
|
171
|
+
export function singleRmTarget(command) {
|
|
172
|
+
const text = String(command ?? '');
|
|
173
|
+
if (NOT_LITERAL_PATH.test(text)) return null;
|
|
174
|
+
const words = shellWords(text);
|
|
175
|
+
if (words.length !== 3) return null;
|
|
176
|
+
if (words[0] !== 'rm' || words[1] !== '-f') return null;
|
|
177
|
+
const target = words[2];
|
|
178
|
+
if (!target || target.startsWith('-')) return null;
|
|
179
|
+
return target;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function withoutTrailingSlashes(value) {
|
|
183
|
+
const text = String(value);
|
|
184
|
+
if (text === '/') return '/';
|
|
185
|
+
return text.replace(/\/+$/, '');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Absolute, lexically normalised form of `target` relative to `cwd`. */
|
|
189
|
+
function absoluteRemotePath(cwd, target) {
|
|
190
|
+
if (!cwd || !target) return null;
|
|
191
|
+
try {
|
|
192
|
+
return posix.normalize(posix.join(String(cwd), String(target)));
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* True when `target` is deletable under the exemption: inside the workspace,
|
|
200
|
+
* but neither the workspace root itself nor an ancestor of it.
|
|
201
|
+
*/
|
|
202
|
+
export function isDeletableWorkspaceFile(target, workspaceRoot) {
|
|
203
|
+
const path = withoutTrailingSlashes(target);
|
|
204
|
+
const base = withoutTrailingSlashes(workspaceRoot);
|
|
205
|
+
if (!path.startsWith('/') || !base.startsWith('/')) return false;
|
|
206
|
+
if (path === base) return false;
|
|
207
|
+
// `path` is an ancestor of the workspace root (e.g. `/tmp` for `/tmp/ws`).
|
|
208
|
+
if (base.startsWith(`${path}/`)) return false;
|
|
209
|
+
return path.startsWith(`${base}/`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Resolve a remote path through symlinks; falls back to the parent directory. */
|
|
213
|
+
async function resolveRemotePath(connection, target, cwd) {
|
|
214
|
+
const script =
|
|
215
|
+
`cd ${shq(cwd)} || exit 0; p=${shq(target)}; ` +
|
|
216
|
+
`r=$(realpath -m -- "$p" 2>/dev/null || readlink -f -- "$p" 2>/dev/null); ` +
|
|
217
|
+
`[ -n "$r" ] || r=$(cd "$(dirname -- "$p")" 2>/dev/null && printf '%s/%s' "$PWD" "$(basename -- "$p")"); ` +
|
|
218
|
+
`printf '%s\\n' "$r"`;
|
|
219
|
+
try {
|
|
220
|
+
const result = await connection.exec(`bash -c ${shq(script)}`, { timeoutMs: 15_000 });
|
|
221
|
+
const line = String(result.stdout ?? '')
|
|
222
|
+
.split('\n')
|
|
223
|
+
.map((value) => value.trim())
|
|
224
|
+
.filter(Boolean)[0];
|
|
225
|
+
return line ?? null;
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The 0.19.0 single-file exemption: `rm -f <one literal path>` for a path that
|
|
233
|
+
* resolves inside the workspace, is not the workspace root and cannot be an
|
|
234
|
+
* ancestor of it. Symlinks are resolved first, so a link inside the workspace
|
|
235
|
+
* that points outside is not exempt.
|
|
236
|
+
*/
|
|
237
|
+
async function exemptSingleFileRm({ connection, command, cwd, workspace }) {
|
|
238
|
+
const workspaceRoot = workspace?.path ?? null;
|
|
239
|
+
if (!cwd || !workspaceRoot) return false;
|
|
240
|
+
const target = singleRmTarget(command);
|
|
241
|
+
if (!target) return false;
|
|
242
|
+
const lexical = absoluteRemotePath(cwd, target);
|
|
243
|
+
if (!lexical) return false;
|
|
244
|
+
const resolved = await resolveRemotePath(connection, target, cwd);
|
|
245
|
+
const candidate = resolved && resolved.startsWith('/') ? posix.normalize(resolved) : lexical;
|
|
246
|
+
return isDeletableWorkspaceFile(candidate, workspaceRoot);
|
|
247
|
+
}
|
|
248
|
+
|
|
69
249
|
function isWithin(parent, child) {
|
|
70
250
|
if (!parent) return false;
|
|
71
251
|
const base = parent.replace(/\/+$/, '');
|
|
@@ -74,6 +254,13 @@ function isWithin(parent, child) {
|
|
|
74
254
|
|
|
75
255
|
/** Run the command in a shell where the destructive verb only reports. */
|
|
76
256
|
async function dryRunDestructive(connection, command, kind, cwd) {
|
|
257
|
+
if (kind === 'truncate') {
|
|
258
|
+
// There is no safe replay for these verbs: `shred`/`truncate`/`mkfs`/`dd of=`
|
|
259
|
+
// write for real and a `:>` redirect cannot be shadowed. Never execute them
|
|
260
|
+
// in the dry run; report that targets cannot be resolved instead of doing
|
|
261
|
+
// the damage while claiming "nothing was executed".
|
|
262
|
+
return { targets: [], stderr: '', code: 0, skipped: true };
|
|
263
|
+
}
|
|
77
264
|
let probe;
|
|
78
265
|
if (kind === 'rm') {
|
|
79
266
|
probe =
|
|
@@ -136,27 +323,71 @@ async function describeTargets(connection, targets, cwd) {
|
|
|
136
323
|
});
|
|
137
324
|
}
|
|
138
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Classify every top-level segment so a rejected command can say which parts
|
|
328
|
+
* were safe. `targets` is best effort: a segment that depends on an earlier
|
|
329
|
+
* assignment (`J=""; rm -rf $J`) cannot be dry-run on its own, so it stays empty
|
|
330
|
+
* instead of guessing.
|
|
331
|
+
*/
|
|
332
|
+
async function describeSplit({ connection, command, cwd, kind, resolved }) {
|
|
333
|
+
const safeSegments = [];
|
|
334
|
+
const destructiveSegments = [];
|
|
335
|
+
for (const segment of splitShellSegments(command)) {
|
|
336
|
+
const segmentKind = destructiveKind(segment);
|
|
337
|
+
if (segmentKind) destructiveSegments.push({ segment, kind: segmentKind, targets: [] });
|
|
338
|
+
else safeSegments.push(segment);
|
|
339
|
+
}
|
|
340
|
+
if (destructiveSegments.length === 0) {
|
|
341
|
+
// The classifier matched across a line break the splitter treats as a
|
|
342
|
+
// boundary; keep the whole command as the single destructive unit.
|
|
343
|
+
destructiveSegments.push({ segment: String(command).trim(), kind, targets: [] });
|
|
344
|
+
}
|
|
345
|
+
if (destructiveSegments.length === 1 && safeSegments.length === 0) {
|
|
346
|
+
// The whole command is the destructive segment: reuse the targets the
|
|
347
|
+
// guard already resolved instead of dry-running it a second time.
|
|
348
|
+
destructiveSegments[0].targets = resolved;
|
|
349
|
+
return { safeSegments, destructiveSegments };
|
|
350
|
+
}
|
|
351
|
+
for (const entry of destructiveSegments) {
|
|
352
|
+
try {
|
|
353
|
+
const segmentDry = await dryRunDestructive(connection, entry.segment, entry.kind, cwd);
|
|
354
|
+
entry.targets = await describeTargets(connection, segmentDry.targets, cwd);
|
|
355
|
+
} catch {
|
|
356
|
+
entry.targets = [];
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return { safeSegments, destructiveSegments };
|
|
360
|
+
}
|
|
361
|
+
|
|
139
362
|
/**
|
|
140
363
|
* Gate one destructive command.
|
|
141
364
|
*
|
|
142
|
-
* Returns null for a benign command (= run it)
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* `.git`) regardless of
|
|
365
|
+
* Returns null for a benign command (= run it) or for the single-file `rm -f`
|
|
366
|
+
* exemption, a report object for a destructive command without confirmation
|
|
367
|
+
* (= do not run, show the report), and throws for the unrecoverable cases
|
|
368
|
+
* (unset variable, workspace root/ancestor, home, `/`, `.git`) regardless of
|
|
369
|
+
* confirmation.
|
|
146
370
|
*
|
|
147
371
|
* @param connection - live PresenceConnection.
|
|
148
372
|
* @param command - the raw command line.
|
|
149
373
|
* @param cwd - remote working directory, or null.
|
|
150
374
|
* @param workspace - `{ path }` of the session workspace, or null.
|
|
151
375
|
* @param host - alias used in messages.
|
|
152
|
-
* @param confirm - the caller already saw a report and asked to proceed
|
|
376
|
+
* @param confirm - the caller already saw a report and asked to proceed
|
|
377
|
+
* (`true` = whole command, `'partial'` = the caller runs split.safeSegments).
|
|
153
378
|
*/
|
|
154
379
|
export async function guardDestructive({ connection, command, cwd = null, workspace = null, host = 'remote', confirm = false }) {
|
|
155
380
|
const kind = destructiveKind(command);
|
|
156
381
|
if (!kind) return null;
|
|
157
382
|
|
|
383
|
+
// 0.19.0: removing one generated file with `rm -f` is the everyday case; the
|
|
384
|
+
// guard exists to stop tree deletions. Everything that is not exactly
|
|
385
|
+
// `rm -f <literal path>` inside the workspace keeps the full guard below.
|
|
386
|
+
if (await exemptSingleFileRm({ connection, command, cwd, workspace })) return null;
|
|
387
|
+
|
|
158
388
|
const dry = await dryRunDestructive(connection, command, kind, cwd);
|
|
159
389
|
const resolved = await describeTargets(connection, dry.targets, cwd);
|
|
390
|
+
const split = await describeSplit({ connection, command, cwd, kind, resolved });
|
|
160
391
|
const home = await connection.homePath().catch(() => null);
|
|
161
392
|
const workspaceRoot = workspace?.path ?? null;
|
|
162
393
|
|
|
@@ -174,20 +405,42 @@ export async function guardDestructive({ connection, command, cwd = null, worksp
|
|
|
174
405
|
);
|
|
175
406
|
}
|
|
176
407
|
|
|
177
|
-
const
|
|
408
|
+
const targetsLine =
|
|
409
|
+
resolved.length > 0
|
|
410
|
+
? 'resolved targets:'
|
|
411
|
+
: dry.skipped
|
|
412
|
+
? `resolved targets: (not resolvable without doing it - ${kind} commands are never replayed)`
|
|
413
|
+
: 'resolved targets: (none - the command would touch nothing)';
|
|
414
|
+
const safeLines = split.safeSegments.length > 0 ? split.safeSegments.map((segment) => ` ${segment}`) : [' (none)'];
|
|
415
|
+
const confirmHint =
|
|
416
|
+
split.safeSegments.length > 0
|
|
417
|
+
? 'Pass confirm:true to run the whole command, confirm:"partial" to run only the safe segments, ' +
|
|
418
|
+
'or use presence_trash for a recoverable delete.'
|
|
419
|
+
: 'Pass confirm:true to run the whole command, or use presence_trash for a recoverable delete.';
|
|
420
|
+
|
|
421
|
+
const lines = [
|
|
422
|
+
'Nothing was executed — the whole command was skipped, including non-destructive parts.',
|
|
178
423
|
`destructive command needs confirmation (${kind}): nothing was executed yet`,
|
|
179
424
|
`host: ${host}`,
|
|
180
425
|
`command: ${String(command).replace(/\s+/g, ' ').slice(0, 400)}`,
|
|
181
426
|
cwd ? `cwd: ${cwd}` : null,
|
|
182
|
-
|
|
427
|
+
targetsLine,
|
|
183
428
|
...resolved.map((entry) => ` ${entry.type} ${entry.path} ${entry.entries} ${entry.size}`),
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
]
|
|
429
|
+
'',
|
|
430
|
+
'Safe segments you can run separately:',
|
|
431
|
+
...safeLines,
|
|
432
|
+
];
|
|
433
|
+
if (!confirm) lines.push('', confirmHint);
|
|
188
434
|
|
|
189
435
|
// `targets` is the machine-readable twin of the report's target block:
|
|
190
436
|
// callers that answer in JSON (presence_exec format:'json') do not need to
|
|
191
437
|
// parse the text, and existing callers keep reading `report`/`resolved`.
|
|
192
|
-
|
|
438
|
+
// `split` lets the caller run only the safe segments (confirm:"partial").
|
|
439
|
+
return {
|
|
440
|
+
kind,
|
|
441
|
+
report: lines.filter((line) => line !== null && line !== undefined).join('\n'),
|
|
442
|
+
resolved,
|
|
443
|
+
targets: resolved,
|
|
444
|
+
split,
|
|
445
|
+
};
|
|
193
446
|
}
|
package/src/intercept.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { parseMountPath, parsePresenceUrl, resolveRemoteInput } from './mounts.js';
|
|
10
10
|
import { remoteGlobDetailed, remoteGrep, splitGlobPattern } from './remote-search.js';
|
|
11
|
+
import { canonicalObservation, lookupObservation, recordObservation } from './observed-store.js';
|
|
11
12
|
|
|
12
13
|
const LIMIT = 200;
|
|
13
14
|
|
|
@@ -131,3 +132,152 @@ export function installToolInterceptor(ctx, { pool, cwdOf }) {
|
|
|
131
132
|
}
|
|
132
133
|
});
|
|
133
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Bridge between the Presence file tools and the harness read-before-edit
|
|
138
|
+
* policy (plugin `fs-observation-policy`).
|
|
139
|
+
*
|
|
140
|
+
* The policy records an authoritative observation only when a tool emits
|
|
141
|
+
* `fs/observed` with a resolved target and the returned version. Its state is a
|
|
142
|
+
* WeakMap keyed by the live Session object inside that plugin — it is not a
|
|
143
|
+
* service, so it cannot be read or written directly from here. What IS possible
|
|
144
|
+
* (and what this bridge does) is to publish the same event the built-in tools
|
|
145
|
+
* publish:
|
|
146
|
+
* - after `presence_read`/`presence_write`/`presence_edit` succeed (through the
|
|
147
|
+
* `observe` callback the tools call), so a later built-in `edit` on the same
|
|
148
|
+
* remote file passes;
|
|
149
|
+
* - after a built-in `read`/`read_image`, so the persisted record survives
|
|
150
|
+
* compaction (the guard's own WeakMap does not);
|
|
151
|
+
* - just before a built-in `edit`/`write`, re-publishing the recorded version
|
|
152
|
+
* when the file is still at exactly that version. A changed file is never
|
|
153
|
+
* auto-approved.
|
|
154
|
+
*
|
|
155
|
+
* `exec cat` (or any command that happens to read a file) is deliberately NOT
|
|
156
|
+
* turned into an observation: a shell command carries neither a target identity
|
|
157
|
+
* nor a version, and fabricating one would defeat the guard.
|
|
158
|
+
*
|
|
159
|
+
* @param ctx - plugin context (event owner).
|
|
160
|
+
* @param options.cwdOf - async (exec) => session cwd or null.
|
|
161
|
+
* @param options.late - late-bound services (`fs`).
|
|
162
|
+
* @returns `{ observe, resolveObservation }`; `observe` is the callback
|
|
163
|
+
* `definePresenceTools` expects, `resolveObservation` is useful for probes.
|
|
164
|
+
*/
|
|
165
|
+
export function installObservationBridge(ctx, { cwdOf, late }) {
|
|
166
|
+
const sessionIdOf = (exec) => exec?.agent?.session?.id ?? exec?.agent?.session?.header?.id ?? null;
|
|
167
|
+
|
|
168
|
+
const explicitHostOf = (exec) => {
|
|
169
|
+
const host = exec?.arguments?.host;
|
|
170
|
+
return typeof host === 'string' && host.trim() ? host.trim() : null;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Resolve a tool path spelling to `{ target, canonical }`. A remote spelling
|
|
175
|
+
* becomes the canonical `presence://host/path` target directly (so a local
|
|
176
|
+
* session that passed `host` explicitly still records the right identity);
|
|
177
|
+
* a genuinely local path falls back to the provider's own target, keeping the
|
|
178
|
+
* pre-0.20 local repair behavior.
|
|
179
|
+
*/
|
|
180
|
+
const resolveObservation = async (exec, path) => {
|
|
181
|
+
if (typeof path !== 'string' || path === '') return null;
|
|
182
|
+
let cwd = null;
|
|
183
|
+
try {
|
|
184
|
+
cwd = (await cwdOf(exec)) ?? null;
|
|
185
|
+
} catch {
|
|
186
|
+
cwd = null;
|
|
187
|
+
}
|
|
188
|
+
const explicitHost = explicitHostOf(exec);
|
|
189
|
+
const canonical = canonicalObservation(path, { cwd, host: explicitHost });
|
|
190
|
+
if (canonical) {
|
|
191
|
+
// The presence provider's identity IS the presence URL; the policy only
|
|
192
|
+
// reads `targetKey`, and every other `fs/observed` listener can stat this
|
|
193
|
+
// target through the same provider.
|
|
194
|
+
return { target: { targetKey: canonical.key, displayPath: canonical.key }, canonical };
|
|
195
|
+
}
|
|
196
|
+
// A relative spelling cannot be mapped when the call named a different host
|
|
197
|
+
// than the mounted workspace: resolving it through the provider would record
|
|
198
|
+
// the mounted machine's file, which is NOT the file the tool touched. Never
|
|
199
|
+
// fabricate a target that belongs to another machine.
|
|
200
|
+
if (explicitHost && cwd && parseMountPath(cwd)) return null;
|
|
201
|
+
const fs = late?.fs;
|
|
202
|
+
if (!fs || typeof fs.resolve !== 'function') return null;
|
|
203
|
+
try {
|
|
204
|
+
const target = await fs.resolve(path, cwd ? { cwd } : undefined);
|
|
205
|
+
if (!target) return null;
|
|
206
|
+
return { target, canonical: canonicalObservation(target, { cwd }) };
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** Record that this session saw the file at its current version. */
|
|
213
|
+
const observe = async (exec, path, options = {}) => {
|
|
214
|
+
try {
|
|
215
|
+
const resolved = await resolveObservation(exec, path);
|
|
216
|
+
if (!resolved) return;
|
|
217
|
+
const fs = late?.fs;
|
|
218
|
+
const stat = fs && typeof fs.stat === 'function' ? await fs.stat(resolved.target) : null;
|
|
219
|
+
const observation = stat ? { kind: 'present', version: stat.version } : { kind: 'absent' };
|
|
220
|
+
// The built-in read/write tools already publish their own event; a second
|
|
221
|
+
// one would only churn `changes` listeners. Presence tools have no other
|
|
222
|
+
// publisher, so they always publish here.
|
|
223
|
+
if (options.publish !== false) ctx.emit('fs/observed', resolved.target, observation, { agent: exec?.agent });
|
|
224
|
+
if (stat && options.remember !== false) {
|
|
225
|
+
recordObservation(
|
|
226
|
+
sessionIdOf(exec),
|
|
227
|
+
resolved.canonical ?? String(resolved.target.targetKey ?? ''),
|
|
228
|
+
stat.version,
|
|
229
|
+
{ cwd: null },
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
} catch {
|
|
233
|
+
/* the observation is an optimization, never a failure */
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** Re-publish a recorded observation if the file is still at that version. */
|
|
238
|
+
const repair = async (exec, path) => {
|
|
239
|
+
const sessionId = sessionIdOf(exec);
|
|
240
|
+
if (!sessionId) return null;
|
|
241
|
+
const resolved = await resolveObservation(exec, path);
|
|
242
|
+
if (!resolved) return null;
|
|
243
|
+
const targetKey = resolved.canonical?.key ?? String(resolved.target.targetKey ?? '');
|
|
244
|
+
const recorded = lookupObservation(sessionId, resolved.canonical ?? targetKey);
|
|
245
|
+
if (!recorded) return null;
|
|
246
|
+
const fs = late?.fs;
|
|
247
|
+
const stat = fs && typeof fs.stat === 'function' ? await fs.stat(resolved.target) : null;
|
|
248
|
+
if (!stat || stat.version !== recorded) return null;
|
|
249
|
+
ctx.emit('fs/observed', resolved.target, { kind: 'present', version: stat.version }, { agent: exec.agent });
|
|
250
|
+
return stat.version;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// Built-in read/read_image: remember what the session has seen, so the record
|
|
254
|
+
// survives compaction even though the guard's WeakMap does not.
|
|
255
|
+
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
256
|
+
try {
|
|
257
|
+
if (!result?.isError && (exec?.name === 'read' || exec?.name === 'read_image')) {
|
|
258
|
+
await observe(exec, exec.arguments?.file_path, { publish: false });
|
|
259
|
+
}
|
|
260
|
+
} catch {
|
|
261
|
+
/* best effort */
|
|
262
|
+
}
|
|
263
|
+
return next();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Built-in edit/write on a remote target: if this session read the file at
|
|
267
|
+
// the same version we recorded, re-publish the observation so the guard
|
|
268
|
+
// passes. A changed file is left alone: the guard's refusal is the point.
|
|
269
|
+
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
270
|
+
try {
|
|
271
|
+
if (exec?.name === 'edit' || exec?.name === 'write') {
|
|
272
|
+
const path = exec.arguments?.file_path;
|
|
273
|
+
if (typeof path === 'string' && path !== '') await repair(exec, path);
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
/* best effort: a failed repair leaves the original guard behavior */
|
|
277
|
+
}
|
|
278
|
+
return next();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
return { observe, resolveObservation };
|
|
282
|
+
}
|
|
283
|
+
|