@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/index.js
CHANGED
|
@@ -4,13 +4,12 @@
|
|
|
4
4
|
import z from '@deepseek-ai/schemastery';
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
6
6
|
import { PresencePool } from './src/pool.js';
|
|
7
|
+
import { JobStore } from './src/job-store.js';
|
|
7
8
|
import { definePresenceTools } from './src/tools.js';
|
|
8
9
|
import { presenceRoutes } from './src/api.js';
|
|
9
|
-
import { activeWorkspace } from './src/workspaces.js';
|
|
10
10
|
import { shared } from './src/shared.js';
|
|
11
11
|
import { mountsRoot, parseMountPath } from './src/mounts.js';
|
|
12
|
-
import { installToolInterceptor } from './src/intercept.js';
|
|
13
|
-
import { lookupObservation, recordObservation } from './src/observed-store.js';
|
|
12
|
+
import { installObservationBridge, installToolInterceptor } from './src/intercept.js';
|
|
14
13
|
import { TunnelStore } from './src/tunnel-store.js';
|
|
15
14
|
import { PortDiscovery } from './src/port-discovery.js';
|
|
16
15
|
import { installJobNotifier } from './src/job-notifier.js';
|
|
@@ -70,7 +69,11 @@ export function apply(ctx, config = {}) {
|
|
|
70
69
|
...config,
|
|
71
70
|
};
|
|
72
71
|
|
|
73
|
-
|
|
72
|
+
// The job registry outlives the process: detached jobs are still on the
|
|
73
|
+
// machine after a restart, and the store is what makes them visible again
|
|
74
|
+
// (as `unknown` until a pid probe proves otherwise).
|
|
75
|
+
const jobStore = new JobStore();
|
|
76
|
+
const pool = new PresencePool(settings, { jobStore });
|
|
74
77
|
shared.pool = pool;
|
|
75
78
|
shared.mountsRoot = mountsRoot();
|
|
76
79
|
// D1/D2: saved forwards and the listening-port scanner share one lifecycle
|
|
@@ -94,6 +97,7 @@ export function apply(ctx, config = {}) {
|
|
|
94
97
|
// D1/D2 owners, constructed above with the pool.
|
|
95
98
|
tunnelStore,
|
|
96
99
|
portDiscovery,
|
|
100
|
+
jobStore,
|
|
97
101
|
};
|
|
98
102
|
ctx.inject(['agents'], (scope) => {
|
|
99
103
|
late.agents = scope.agents;
|
|
@@ -121,88 +125,22 @@ export function apply(ctx, config = {}) {
|
|
|
121
125
|
// The harness read-before-edit policy is event-driven: it records a target as
|
|
122
126
|
// "seen" only when a tool dispatches `fs/observed`. presence_read/write/edit
|
|
123
127
|
// are not the built-in fs tools, so they publish the observation themselves —
|
|
124
|
-
// otherwise an agent that read a file with presence_read still hits
|
|
125
|
-
//
|
|
128
|
+
// otherwise an agent that read a file with presence_read still hits "file has
|
|
129
|
+
// not been read" when it tries the built-in edit.
|
|
126
130
|
//
|
|
127
|
-
// The
|
|
128
|
-
//
|
|
129
|
-
// just before a built-in edit/write, re-
|
|
130
|
-
// is still at that exact version.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const resolveTarget = async (exec, path) => {
|
|
134
|
-
const fs = late.fs;
|
|
135
|
-
if (!fs || typeof path !== 'string' || path === '') return null;
|
|
136
|
-
const cwd = await cwdOf(exec);
|
|
137
|
-
const target = await fs.resolve(path, cwd ? { cwd } : undefined);
|
|
138
|
-
return target ?? null;
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
const observe = async (exec, path, options = {}) => {
|
|
142
|
-
try {
|
|
143
|
-
const target = await resolveTarget(exec, path);
|
|
144
|
-
if (!target) return;
|
|
145
|
-
const stat = await late.fs.stat(target);
|
|
146
|
-
const observation = stat ? { kind: 'present', version: stat.version } : { kind: 'absent' };
|
|
147
|
-
ctx.emit('fs/observed', target, observation, { agent: exec.agent });
|
|
148
|
-
if (stat && options.remember !== false) {
|
|
149
|
-
recordObservation(sessionIdOf(exec), String(target.targetKey ?? ''), stat.version);
|
|
150
|
-
}
|
|
151
|
-
} catch {
|
|
152
|
-
/* the observation is an optimization, never a failure */
|
|
153
|
-
}
|
|
154
|
-
};
|
|
155
|
-
|
|
131
|
+
// The bridge (src/intercept.js) canonicalizes every path spelling to
|
|
132
|
+
// `presence://host/path`, persists (session, canonical target) -> version and,
|
|
133
|
+
// just before a built-in edit/write, re-publishes the observation when the
|
|
134
|
+
// file is still at that exact version. That repairs the guard after
|
|
135
|
+
// compaction, when its own WeakMap is gone; a changed file is never
|
|
136
|
+
// auto-approved.
|
|
156
137
|
ctx.inject(['fs'], (scope) => {
|
|
157
138
|
late.fs = scope.fs;
|
|
158
139
|
scope.effect(() => () => {
|
|
159
140
|
late.fs = undefined;
|
|
160
141
|
});
|
|
161
142
|
});
|
|
162
|
-
|
|
163
|
-
// Built-in read/read_image: remember what the session has seen, so the record
|
|
164
|
-
// survives compaction even though the guard's WeakMap does not.
|
|
165
|
-
ctx.on('tools/post-execute', async (exec, result, next) => {
|
|
166
|
-
try {
|
|
167
|
-
if (!result?.isError && (exec?.name === 'read' || exec?.name === 'read_image')) {
|
|
168
|
-
const path = exec.arguments?.file_path;
|
|
169
|
-
const target = await resolveTarget(exec, path);
|
|
170
|
-
if (target) {
|
|
171
|
-
const stat = await late.fs?.stat(target);
|
|
172
|
-
if (stat) recordObservation(sessionIdOf(exec), String(target.targetKey ?? ''), stat.version);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
} catch {
|
|
176
|
-
/* best effort */
|
|
177
|
-
}
|
|
178
|
-
return next();
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
// Built-in edit/write on a remote target: if this session read the file at the
|
|
182
|
-
// same version we recorded, re-publish the observation so the guard passes.
|
|
183
|
-
ctx.on('tools/pre-execute', async (exec, next) => {
|
|
184
|
-
try {
|
|
185
|
-
if (exec?.name === 'edit' || exec?.name === 'write') {
|
|
186
|
-
const path = exec.arguments?.file_path;
|
|
187
|
-
if (typeof path === 'string' && late.fs) {
|
|
188
|
-
const sessionId = sessionIdOf(exec);
|
|
189
|
-
const target = await resolveTarget(exec, path);
|
|
190
|
-
if (target && sessionId) {
|
|
191
|
-
const recorded = lookupObservation(sessionId, String(target.targetKey ?? ''));
|
|
192
|
-
if (recorded) {
|
|
193
|
-
const stat = await late.fs.stat(target);
|
|
194
|
-
if (stat && stat.version === recorded) {
|
|
195
|
-
ctx.emit('fs/observed', target, { kind: 'present', version: stat.version }, { agent: exec.agent });
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
} catch {
|
|
202
|
-
/* best effort: a failed repair leaves the original guard behavior */
|
|
203
|
-
}
|
|
204
|
-
return next();
|
|
205
|
-
});
|
|
143
|
+
const { observe } = installObservationBridge(ctx, { cwdOf, late });
|
|
206
144
|
|
|
207
145
|
for (const definition of definePresenceTools(pool, settings, { cwdOf, observe })) {
|
|
208
146
|
ctx.tools.register(definition);
|
|
@@ -285,7 +223,20 @@ export function apply(ctx, config = {}) {
|
|
|
285
223
|
});
|
|
286
224
|
});
|
|
287
225
|
|
|
288
|
-
//
|
|
226
|
+
// ---------------------------------------------------------------- prompts
|
|
227
|
+
//
|
|
228
|
+
// The static context is deployment-wide and token-cheap. 0.20 makes the
|
|
229
|
+
// session-scope sentence explicit: a LOCAL session and a session inside a
|
|
230
|
+
// remote workspace need opposite rules, and the 0.19 wording ("if your
|
|
231
|
+
// working directory is under the mounts root...") let a local session assume
|
|
232
|
+
// it was remote.
|
|
233
|
+
const STATIC_GUIDANCE =
|
|
234
|
+
'- 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/stat/search/glob, and listing or running commands there needs presence_exec (e.g. presence_exec {command:"ls -la /data"}); present and Office only see local files, so presence_export first.\n' +
|
|
235
|
+
'- 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' +
|
|
236
|
+
'- 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' +
|
|
237
|
+
'- Destructive commands: dry-run first; confirm:true runs the whole command, confirm:"partial" runs only the safe segments; prefer presence_trash.\n' +
|
|
238
|
+
'- Hosts: transient failures are retried, then a host may show degraded with the last error; presence_hosts marks the current workspace machine.';
|
|
239
|
+
|
|
289
240
|
ctx.inject(['systemPrompt'], (scope) => {
|
|
290
241
|
scope.effect(() =>
|
|
291
242
|
scope.systemPrompt.context({
|
|
@@ -298,8 +249,13 @@ export function apply(ctx, config = {}) {
|
|
|
298
249
|
const live = connected.length > 0 ? `connected now: ${connected.join(', ')}` : 'none connected yet';
|
|
299
250
|
return (
|
|
300
251
|
`Presence ${VERSION ?? ''}${VERSION ? ':' : ''} ${hosts.length} remote machines from ~/.ssh/config (${live}). ` +
|
|
301
|
-
'
|
|
302
|
-
'
|
|
252
|
+
'Session scope — decide this first; the two cases need opposite behaviour. ' +
|
|
253
|
+
'(a) If your working directory is under the presence mounts root (`~/.dsh/presence/mounts/<host>/<path>`), ' +
|
|
254
|
+
'this session RUNS IN that remote workspace: built-in read/write/edit, the file tree, and grep/glob all reach it ' +
|
|
255
|
+
'(grep/glob are routed to the remote machine automatically). ' +
|
|
256
|
+
'(b) If your working directory is NOT under that root, this session is LOCAL: built-in tools touch only local files, ' +
|
|
257
|
+
'so every presence_* call must pass `host` explicitly (or have PRESENCE_HOST configured once), and remote output is ' +
|
|
258
|
+
'never your workspace. ' +
|
|
303
259
|
'TO CHANGE A REMOTE FILE use the built-in `edit`/`write` tools or presence_edit/presence_write — never `sed -i`, ' +
|
|
304
260
|
'`perl -pi`, or shell redirects, which bypass the atomic write, the ambiguity check and the version guard. ' +
|
|
305
261
|
'DESTRUCTIVE shell commands (rm -r/-f, git clean -fdx, find -delete, shred, dd of=) are dry-run first and need confirm:true; ' +
|
|
@@ -310,13 +266,112 @@ export function apply(ctx, config = {}) {
|
|
|
310
266
|
'read_image works directly on remote images (mount path or presence:// URL) as long as the model accepts image input. ' +
|
|
311
267
|
'Built-in read/write/edit and presence_read/write/edit both reach the remote workspace and share the read-before-edit guard; ' +
|
|
312
268
|
'that guard is repaired automatically after compaction, so a file you already read stays editable. Prefer the built-in tools inside the workspace and presence_* outside it. ' +
|
|
313
|
-
'For long work start it with presence_exec {background:true}, then use presence_jobs {action:"wait"} instead of sleep+poll, ' +
|
|
269
|
+
'For long work start it with presence_exec {background:true}, then use presence_jobs {action:"wait"} or presence_wait instead of sleep+poll, ' +
|
|
314
270
|
'and presence_jobs {action:"list"|"stop"} to manage this session\'s jobs. ' +
|
|
315
271
|
'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.'
|
|
272
|
+
'Paths may be given relative to the workspace root, as a presence:// URL, or as the local mount path.' +
|
|
273
|
+
`\n\nGuidance:\n${STATIC_GUIDANCE}` +
|
|
274
|
+
'\n\nSession scope rules:\n' +
|
|
275
|
+
'- Local session: the globally last-opened remote workspace is not bound to this session; pass host explicitly on every presence_* call, and treat presence_exec output as remote data — not as files in your working directory.\n' +
|
|
276
|
+
'- Remote-workspace session: prefer the built-in read/write/edit/grep/glob inside the mounted workspace; use presence_read/write/edit/stat/search/glob for absolute paths elsewhere (presence_exec for listing/commands), and pass host explicitly for any machine other than the mount host.\n' +
|
|
277
|
+
'- presence_hosts marks the machine this session is mounted on (if any) and the explicit default host; check it before assuming a machine.'
|
|
317
278
|
);
|
|
318
279
|
},
|
|
319
280
|
}),
|
|
320
281
|
);
|
|
321
282
|
});
|
|
283
|
+
|
|
284
|
+
// Per-agent session scope. No static text can know where *this* agent runs, so
|
|
285
|
+
// resolve the agent's working directory once when it is created and register a
|
|
286
|
+
// scoped system-prompt section (the pattern `dsh-file-reference-local` uses:
|
|
287
|
+
// `agent.ctx.inject([...])` + `scope.systemPrompt.section(...)`), disposed with
|
|
288
|
+
// the agent. The section text is a function so a `cd` into/out of a mounted
|
|
289
|
+
// workspace updates it; the resolved cwd is only the fallback.
|
|
290
|
+
const sessionScopeFibers = new Map();
|
|
291
|
+
const sessionScopeText = (cwd) => {
|
|
292
|
+
if (!cwd) return '';
|
|
293
|
+
const mounted = parseMountPath(cwd);
|
|
294
|
+
if (mounted) {
|
|
295
|
+
return (
|
|
296
|
+
`This session runs in the remote workspace ${mounted.host}:${mounted.path}; ` +
|
|
297
|
+
'built-in read/write/edit, the file tree and grep/glob reach it. ' +
|
|
298
|
+
`presence_* calls may omit host for ${mounted.host} only — pass host explicitly for any other machine — ` +
|
|
299
|
+
'and remote output is not your local workspace.'
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return (
|
|
303
|
+
`This session is LOCAL (cwd=${JSON.stringify(cwd)}). Presence tools are optional: ` +
|
|
304
|
+
'pass host explicitly on every presence_* call (or configure PRESENCE_HOST once), and never treat remote output ' +
|
|
305
|
+
'as the workspace or as a local path.'
|
|
306
|
+
);
|
|
307
|
+
};
|
|
308
|
+
const installSessionScope = (agent) => {
|
|
309
|
+
if (!agent?.ctx || sessionScopeFibers.has(agent)) return sessionScopeFibers.get(agent) ?? null;
|
|
310
|
+
let fiber = null;
|
|
311
|
+
try {
|
|
312
|
+
fiber = agent.ctx.inject(['systemPrompt', 'workingDirectory'], async (scope) => {
|
|
313
|
+
let cwd = null;
|
|
314
|
+
try {
|
|
315
|
+
cwd = (await scope.workingDirectory.ensure(agent)) ?? null;
|
|
316
|
+
} catch {
|
|
317
|
+
cwd = null;
|
|
318
|
+
}
|
|
319
|
+
scope.systemPrompt.section({
|
|
320
|
+
name: 'presence:session-scope',
|
|
321
|
+
order: 450,
|
|
322
|
+
text: () => {
|
|
323
|
+
let current = cwd;
|
|
324
|
+
try {
|
|
325
|
+
current = scope.workingDirectory?.get?.(agent.session) ?? cwd;
|
|
326
|
+
} catch {
|
|
327
|
+
current = cwd;
|
|
328
|
+
}
|
|
329
|
+
return sessionScopeText(current);
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
sessionScopeFibers.set(agent, fiber);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
console.error(`presence: could not install the per-agent session scope: ${error?.message ?? String(error)}`);
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
return fiber;
|
|
339
|
+
};
|
|
340
|
+
const disposeSessionScope = (agent) => {
|
|
341
|
+
const fiber = sessionScopeFibers.get(agent);
|
|
342
|
+
if (!fiber) return;
|
|
343
|
+
sessionScopeFibers.delete(agent);
|
|
344
|
+
try {
|
|
345
|
+
Promise.resolve(fiber.dispose()).catch(() => {});
|
|
346
|
+
} catch {
|
|
347
|
+
/* already gone */
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
ctx.on('agent/created', (payload) => {
|
|
351
|
+
installSessionScope(payload?.agent ?? payload);
|
|
352
|
+
});
|
|
353
|
+
ctx.on('agent/disposed', (payload) => {
|
|
354
|
+
disposeSessionScope(payload?.agent ?? payload);
|
|
355
|
+
});
|
|
356
|
+
// Agents that already exist when Presence activates need the section too; a
|
|
357
|
+
// missing `agents` service just means there is nothing to install yet.
|
|
358
|
+
ctx.inject(['agents'], (scope) => {
|
|
359
|
+
try {
|
|
360
|
+
for (const agent of scope.agents.list()) installSessionScope(agent);
|
|
361
|
+
} catch {
|
|
362
|
+
/* best effort: the static context still covers the session scope */
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
ctx.effect(() => () => {
|
|
366
|
+
const disposals = [];
|
|
367
|
+
for (const [agent, fiber] of [...sessionScopeFibers]) {
|
|
368
|
+
sessionScopeFibers.delete(agent);
|
|
369
|
+
try {
|
|
370
|
+
disposals.push(Promise.resolve(fiber.dispose()));
|
|
371
|
+
} catch {
|
|
372
|
+
/* already gone */
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return Promise.allSettled(disposals).then(() => {});
|
|
376
|
+
});
|
|
322
377
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rjfan/dsh-presence",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.1",
|
|
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/api.js
CHANGED
|
@@ -296,7 +296,9 @@ export function presenceRoutes(pool, late = {}) {
|
|
|
296
296
|
}
|
|
297
297
|
// C2: the client detects running -> exited transitions from this list.
|
|
298
298
|
// Trimmed to the toast fields: this poll runs every few seconds and the
|
|
299
|
-
// full pool row carries the command line (session-log sensitive).
|
|
299
|
+
// full pool row carries the command line (session-log sensitive). 0.20
|
|
300
|
+
// adds the process identity and the two log paths, so a client can show
|
|
301
|
+
// which process a persisted job belongs to and tail the right log.
|
|
300
302
|
let jobs = [];
|
|
301
303
|
try {
|
|
302
304
|
jobs = (await pool.listJobs({ sessionId: undefined })).map((job) => ({
|
|
@@ -309,6 +311,9 @@ export function presenceRoutes(pool, late = {}) {
|
|
|
309
311
|
startedAt: job.startedAt ?? null,
|
|
310
312
|
logPath: job.logPath ?? null,
|
|
311
313
|
pid: job.pid ?? null,
|
|
314
|
+
pgid: job.pgid ?? null,
|
|
315
|
+
commandLogPath: job.commandLogPath ?? null,
|
|
316
|
+
persisted: job.persisted === true,
|
|
312
317
|
}));
|
|
313
318
|
} catch {
|
|
314
319
|
jobs = [];
|
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}`}`);
|