@rjfan/dsh-presence 0.18.0 → 0.19.0
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 +94 -9
- package/README.zh-CN.md +47 -9
- package/client.js +1 -1
- package/index.js +7 -1
- package/package.json +1 -1
- package/src/core/connection.mjs +125 -31
- package/src/destructive.js +265 -12
- package/src/pool.js +136 -11
- package/src/tools.js +646 -137
package/index.js
CHANGED
|
@@ -313,7 +313,13 @@ export function apply(ctx, config = {}) {
|
|
|
313
313
|
'For long work start it with presence_exec {background:true}, then use presence_jobs {action:"wait"} instead of sleep+poll, ' +
|
|
314
314
|
'and presence_jobs {action:"list"|"stop"} to manage this session\'s jobs. ' +
|
|
315
315
|
'The mount path is virtual: Explorer, Office, Python and native open cannot see remote files — call presence_export first, then present the returned local path. ' +
|
|
316
|
-
'Paths may be given relative to the workspace root, as a presence:// URL, or as the local mount path.'
|
|
316
|
+
'Paths may be given relative to the workspace root, as a presence:// URL, or as the local mount path.' +
|
|
317
|
+
'\n\nGuidance:\n' +
|
|
318
|
+
'- Which tool: inside the workspace mount use the built-in read/write/edit/grep/glob (grep/glob are routed to the remote machine); absolute paths outside it (/data, GPFS, /scratch) need presence_read/write/edit/ls/stat/search/glob; present and Office only see local files, so presence_export first.\n' +
|
|
319
|
+
'- Long jobs: presence_exec {background:true} then presence_jobs {action:"status"|"wait"}, with presence_tail to follow the log. Interactive shells: presence_terminal (tmux sessions survive reconnects). Do not start nohup/tmux new-session/& inside a foreground presence_exec for long work — a login-node session can be cleaned up; background:true already detaches with setsid, and on scheduler-managed clusters use the cluster launcher (rlaunch -d, salloc/sbatch).\n' +
|
|
320
|
+
'- Output: \\r progress bars are normalized by default (progress:"all"/"strip" available); output over the cap is spilled to a remote file whose path is returned — presence_tail it.\n' +
|
|
321
|
+
'- Destructive commands: dry-run first; confirm:true runs the whole command, confirm:"partial" runs only the safe segments; prefer presence_trash.\n' +
|
|
322
|
+
'- Hosts: transient failures are retried, then a host may show degraded with the last error; presence_hosts marks the current workspace machine.'
|
|
317
323
|
);
|
|
318
324
|
},
|
|
319
325
|
}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rjfan/dsh-presence",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Work on any machine in your ssh config as if it were local: persistent connections, native file editing, search, exec and terminals, with nothing installed on the remote host.",
|
|
6
6
|
"icon": "icon.svg",
|
package/src/core/connection.mjs
CHANGED
|
@@ -123,6 +123,42 @@ function splitLines(text) {
|
|
|
123
123
|
return lines;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// Progress bars (tqdm, apt, pip, docker pull) repaint one line with `\r`, so a
|
|
127
|
+
// captured stream carries every intermediate state. `last` keeps what each line
|
|
128
|
+
// finally settled on; `all` turns every repaint into its own line so the log
|
|
129
|
+
// stays lossless; `strip` only drops the CR bytes. `raw` is the internal escape
|
|
130
|
+
// hatch for callers reading file content, where a CR may be real data.
|
|
131
|
+
const PROGRESS_MODES = new Set(['last', 'all', 'strip', 'raw']);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Normalize carriage-return progress output in one captured stream.
|
|
135
|
+
*
|
|
136
|
+
* Applied before the output cap and the spill file are computed, so the text a
|
|
137
|
+
* caller sees, the bytes counted against `maxOutputBytes` and the spilled copy
|
|
138
|
+
* all describe the same stream.
|
|
139
|
+
*
|
|
140
|
+
* @param {string} text - the captured stream decoded as latin1 (byte-faithful).
|
|
141
|
+
* @param {'last'|'all'|'strip'|'raw'} [mode] - an unknown mode falls back to
|
|
142
|
+
* `'last'`.
|
|
143
|
+
* @returns {string} the normalized text; re-encoding it as latin1 restores the
|
|
144
|
+
* original bytes except for the CR/LF bytes this mode rewrote.
|
|
145
|
+
*/
|
|
146
|
+
export function normalizeProgress(text, mode = 'last') {
|
|
147
|
+
const chosen = PROGRESS_MODES.has(mode) ? mode : 'last';
|
|
148
|
+
const value = String(text ?? '');
|
|
149
|
+
if (chosen === 'raw') return value;
|
|
150
|
+
if (chosen === 'strip') return value.replace(/\r/g, '');
|
|
151
|
+
if (chosen === 'all') return value.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
152
|
+
// 'last': per line, keep only what was written after its final carriage return.
|
|
153
|
+
return value
|
|
154
|
+
.split('\n')
|
|
155
|
+
.map((line) => {
|
|
156
|
+
const at = line.lastIndexOf('\r');
|
|
157
|
+
return at === -1 ? line : line.slice(at + 1);
|
|
158
|
+
})
|
|
159
|
+
.join('\n');
|
|
160
|
+
}
|
|
161
|
+
|
|
126
162
|
function randomToken() {
|
|
127
163
|
return randomBytes(6).toString('hex');
|
|
128
164
|
}
|
|
@@ -391,7 +427,10 @@ export class PresenceConnection {
|
|
|
391
427
|
}
|
|
392
428
|
|
|
393
429
|
/** Run one command over a fresh channel; the connection stays up. */
|
|
394
|
-
async exec(
|
|
430
|
+
async exec(
|
|
431
|
+
command,
|
|
432
|
+
{ timeoutMs = 120_000, env = null, signal, maxOutputBytes = OUTPUT_CAP_BYTES, progress = 'last' } = {},
|
|
433
|
+
) {
|
|
395
434
|
if (this.#closed) throw new Error('connection is closed');
|
|
396
435
|
if (signal?.aborted) throw new Error('command aborted');
|
|
397
436
|
|
|
@@ -415,13 +454,22 @@ export class PresenceConnection {
|
|
|
415
454
|
|
|
416
455
|
await this.#acquire();
|
|
417
456
|
try {
|
|
418
|
-
return await this.#execChannel(wrapped, { timeoutMs: budgetMs, signal, maxOutputBytes, remoteDeadline });
|
|
457
|
+
return await this.#execChannel(wrapped, { timeoutMs: budgetMs, signal, maxOutputBytes, remoteDeadline, progress });
|
|
419
458
|
} finally {
|
|
420
459
|
this.#release();
|
|
421
460
|
}
|
|
422
461
|
}
|
|
423
462
|
|
|
424
|
-
#execChannel(
|
|
463
|
+
#execChannel(
|
|
464
|
+
command,
|
|
465
|
+
{
|
|
466
|
+
timeoutMs = 0,
|
|
467
|
+
signal = null,
|
|
468
|
+
maxOutputBytes = OUTPUT_CAP_BYTES,
|
|
469
|
+
remoteDeadline = false,
|
|
470
|
+
progress = 'last',
|
|
471
|
+
} = {},
|
|
472
|
+
) {
|
|
425
473
|
const cap = Number(maxOutputBytes) > 0 ? Math.floor(Number(maxOutputBytes)) : Infinity;
|
|
426
474
|
|
|
427
475
|
return new Promise((resolve, reject) => {
|
|
@@ -438,26 +486,27 @@ export class PresenceConnection {
|
|
|
438
486
|
signal?.removeEventListener('abort', onAbort);
|
|
439
487
|
};
|
|
440
488
|
|
|
441
|
-
/**
|
|
489
|
+
/**
|
|
490
|
+
* Head of the stream for the caller, the rest onto the remote disk.
|
|
491
|
+
*
|
|
492
|
+
* Normalization happens first: CR and LF are single-byte ASCII, so
|
|
493
|
+
* decoding as latin1 rewrites only those bytes and re-encoding restores
|
|
494
|
+
* every other byte exactly, even for binary output. The returned head is
|
|
495
|
+
* decoded as UTF-8 afterwards, as before.
|
|
496
|
+
*
|
|
497
|
+
* A truncated stream always carries either the spill path or the reason
|
|
498
|
+
* the spill could not be written, so output is never dropped silently.
|
|
499
|
+
*/
|
|
442
500
|
const keepHead = async (chunks, kind) => {
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
// a command that prints gigabytes must not fill /tmp.
|
|
449
|
-
const file = `/tmp/presence-exec-${randomToken()}${kind === 'stdout' ? '' : `-${kind}`}.log`;
|
|
450
|
-
// Best effort, and deliberately outside the slot pool: the caller still
|
|
451
|
-
// owns a slot while this result is being built.
|
|
452
|
-
await this.#pruneSpillLogs();
|
|
453
|
-
const data = all.subarray(0, SPILL_MAX_BYTES);
|
|
454
|
-
try {
|
|
455
|
-
const written = await this.writeText(file, data);
|
|
456
|
-
return { text, truncated: true, file, bytes: written.bytes };
|
|
457
|
-
} catch {
|
|
458
|
-
// A read-only /tmp must not turn a finished command into a failure.
|
|
459
|
-
return { text, truncated: true, file: null, bytes: 0 };
|
|
501
|
+
const raw = Buffer.concat(chunks);
|
|
502
|
+
const all =
|
|
503
|
+
progress === 'raw' ? raw : Buffer.from(normalizeProgress(raw.toString('latin1'), progress), 'latin1');
|
|
504
|
+
if (all.length <= cap) {
|
|
505
|
+
return { text: all.toString('utf8'), truncated: false, file: null, bytes: 0, error: null };
|
|
460
506
|
}
|
|
507
|
+
const text = all.subarray(0, cap).toString('utf8');
|
|
508
|
+
const spill = await this.#spillOutput(all, kind);
|
|
509
|
+
return { text, truncated: true, file: spill.file, bytes: spill.bytes, error: spill.error };
|
|
461
510
|
};
|
|
462
511
|
|
|
463
512
|
const buildResult = async (code, signalName, { timedOut, durationMs }) => {
|
|
@@ -471,16 +520,30 @@ export class PresenceConnection {
|
|
|
471
520
|
durationMs,
|
|
472
521
|
stdoutTruncated: stdout.truncated,
|
|
473
522
|
stderrTruncated: stderr.truncated,
|
|
474
|
-
timedOut,
|
|
523
|
+
timedOut: Boolean(timedOut),
|
|
475
524
|
};
|
|
476
525
|
if (stdout.truncated) {
|
|
526
|
+
// A path is always reported; when the spill write itself failed the
|
|
527
|
+
// path is null and the reason travels with it.
|
|
477
528
|
value.stdoutFile = stdout.file;
|
|
478
529
|
value.stdoutFileBytes = stdout.bytes;
|
|
530
|
+
if (stdout.file === null) value.stdoutFileError = stdout.error;
|
|
479
531
|
}
|
|
480
532
|
if (stderr.truncated) {
|
|
481
533
|
value.stderrFile = stderr.file;
|
|
482
534
|
value.stderrFileBytes = stderr.bytes;
|
|
535
|
+
if (stderr.file === null) value.stderrFileError = stderr.error;
|
|
483
536
|
}
|
|
537
|
+
if (timedOut) {
|
|
538
|
+
// The deadline fired: the remote `timeout` wrapper sends TERM and
|
|
539
|
+
// then KILL; when only the local deadline applies, closing the
|
|
540
|
+
// channel is the only kill there is.
|
|
541
|
+
value.killed = true;
|
|
542
|
+
value.killSequence = remoteDeadline ? ['SIGTERM', 'SIGKILL'] : ['channel-close'];
|
|
543
|
+
}
|
|
544
|
+
// The shell convention is 128 + 13 for SIGPIPE; keep the code as-is and
|
|
545
|
+
// let callers translate the meaning.
|
|
546
|
+
if (code === 141 || /^SIGPIPE$/i.test(String(signalName ?? ''))) value.sigpipe = true;
|
|
484
547
|
return value;
|
|
485
548
|
};
|
|
486
549
|
|
|
@@ -514,14 +577,9 @@ export class PresenceConnection {
|
|
|
514
577
|
} catch {
|
|
515
578
|
/* channel already gone */
|
|
516
579
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
reject(new Error(`command timed out after ${timeoutMs}ms (the remote process may still be running)`));
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
// The remote `timeout` should have returned by now; report the deadline
|
|
524
|
-
// as hit instead of hanging the caller on a channel that never closes.
|
|
580
|
+
// Keep the output captured before the kill instead of throwing it away:
|
|
581
|
+
// the caller gets `timedOut`, the kill sequence and the partial stream
|
|
582
|
+
// (which `buildResult` spills when it is over the cap).
|
|
525
583
|
buildResult(124, null, { timedOut: true, durationMs: started ? Date.now() - started : 0 }).then(resolve, reject);
|
|
526
584
|
}
|
|
527
585
|
|
|
@@ -552,7 +610,8 @@ export class PresenceConnection {
|
|
|
552
610
|
}
|
|
553
611
|
started = Date.now();
|
|
554
612
|
// No setEncoding: byte counts must match the cap, and the spill file has
|
|
555
|
-
// to hold the
|
|
613
|
+
// to hold the captured stream (progress-normalized in `keepHead`, with
|
|
614
|
+
// the raw bytes preserved for every non-CR/LF byte).
|
|
556
615
|
channel.on('data', (chunk) => {
|
|
557
616
|
stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), 'utf8'));
|
|
558
617
|
});
|
|
@@ -603,6 +662,39 @@ export class PresenceConnection {
|
|
|
603
662
|
}
|
|
604
663
|
}
|
|
605
664
|
|
|
665
|
+
/**
|
|
666
|
+
* Write captured output past the in-memory cap to /tmp. The usual path is
|
|
667
|
+
* `/tmp/presence-exec-<token>.log`; a failed write is retried once at
|
|
668
|
+
* `/tmp/presence-exec-<token>-retry.log`, and only then does the result carry
|
|
669
|
+
* a null path plus the reason — output is never dropped without a trace.
|
|
670
|
+
*
|
|
671
|
+
* Best effort, and deliberately outside the slot pool: the caller still owns
|
|
672
|
+
* a slot while this result is being built.
|
|
673
|
+
*/
|
|
674
|
+
async #spillOutput(buffer, kind) {
|
|
675
|
+
if (this.#closed) return { file: null, bytes: 0, error: 'the connection closed before the spill file was written' };
|
|
676
|
+
const file = `/tmp/presence-exec-${randomToken()}${kind === 'stdout' ? '' : `-${kind}`}.log`;
|
|
677
|
+
const retry = `${file.slice(0, -'.log'.length)}-retry.log`;
|
|
678
|
+
// It holds at most SPILL_MAX_BYTES: a command that prints gigabytes must not
|
|
679
|
+
// fill /tmp.
|
|
680
|
+
const data = buffer.subarray(0, SPILL_MAX_BYTES);
|
|
681
|
+
await this.#pruneSpillLogs();
|
|
682
|
+
let failure = null;
|
|
683
|
+
for (const path of [file, retry]) {
|
|
684
|
+
try {
|
|
685
|
+
const written = await this.writeText(path, data);
|
|
686
|
+
return { file: path, bytes: written.bytes, error: null };
|
|
687
|
+
} catch (error) {
|
|
688
|
+
failure = error;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
file: null,
|
|
693
|
+
bytes: 0,
|
|
694
|
+
error: String(failure?.message ?? failure ?? 'could not write the spill file'),
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
606
698
|
/** Open an interactive terminal channel (PTY allocated server-side). */
|
|
607
699
|
async shell({ cols = 120, rows = 32, term = 'xterm-256color' } = {}) {
|
|
608
700
|
if (this.#closed) throw new Error('connection is closed');
|
|
@@ -1283,6 +1375,8 @@ export class PresenceConnection {
|
|
|
1283
1375
|
const read = await this.exec(`sed -n ${shellQuote(`${first},${end}p`)} ${shellQuote(path)}`, {
|
|
1284
1376
|
timeoutMs: 60_000,
|
|
1285
1377
|
maxOutputBytes: budget,
|
|
1378
|
+
// This is file content, not command output: a CR in the file is data.
|
|
1379
|
+
progress: 'raw',
|
|
1286
1380
|
});
|
|
1287
1381
|
if (read.code !== 0 && read.stdout === '') {
|
|
1288
1382
|
throw new Error(`could not read ${path}: ${read.stderr.trim() || `sed exited ${read.code}`}`);
|
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
|
}
|