edge-ai-client-ts 1.0.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/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +174 -0
- package/bin/ec-ts.js +18 -0
- package/dist/buffer/disk-queue.d.ts +140 -0
- package/dist/buffer/disk-queue.js +370 -0
- package/dist/cli/devices.d.ts +1 -0
- package/dist/cli/devices.js +61 -0
- package/dist/cli/enroll.d.ts +2 -0
- package/dist/cli/enroll.js +89 -0
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.js +116 -0
- package/dist/cli/messages.d.ts +1 -0
- package/dist/cli/messages.js +59 -0
- package/dist/cli/run.d.ts +5 -0
- package/dist/cli/run.js +112 -0
- package/dist/cli/status.d.ts +1 -0
- package/dist/cli/status.js +56 -0
- package/dist/cli/whoami.d.ts +2 -0
- package/dist/cli/whoami.js +41 -0
- package/dist/config/cmd-gate.d.ts +65 -0
- package/dist/config/cmd-gate.js +128 -0
- package/dist/config/settings.d.ts +209 -0
- package/dist/config/settings.js +627 -0
- package/dist/crypto/aes-gcm.d.ts +38 -0
- package/dist/crypto/aes-gcm.js +90 -0
- package/dist/crypto/hmac.d.ts +31 -0
- package/dist/crypto/hmac.js +52 -0
- package/dist/crypto/tls-guard.d.ts +36 -0
- package/dist/crypto/tls-guard.js +54 -0
- package/dist/daemon/manager.d.ts +82 -0
- package/dist/daemon/manager.js +461 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.js +63 -0
- package/dist/logging/file-logger.d.ts +21 -0
- package/dist/logging/file-logger.js +71 -0
- package/dist/network/ws-client.d.ts +221 -0
- package/dist/network/ws-client.js +1134 -0
- package/dist/session/fail-fast.d.ts +70 -0
- package/dist/session/fail-fast.js +122 -0
- package/dist/session/manager.d.ts +136 -0
- package/dist/session/manager.js +291 -0
- package/dist/session/persistence.d.ts +103 -0
- package/dist/session/persistence.js +194 -0
- package/dist/session/pi-rpc.d.ts +164 -0
- package/dist/session/pi-rpc.js +412 -0
- package/dist/session/sftp.d.ts +64 -0
- package/dist/session/sftp.js +335 -0
- package/dist/session/shell-frame.d.ts +77 -0
- package/dist/session/shell-frame.js +199 -0
- package/dist/session/shell.d.ts +124 -0
- package/dist/session/shell.js +300 -0
- package/docs/CONFIGURATION.md +169 -0
- package/docs/INSTALLATION.md +164 -0
- package/docs/PROTOCOL.md +248 -0
- package/docs/TROUBLESHOOTING.md +177 -0
- package/package.json +79 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi RPC integration — spawns the `pi` coding-agent CLI.
|
|
3
|
+
* Mirrors `edge-client/src/session/pi_rpc.rs`.
|
|
4
|
+
*
|
|
5
|
+
* Edge-client is a transport layer: forward prompt to pi stdin, forward
|
|
6
|
+
* ALL events from pi stdout to the buffer queue, handle buffering/ACK/reconnect.
|
|
7
|
+
*
|
|
8
|
+
* ## systemd-run wrapping (Phase 4 #3)
|
|
9
|
+
* When `systemd-run --user --scope` is available AND resource limits are set,
|
|
10
|
+
* pi is spawned under a per-agent transient scope that enforces MemoryMax /
|
|
11
|
+
* CPUQuota / TasksMax via Linux cgroups.
|
|
12
|
+
*
|
|
13
|
+
* ## Session continuity (GAP-017/P5)
|
|
14
|
+
* Each agent_id gets a stable pi session ID. On respawn, the same ID is reused
|
|
15
|
+
* so pi reopens its session file (conversation context survives).
|
|
16
|
+
*/
|
|
17
|
+
import * as child_process from 'node:child_process';
|
|
18
|
+
import * as os from 'node:os';
|
|
19
|
+
import { EventEmitter } from 'node:events';
|
|
20
|
+
/** True if any resource limit is set (non-zero). Mirrors Rust has_limits(). */
|
|
21
|
+
export function hasLimits(config) {
|
|
22
|
+
return config.memory_max_mb > 0 || config.cpu_quota_percent > 0 || config.tasks_max > 0;
|
|
23
|
+
}
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// systemd-run probing (mirrors Rust systemd_run_available)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
let cachedSystemdRunAvailable;
|
|
28
|
+
/**
|
|
29
|
+
* Probe whether `systemd-run` is available. Caches the result (probe once
|
|
30
|
+
* per process). Returns false on non-Linux or if `systemd-run` isn't on PATH.
|
|
31
|
+
* Mirrors Rust `systemd_run_available()`.
|
|
32
|
+
*/
|
|
33
|
+
export function systemdRunAvailable() {
|
|
34
|
+
if (cachedSystemdRunAvailable !== undefined) {
|
|
35
|
+
return cachedSystemdRunAvailable;
|
|
36
|
+
}
|
|
37
|
+
if (process.platform !== 'linux') {
|
|
38
|
+
cachedSystemdRunAvailable = false;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const result = child_process.spawnSync('systemd-run', ['--version'], {
|
|
43
|
+
stdio: 'ignore',
|
|
44
|
+
timeout: 5000,
|
|
45
|
+
});
|
|
46
|
+
cachedSystemdRunAvailable = result.status === 0;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
cachedSystemdRunAvailable = false;
|
|
50
|
+
}
|
|
51
|
+
return cachedSystemdRunAvailable;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reset the systemd-run probe cache (for testing).
|
|
55
|
+
* @internal
|
|
56
|
+
*/
|
|
57
|
+
export function resetSystemdRunCache() {
|
|
58
|
+
cachedSystemdRunAvailable = undefined;
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// cgroup wrapping (mirrors Rust wrap_with_cgroup)
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
/**
|
|
64
|
+
* Build the wrapped program + arg vector for a pi spawn, optionally under
|
|
65
|
+
* a per-agent `systemd-run --user --scope` transient unit.
|
|
66
|
+
*
|
|
67
|
+
* When `wrap` is false, returns `(piPath, originalArgs)` unchanged.
|
|
68
|
+
* When `wrap` is true, returns `("systemd-run", ["--user", "--scope", ...])`.
|
|
69
|
+
*
|
|
70
|
+
* Pure function — no I/O, fully unit-testable.
|
|
71
|
+
*/
|
|
72
|
+
export function wrapWithCgroup(piPath, originalArgs, agentId, resources, wrap) {
|
|
73
|
+
if (!wrap) {
|
|
74
|
+
return { program: piPath, args: [...originalArgs] };
|
|
75
|
+
}
|
|
76
|
+
const args = [
|
|
77
|
+
'--user',
|
|
78
|
+
'--scope',
|
|
79
|
+
`--unit=edge-pi-${agentId}`,
|
|
80
|
+
];
|
|
81
|
+
if (resources.memory_max_mb > 0) {
|
|
82
|
+
args.push(`--property=MemoryMax=${resources.memory_max_mb}M`);
|
|
83
|
+
}
|
|
84
|
+
if (resources.cpu_quota_percent > 0) {
|
|
85
|
+
args.push(`--property=CPUQuota=${resources.cpu_quota_percent}%`);
|
|
86
|
+
}
|
|
87
|
+
if (resources.tasks_max > 0) {
|
|
88
|
+
args.push(`--property=TasksMax=${resources.tasks_max}`);
|
|
89
|
+
}
|
|
90
|
+
args.push(piPath);
|
|
91
|
+
args.push(...originalArgs);
|
|
92
|
+
return { program: 'systemd-run', args };
|
|
93
|
+
}
|
|
94
|
+
export class PiProcess {
|
|
95
|
+
agentId;
|
|
96
|
+
machineId;
|
|
97
|
+
emitter = new EventEmitter();
|
|
98
|
+
child = null;
|
|
99
|
+
alive = false;
|
|
100
|
+
streaming = false;
|
|
101
|
+
sessionId;
|
|
102
|
+
parseErrors = 0;
|
|
103
|
+
currentMsgId;
|
|
104
|
+
constructor(opts) {
|
|
105
|
+
this.agentId = opts.agentId;
|
|
106
|
+
this.machineId = opts.machineId;
|
|
107
|
+
this.sessionId = opts.resumeSessionId;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Spawn pi process. Mirrors Rust `PiProcess::spawn`.
|
|
111
|
+
* Returns a promise that resolves when the process is spawned.
|
|
112
|
+
*/
|
|
113
|
+
async spawn(opts) {
|
|
114
|
+
const envCtx = this.buildEnvContext(opts.machineId);
|
|
115
|
+
const baseArgs = ['--mode', 'rpc', '--name', opts.agentId];
|
|
116
|
+
const resumeArgs = [];
|
|
117
|
+
if (opts.resumeSessionId !== undefined) {
|
|
118
|
+
resumeArgs.push('--session-id', opts.resumeSessionId);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
resumeArgs.push('--no-session');
|
|
122
|
+
}
|
|
123
|
+
resumeArgs.push('--append-system-prompt', envCtx);
|
|
124
|
+
// Decide whether to wrap under systemd-run
|
|
125
|
+
const wrap = process.platform === 'linux' && hasLimits(opts.resources) && systemdRunAvailable();
|
|
126
|
+
const { program, args: wrappedArgs } = wrapWithCgroup(opts.piPath, baseArgs, opts.agentId, opts.resources, wrap);
|
|
127
|
+
const allArgs = [...wrappedArgs, ...resumeArgs];
|
|
128
|
+
const spawnFn = opts.spawnFn ?? child_process.spawn;
|
|
129
|
+
this.child = spawnFn(program, allArgs, {
|
|
130
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
131
|
+
});
|
|
132
|
+
this.alive = true;
|
|
133
|
+
// Wire stdout reader
|
|
134
|
+
if (this.child.stdout !== null) {
|
|
135
|
+
this.child.stdout.setEncoding('utf8');
|
|
136
|
+
let buffer = '';
|
|
137
|
+
this.child.stdout.on('data', (chunk) => {
|
|
138
|
+
buffer += chunk;
|
|
139
|
+
const lines = buffer.split('\n');
|
|
140
|
+
buffer = lines.pop() ?? '';
|
|
141
|
+
for (const line of lines) {
|
|
142
|
+
const raw = line.trimEnd();
|
|
143
|
+
if (raw.length === 0)
|
|
144
|
+
continue;
|
|
145
|
+
this.handleStdoutLine(raw);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// Wire stderr reader (log only, do not forward)
|
|
150
|
+
if (this.child.stderr !== null) {
|
|
151
|
+
this.child.stderr.setEncoding('utf8');
|
|
152
|
+
this.child.stderr.on('data', (chunk) => {
|
|
153
|
+
const lines = chunk.split('\n');
|
|
154
|
+
for (const line of lines) {
|
|
155
|
+
const trimmed = line.trimEnd();
|
|
156
|
+
if (trimmed.length > 0) {
|
|
157
|
+
this.emitter.emit('stderr', `[pi:${this.agentId}:stderr] ${trimmed}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// Handle process exit
|
|
163
|
+
this.child.on('exit', () => {
|
|
164
|
+
this.alive = false;
|
|
165
|
+
this.emitter.emit('exit', this.agentId);
|
|
166
|
+
});
|
|
167
|
+
this.child.on('error', () => {
|
|
168
|
+
this.alive = false;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
handleStdoutLine(raw) {
|
|
172
|
+
let event;
|
|
173
|
+
try {
|
|
174
|
+
event = JSON.parse(raw);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
this.parseErrors++;
|
|
178
|
+
this.emitter.emit('stderr', `[pi:${this.agentId}:stdout] parse failed: ${raw.slice(0, 500)}`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Track turn boundary
|
|
182
|
+
if (event.type === 'agent_start') {
|
|
183
|
+
this.streaming = true;
|
|
184
|
+
}
|
|
185
|
+
else if (event.type === 'agent_end') {
|
|
186
|
+
this.streaming = false;
|
|
187
|
+
}
|
|
188
|
+
// Forward event as StreamMessage
|
|
189
|
+
const msgId = this.extractEventMsgId(event) ?? this.currentMsgId ?? 'unknown';
|
|
190
|
+
const entry = {
|
|
191
|
+
msg_id: msgId,
|
|
192
|
+
machine_id: this.machineId,
|
|
193
|
+
agent_id: this.agentId,
|
|
194
|
+
type: event.type,
|
|
195
|
+
payload: event,
|
|
196
|
+
timestamp: Date.now(),
|
|
197
|
+
};
|
|
198
|
+
this.emitter.emit('event', entry);
|
|
199
|
+
}
|
|
200
|
+
extractEventMsgId(event) {
|
|
201
|
+
// Check event.payload.id first (pi RpcResponse echoes it)
|
|
202
|
+
const id = event['id'];
|
|
203
|
+
if (typeof id === 'string' && id.trim().length > 0) {
|
|
204
|
+
return id.trim();
|
|
205
|
+
}
|
|
206
|
+
// Check alternate keys
|
|
207
|
+
for (const key of ['messageId', 'requestId']) {
|
|
208
|
+
const val = event[key];
|
|
209
|
+
if (typeof val === 'string' && val.trim().length > 0) {
|
|
210
|
+
return val.trim();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
/** Send a command to pi stdin. */
|
|
216
|
+
writeCommand(cmd) {
|
|
217
|
+
if (this.child?.stdin === null || this.child?.stdin === undefined) {
|
|
218
|
+
throw new Error('pi stdin closed');
|
|
219
|
+
}
|
|
220
|
+
const json = JSON.stringify(cmd);
|
|
221
|
+
this.child.stdin.write(json + '\n');
|
|
222
|
+
this.currentMsgId = typeof cmd.id === 'string' ? cmd.id : this.currentMsgId;
|
|
223
|
+
}
|
|
224
|
+
/** Send a prompt command. Mirrors Rust PiProcess::send_prompt. */
|
|
225
|
+
sendPrompt(msgId, message, streamingBehavior) {
|
|
226
|
+
const cmd = {
|
|
227
|
+
id: msgId,
|
|
228
|
+
type: 'prompt',
|
|
229
|
+
message,
|
|
230
|
+
...(streamingBehavior !== undefined ? { streamingBehavior } : {}),
|
|
231
|
+
};
|
|
232
|
+
this.writeCommand(cmd);
|
|
233
|
+
}
|
|
234
|
+
/** Send an abort command. Mirrors Rust PiProcess::send_abort. */
|
|
235
|
+
sendAbort() {
|
|
236
|
+
this.writeCommand({ type: 'abort' });
|
|
237
|
+
}
|
|
238
|
+
/** Forward extension_ui_response to pi. */
|
|
239
|
+
sendExtensionUIResponse(requestId, response) {
|
|
240
|
+
const cmd = {
|
|
241
|
+
type: 'extension_ui_response',
|
|
242
|
+
id: requestId,
|
|
243
|
+
...response,
|
|
244
|
+
};
|
|
245
|
+
if (this.child?.stdin === null || this.child?.stdin === undefined) {
|
|
246
|
+
throw new Error('pi stdin closed');
|
|
247
|
+
}
|
|
248
|
+
this.child.stdin.write(JSON.stringify(cmd) + '\n');
|
|
249
|
+
}
|
|
250
|
+
/** Switch LLM model. Mirrors Rust PiProcess::set_model. */
|
|
251
|
+
setModel(provider, modelId) {
|
|
252
|
+
this.writeCommand({
|
|
253
|
+
type: 'set_model',
|
|
254
|
+
provider,
|
|
255
|
+
modelId,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/** Set thinking level. Mirrors Rust PiProcess::set_thinking_level. */
|
|
259
|
+
setThinkingLevel(level) {
|
|
260
|
+
this.writeCommand({ type: 'set_thinking_level', level });
|
|
261
|
+
}
|
|
262
|
+
/** Manual context compaction. Mirrors Rust PiProcess::compact. */
|
|
263
|
+
compact() {
|
|
264
|
+
this.writeCommand({ type: 'compact' });
|
|
265
|
+
}
|
|
266
|
+
/** Get available models. Mirrors Rust PiProcess::get_available_models. */
|
|
267
|
+
getAvailableModels(id) {
|
|
268
|
+
this.writeCommand({ type: 'get_available_models', id });
|
|
269
|
+
}
|
|
270
|
+
/** Is the agent currently streaming (between agent_start and agent_end)? */
|
|
271
|
+
isStreaming() {
|
|
272
|
+
return this.streaming;
|
|
273
|
+
}
|
|
274
|
+
/** Is the pi process still alive (stdout not EOF)? GAP-003 health check. */
|
|
275
|
+
isAlive() {
|
|
276
|
+
return this.alive;
|
|
277
|
+
}
|
|
278
|
+
/** Number of unparseable stdout lines (Q4 GAP-020 health counter). */
|
|
279
|
+
getParseErrorCount() {
|
|
280
|
+
return this.parseErrors;
|
|
281
|
+
}
|
|
282
|
+
/** Captured pi session ID (GAP-017/P5). */
|
|
283
|
+
getPersistedSessionId() {
|
|
284
|
+
return this.sessionId;
|
|
285
|
+
}
|
|
286
|
+
/** Register a handler for StreamMessage events. */
|
|
287
|
+
onEvent(handler) {
|
|
288
|
+
this.emitter.on('event', handler);
|
|
289
|
+
}
|
|
290
|
+
/** Register a handler for exit events. */
|
|
291
|
+
onExit(handler) {
|
|
292
|
+
this.emitter.on('exit', handler);
|
|
293
|
+
}
|
|
294
|
+
/** Register a handler for stderr log lines. */
|
|
295
|
+
onStderr(handler) {
|
|
296
|
+
this.emitter.on('stderr', handler);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Graceful shutdown: send abort, close stdin, kill child.
|
|
300
|
+
* Mirrors Rust PiProcess::terminate.
|
|
301
|
+
*/
|
|
302
|
+
terminate() {
|
|
303
|
+
try {
|
|
304
|
+
this.sendAbort();
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
// stdin may already be closed
|
|
308
|
+
}
|
|
309
|
+
if (this.child?.stdin !== null && this.child?.stdin !== undefined) {
|
|
310
|
+
this.child.stdin.end();
|
|
311
|
+
}
|
|
312
|
+
if (this.child !== null) {
|
|
313
|
+
try {
|
|
314
|
+
this.child.kill();
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
// process may already be dead
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
this.alive = false;
|
|
321
|
+
}
|
|
322
|
+
buildEnvContext(machineId) {
|
|
323
|
+
const platform = os.platform();
|
|
324
|
+
const user = process.env.USER ?? process.env.USERNAME ?? 'unknown';
|
|
325
|
+
const cwd = process.cwd();
|
|
326
|
+
return (`[Edge Client Context]\n` +
|
|
327
|
+
`Machine ID: ${machineId}\n` +
|
|
328
|
+
`Operating System: ${platform}\n` +
|
|
329
|
+
`User: ${user}\n` +
|
|
330
|
+
`Working Directory: ${cwd}`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// RpcCommand factories (mirrors Rust RpcCommand methods)
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
export function promptCommand(id, message) {
|
|
337
|
+
return { id, type: 'prompt', message };
|
|
338
|
+
}
|
|
339
|
+
export function promptWithStreamingCommand(id, message, behavior) {
|
|
340
|
+
return { id, type: 'prompt', message, streamingBehavior: behavior };
|
|
341
|
+
}
|
|
342
|
+
export function abortCommand() {
|
|
343
|
+
return { type: 'abort' };
|
|
344
|
+
}
|
|
345
|
+
export function setModelCommand(provider, modelId) {
|
|
346
|
+
return { type: 'set_model', provider, modelId };
|
|
347
|
+
}
|
|
348
|
+
export function setThinkingLevelCommand(level) {
|
|
349
|
+
return { type: 'set_thinking_level', level };
|
|
350
|
+
}
|
|
351
|
+
export function compactCommand() {
|
|
352
|
+
return { type: 'compact' };
|
|
353
|
+
}
|
|
354
|
+
export function getAvailableModelsCommand(id) {
|
|
355
|
+
return { type: 'get_available_models', id };
|
|
356
|
+
}
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Pure helpers for testing (mirrors Rust pure functions)
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
/**
|
|
361
|
+
* Generate a pi-compatible session ID (RFC 4122 v4 UUID).
|
|
362
|
+
* Mirrors Rust `new_session_id()`.
|
|
363
|
+
*/
|
|
364
|
+
export function newPiSessionId() {
|
|
365
|
+
// Use crypto.randomUUID() for UUIDv4
|
|
366
|
+
return crypto.randomUUID();
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Extract pi sessionId from event payload.
|
|
370
|
+
* Mirrors Rust `extract_session_id`.
|
|
371
|
+
*/
|
|
372
|
+
export function extractSessionId(eventType, payload) {
|
|
373
|
+
// get_state response → data.sessionId (authoritative source)
|
|
374
|
+
if (eventType === 'response') {
|
|
375
|
+
const command = payload['command'];
|
|
376
|
+
if (command === 'get_state') {
|
|
377
|
+
const data = payload['data'];
|
|
378
|
+
if (typeof data === 'object' && data !== null) {
|
|
379
|
+
const sessionId = data['sessionId'];
|
|
380
|
+
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
|
381
|
+
return sessionId;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
// Defensive: check common keys
|
|
387
|
+
for (const key of ['sessionId', 'session_id']) {
|
|
388
|
+
const val = payload[key];
|
|
389
|
+
if (typeof val === 'string' && val.length > 0) {
|
|
390
|
+
return val;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return undefined;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Resolve msg_id for a forwarded event. Mirrors Rust `event_msg_id`.
|
|
397
|
+
* Prefers pi's own id in payload, falls back to currentMsgId.
|
|
398
|
+
*/
|
|
399
|
+
export function eventMsgId(payload, fallback) {
|
|
400
|
+
const id = payload['id'];
|
|
401
|
+
if (typeof id === 'string' && id.trim().length > 0) {
|
|
402
|
+
return id.trim();
|
|
403
|
+
}
|
|
404
|
+
for (const key of ['messageId', 'message_id', 'requestId', 'request_id']) {
|
|
405
|
+
const val = payload[key];
|
|
406
|
+
if (typeof val === 'string' && val.trim().length > 0) {
|
|
407
|
+
return val.trim();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const trimmed = fallback.trim();
|
|
411
|
+
return trimmed.length > 0 ? trimmed : 'unknown';
|
|
412
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SFTP-over-frame — structured file RPC over the shell binary channel.
|
|
3
|
+
* Mirrors `edge-client/src/session/sftp.rs`.
|
|
4
|
+
*
|
|
5
|
+
* A frame type `FrameType::Sftp = 6` carries a JSON request/response payload
|
|
6
|
+
* on the existing 17-byte shell frame. The relay is a dumb byte-pump:
|
|
7
|
+
* it forwards Sftp frames verbatim; ALL filesystem logic lives here.
|
|
8
|
+
* Under the hood this is JSON RPC over `node:fs` — we do NOT shell out
|
|
9
|
+
* to `sftp` (the edge-client already runs as the Standard User with full
|
|
10
|
+
* filesystem access).
|
|
11
|
+
*
|
|
12
|
+
* ## Protocol
|
|
13
|
+
* Request (browser → edge, Sftp frame payload = UTF-8 JSON):
|
|
14
|
+
* ```json
|
|
15
|
+
* {"op":"list|read|write|delete|stat","path":"/abs/path"[,"content":"..."],"reqId":1}
|
|
16
|
+
* ```
|
|
17
|
+
* Response (edge → browser):
|
|
18
|
+
* ```json
|
|
19
|
+
* {"reqId":1,"ok":true,"data":{...}}
|
|
20
|
+
* {"reqId":1,"ok":false,"error":"..."}
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* ## P1-a / R-MED-2 — per-session SFTP chroot
|
|
24
|
+
* When the relay sends an `SftpRoot` control frame (frame type 7) on shell
|
|
25
|
+
* session open, the edge stores the supplied root path in a per-session map.
|
|
26
|
+
* Every subsequent Sftp op for that session is resolved under the root.
|
|
27
|
+
* A `null` root (or absent) means "no chroot" (default).
|
|
28
|
+
*
|
|
29
|
+
* ## E-H2 — max file size for read/write ops
|
|
30
|
+
* Files larger than 32 MiB are refused to prevent browser OOM.
|
|
31
|
+
*/
|
|
32
|
+
interface SftpRequest {
|
|
33
|
+
op: string;
|
|
34
|
+
path: string;
|
|
35
|
+
content?: string;
|
|
36
|
+
reqId?: number;
|
|
37
|
+
}
|
|
38
|
+
interface SftpResponse {
|
|
39
|
+
reqId: number;
|
|
40
|
+
ok: boolean;
|
|
41
|
+
data?: Record<string, unknown>;
|
|
42
|
+
error?: string;
|
|
43
|
+
}
|
|
44
|
+
declare function setSessionRoot(sessionId: string, payload: Buffer): void;
|
|
45
|
+
declare function resolveSftpPath(sessionId: string, userPath: string): string;
|
|
46
|
+
declare function dispatch(sessionId: string, req: SftpRequest): SftpResponse;
|
|
47
|
+
export type FrameHandler = (sessionId: string, frameType: number, payload: Buffer) => void;
|
|
48
|
+
export declare class SftpForwarder {
|
|
49
|
+
private readonly emitter;
|
|
50
|
+
/**
|
|
51
|
+
* Handle an inbound Sftp frame. Mirrors Rust `handle_sftp_request`.
|
|
52
|
+
* Parses the JSON request, dispatches the file op, and emits the response
|
|
53
|
+
* frame (to be sent back to the relay/browser).
|
|
54
|
+
*/
|
|
55
|
+
handleFrame(sessionId: string, frameType: number, payload: Buffer): void;
|
|
56
|
+
/**
|
|
57
|
+
* Register a handler for outbound Sftp response frames.
|
|
58
|
+
* The handler receives (sessionId, frameType, payloadBytes).
|
|
59
|
+
*/
|
|
60
|
+
onFrame(handler: FrameHandler): void;
|
|
61
|
+
}
|
|
62
|
+
/** Clear all session roots (for tests only). */
|
|
63
|
+
export declare function clearSessionRoots(): void;
|
|
64
|
+
export { setSessionRoot, resolveSftpPath, dispatch };
|