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,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGT-02 Fail-Fast Policy (SPEC §3.3) — tool-output safety invariant.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `edge-client/src/session/fail_fast.rs` 1:1. The edge-client's core
|
|
5
|
+
* doctrine is "TRUST PI, TRANSPORT 100%": it forwards every pi event verbatim
|
|
6
|
+
* and does not second-guess pi's reasoning. AGT-02 adds ONE scoped exception:
|
|
7
|
+
* it inspects pi's **tool execution OUTPUT** (not pi's reasoning/text) for
|
|
8
|
+
* forbidden OS-policy keywords. When a tool result contains "Access Denied"
|
|
9
|
+
* / "AppLocker" / "Permission denied", the agent reasoning loop is aborted
|
|
10
|
+
* immediately and an error is reported to the server, so pi cannot auto-seek
|
|
11
|
+
* alternative methods that would violate the organization's internal security
|
|
12
|
+
* policy.
|
|
13
|
+
*
|
|
14
|
+
* This does NOT break transport-100%: the triggering event is still forwarded
|
|
15
|
+
* unchanged — the wrapper only ADDS an abort + a synthetic `error` event.
|
|
16
|
+
*
|
|
17
|
+
* Disable for pure-trust mode via `EDGE_FAIL_FAST=0` (or `EDGE_FAIL_FAST=false`).
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Forbidden keywords per SPEC §3.3 AGT-02. Matched case-insensitively as
|
|
21
|
+
* substrings of the concatenated tool-output text.
|
|
22
|
+
* Mirror of Rust `fail_fast::FORBIDDEN_KEYWORDS`.
|
|
23
|
+
*/
|
|
24
|
+
export declare const FORBIDDEN_KEYWORDS: readonly string[];
|
|
25
|
+
/**
|
|
26
|
+
* Pi event types whose payload may carry tool OUTPUT worth inspecting.
|
|
27
|
+
* We deliberately do NOT inspect reasoning/text events (`message_update`,
|
|
28
|
+
* `message_end`, ...) — only concrete tool execution results, so the policy
|
|
29
|
+
* acts on what a tool actually returned, never on pi's narration.
|
|
30
|
+
* Mirror of Rust `fail_fast::INSPECTABLE_EVENT_TYPES`.
|
|
31
|
+
*/
|
|
32
|
+
export declare const INSPECTABLE_EVENT_TYPES: readonly string[];
|
|
33
|
+
/**
|
|
34
|
+
* Synthetic error content emitted on fail-fast. Wording matches SPEC §3.3.
|
|
35
|
+
* Mirror of Rust `fail_fast::FAIL_FAST_ERROR_CONTENT`.
|
|
36
|
+
*/
|
|
37
|
+
export declare const FAIL_FAST_ERROR_CONTENT = "Permission Denied: OS Blocked";
|
|
38
|
+
/** Env var name (mirror of Rust `fail_fast::fail_fast_enabled`). */
|
|
39
|
+
export declare const ENV_FAIL_FAST = "EDGE_FAIL_FAST";
|
|
40
|
+
/**
|
|
41
|
+
* Whether AGT-02 fail-fast is enabled. Default ON per SPEC. Disable with
|
|
42
|
+
* `EDGE_FAIL_FAST=0` (or `false`) for pure trust-pi mode (legacy).
|
|
43
|
+
*
|
|
44
|
+
* Mirror of Rust `fail_fast_enabled()`. Reads `process.env` directly — keep
|
|
45
|
+
* the option to pass a custom env in for testability.
|
|
46
|
+
*/
|
|
47
|
+
export declare function failFastEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* If `event_type` is an inspectable tool-execution event AND its payload text
|
|
50
|
+
* contains a forbidden keyword, return that keyword (the FIRST match, in the
|
|
51
|
+
* order defined in `FORBIDDEN_KEYWORDS`). Returns `null` otherwise.
|
|
52
|
+
*
|
|
53
|
+
* PURE — no env, no I/O; callers gate on `failFastEnabled()` separately so
|
|
54
|
+
* the detection logic is unit-testable deterministically.
|
|
55
|
+
*
|
|
56
|
+
* Mirror of Rust `fail_fast::detect_forbidden`.
|
|
57
|
+
*/
|
|
58
|
+
export declare function detectForbidden(eventType: string, payload: unknown): string | null;
|
|
59
|
+
/**
|
|
60
|
+
* Build the synthetic error event the wrapper emits on fail-fast. The shape
|
|
61
|
+
* matches a pi `error` event (payload can be forwarded over `/edge` exactly
|
|
62
|
+
* like any other event — transport-100% preserved).
|
|
63
|
+
*
|
|
64
|
+
* Mirror of the synthetic-event construction in `fail_fast.rs::on_tool_event`
|
|
65
|
+
* (the Rust side builds the same `{type:"error", content:FAIL_FAST_ERROR_CONTENT}`).
|
|
66
|
+
*/
|
|
67
|
+
export declare function buildSyntheticErrorEvent(): {
|
|
68
|
+
type: 'error';
|
|
69
|
+
content: string;
|
|
70
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AGT-02 Fail-Fast Policy (SPEC §3.3) — tool-output safety invariant.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `edge-client/src/session/fail_fast.rs` 1:1. The edge-client's core
|
|
5
|
+
* doctrine is "TRUST PI, TRANSPORT 100%": it forwards every pi event verbatim
|
|
6
|
+
* and does not second-guess pi's reasoning. AGT-02 adds ONE scoped exception:
|
|
7
|
+
* it inspects pi's **tool execution OUTPUT** (not pi's reasoning/text) for
|
|
8
|
+
* forbidden OS-policy keywords. When a tool result contains "Access Denied"
|
|
9
|
+
* / "AppLocker" / "Permission denied", the agent reasoning loop is aborted
|
|
10
|
+
* immediately and an error is reported to the server, so pi cannot auto-seek
|
|
11
|
+
* alternative methods that would violate the organization's internal security
|
|
12
|
+
* policy.
|
|
13
|
+
*
|
|
14
|
+
* This does NOT break transport-100%: the triggering event is still forwarded
|
|
15
|
+
* unchanged — the wrapper only ADDS an abort + a synthetic `error` event.
|
|
16
|
+
*
|
|
17
|
+
* Disable for pure-trust mode via `EDGE_FAIL_FAST=0` (or `EDGE_FAIL_FAST=false`).
|
|
18
|
+
*/
|
|
19
|
+
// ──────────────────────────── constants ────────────────────────────
|
|
20
|
+
/**
|
|
21
|
+
* Forbidden keywords per SPEC §3.3 AGT-02. Matched case-insensitively as
|
|
22
|
+
* substrings of the concatenated tool-output text.
|
|
23
|
+
* Mirror of Rust `fail_fast::FORBIDDEN_KEYWORDS`.
|
|
24
|
+
*/
|
|
25
|
+
export const FORBIDDEN_KEYWORDS = Object.freeze([
|
|
26
|
+
'Access Denied',
|
|
27
|
+
'AppLocker',
|
|
28
|
+
'Permission denied',
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Pi event types whose payload may carry tool OUTPUT worth inspecting.
|
|
32
|
+
* We deliberately do NOT inspect reasoning/text events (`message_update`,
|
|
33
|
+
* `message_end`, ...) — only concrete tool execution results, so the policy
|
|
34
|
+
* acts on what a tool actually returned, never on pi's narration.
|
|
35
|
+
* Mirror of Rust `fail_fast::INSPECTABLE_EVENT_TYPES`.
|
|
36
|
+
*/
|
|
37
|
+
export const INSPECTABLE_EVENT_TYPES = Object.freeze([
|
|
38
|
+
'tool_execution_update',
|
|
39
|
+
'tool_execution_end',
|
|
40
|
+
]);
|
|
41
|
+
/**
|
|
42
|
+
* Synthetic error content emitted on fail-fast. Wording matches SPEC §3.3.
|
|
43
|
+
* Mirror of Rust `fail_fast::FAIL_FAST_ERROR_CONTENT`.
|
|
44
|
+
*/
|
|
45
|
+
export const FAIL_FAST_ERROR_CONTENT = 'Permission Denied: OS Blocked';
|
|
46
|
+
/** Env var name (mirror of Rust `fail_fast::fail_fast_enabled`). */
|
|
47
|
+
export const ENV_FAIL_FAST = 'EDGE_FAIL_FAST';
|
|
48
|
+
// ──────────────────────────── enablement gate ──────────────────────
|
|
49
|
+
/**
|
|
50
|
+
* Whether AGT-02 fail-fast is enabled. Default ON per SPEC. Disable with
|
|
51
|
+
* `EDGE_FAIL_FAST=0` (or `false`) for pure trust-pi mode (legacy).
|
|
52
|
+
*
|
|
53
|
+
* Mirror of Rust `fail_fast_enabled()`. Reads `process.env` directly — keep
|
|
54
|
+
* the option to pass a custom env in for testability.
|
|
55
|
+
*/
|
|
56
|
+
export function failFastEnabled(env = process.env) {
|
|
57
|
+
const raw = env[ENV_FAIL_FAST];
|
|
58
|
+
return !(raw === '0' || raw === 'false');
|
|
59
|
+
}
|
|
60
|
+
// ──────────────────────────── detection ────────────────────────────
|
|
61
|
+
/**
|
|
62
|
+
* Recursively collect every string value in `value` into `out` (newline-
|
|
63
|
+
* separated). Lets the keyword scan cover nested `result`/`args`/`stdout`
|
|
64
|
+
* fields regardless of the exact tool event shape pi emits.
|
|
65
|
+
*
|
|
66
|
+
* Mirror of Rust `fail_fast::collect_strings`.
|
|
67
|
+
*/
|
|
68
|
+
function collectStrings(value, out) {
|
|
69
|
+
if (value === null || value === undefined)
|
|
70
|
+
return;
|
|
71
|
+
if (typeof value === 'string') {
|
|
72
|
+
out.push(value);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(value)) {
|
|
76
|
+
for (const v of value)
|
|
77
|
+
collectStrings(v, out);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (typeof value === 'object') {
|
|
81
|
+
for (const v of Object.values(value)) {
|
|
82
|
+
collectStrings(v, out);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// numbers, booleans, bigints → skip (only strings match keywords)
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* If `event_type` is an inspectable tool-execution event AND its payload text
|
|
89
|
+
* contains a forbidden keyword, return that keyword (the FIRST match, in the
|
|
90
|
+
* order defined in `FORBIDDEN_KEYWORDS`). Returns `null` otherwise.
|
|
91
|
+
*
|
|
92
|
+
* PURE — no env, no I/O; callers gate on `failFastEnabled()` separately so
|
|
93
|
+
* the detection logic is unit-testable deterministically.
|
|
94
|
+
*
|
|
95
|
+
* Mirror of Rust `fail_fast::detect_forbidden`.
|
|
96
|
+
*/
|
|
97
|
+
export function detectForbidden(eventType, payload) {
|
|
98
|
+
if (!INSPECTABLE_EVENT_TYPES.includes(eventType))
|
|
99
|
+
return null;
|
|
100
|
+
const strings = [];
|
|
101
|
+
collectStrings(payload, strings);
|
|
102
|
+
if (strings.length === 0)
|
|
103
|
+
return null;
|
|
104
|
+
const blob = strings.join('\n').toLowerCase();
|
|
105
|
+
for (const kw of FORBIDDEN_KEYWORDS) {
|
|
106
|
+
if (blob.includes(kw.toLowerCase()))
|
|
107
|
+
return kw;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
// ──────────────────────────── synthetic event ──────────────────────
|
|
112
|
+
/**
|
|
113
|
+
* Build the synthetic error event the wrapper emits on fail-fast. The shape
|
|
114
|
+
* matches a pi `error` event (payload can be forwarded over `/edge` exactly
|
|
115
|
+
* like any other event — transport-100% preserved).
|
|
116
|
+
*
|
|
117
|
+
* Mirror of the synthetic-event construction in `fail_fast.rs::on_tool_event`
|
|
118
|
+
* (the Rust side builds the same `{type:"error", content:FAIL_FAST_ERROR_CONTENT}`).
|
|
119
|
+
*/
|
|
120
|
+
export function buildSyntheticErrorEvent() {
|
|
121
|
+
return { type: 'error', content: FAIL_FAST_ERROR_CONTENT };
|
|
122
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session manager — mirrors `edge-client/src/session/manager.rs`.
|
|
3
|
+
*
|
|
4
|
+
* Owns PTY shell sessions + SFTP sessions + Pi RPC sessions.
|
|
5
|
+
* Keys are session_id UUIDs. Implements the request lifecycle:
|
|
6
|
+
* - Create a session: assign a UUID session_id, call underlying manager.
|
|
7
|
+
* - Incoming frames from binary WS routed by session_id.
|
|
8
|
+
* - Heartbeat metadata (activeSessionCount, shellSessionCount, piSessionCount).
|
|
9
|
+
* - Cleanup on edge-client shutdown (kill all child processes, flush SFTP).
|
|
10
|
+
*/
|
|
11
|
+
import { ShellManager } from './shell.js';
|
|
12
|
+
import type { FrameTypeValue } from './shell-frame.js';
|
|
13
|
+
import { SftpForwarder } from './sftp.js';
|
|
14
|
+
import { PiProcess } from './pi-rpc.js';
|
|
15
|
+
import type { ResourcesConfig, StreamMessage } from './pi-rpc.js';
|
|
16
|
+
import { SessionIdStore } from './persistence.js';
|
|
17
|
+
/** Session type discrimination. */
|
|
18
|
+
export type SessionType = 'shell' | 'sftp' | 'pi';
|
|
19
|
+
/** Heartbeat metadata. Mirrors Rust SessionManager heartbeat fields. */
|
|
20
|
+
export interface HeartbeatMetadata {
|
|
21
|
+
readonly activeSessionCount: number;
|
|
22
|
+
readonly shellSessionCount: number;
|
|
23
|
+
readonly piSessionCount: number;
|
|
24
|
+
}
|
|
25
|
+
/** Callback for heartbeat metadata updates. */
|
|
26
|
+
export type HeartbeatHandler = (metadata: HeartbeatMetadata) => void;
|
|
27
|
+
/** Options for the session manager. */
|
|
28
|
+
export interface SessionManagerOptions {
|
|
29
|
+
readonly machineId: string;
|
|
30
|
+
readonly shellEnabled: boolean;
|
|
31
|
+
readonly piPath?: string;
|
|
32
|
+
readonly resources?: ResourcesConfig;
|
|
33
|
+
}
|
|
34
|
+
export declare class SessionManager {
|
|
35
|
+
private readonly machineId;
|
|
36
|
+
private readonly piPath;
|
|
37
|
+
private readonly resources;
|
|
38
|
+
/** Active sessions keyed by session_id. */
|
|
39
|
+
private readonly sessions;
|
|
40
|
+
/** Shell manager (PTY sessions). */
|
|
41
|
+
private readonly shell;
|
|
42
|
+
/** SFTP forwarder (file browser RPC). */
|
|
43
|
+
private readonly sftp;
|
|
44
|
+
/** Pi processes keyed by agent_id (not session_id). */
|
|
45
|
+
private readonly piProcesses;
|
|
46
|
+
/** Persistence store for pi session IDs. */
|
|
47
|
+
private readonly sessionIdStore;
|
|
48
|
+
/** Event emitter for outgoing messages. */
|
|
49
|
+
private readonly emitter;
|
|
50
|
+
constructor(opts: SessionManagerOptions);
|
|
51
|
+
/**
|
|
52
|
+
* Open a new shell session. Mirrors Rust `ShellManager::open` lifecycle.
|
|
53
|
+
* Returns the assigned session_id.
|
|
54
|
+
*/
|
|
55
|
+
openShellSession(cols: number, rows: number, shell?: string): Promise<string>;
|
|
56
|
+
/**
|
|
57
|
+
* Handle a shell-related frame from the binary WS.
|
|
58
|
+
* Routes to the appropriate underlying manager.
|
|
59
|
+
*/
|
|
60
|
+
handleShellFrame(frameType: FrameTypeValue, sessionId: string, payload: Buffer): void;
|
|
61
|
+
/**
|
|
62
|
+
* Handle an SFTP frame from the binary WS.
|
|
63
|
+
* Delegates to the SFTP forwarder.
|
|
64
|
+
*/
|
|
65
|
+
handleSftpFrame(sessionId: string, frameType: number, payload: Buffer): void;
|
|
66
|
+
/**
|
|
67
|
+
* Get or create a Pi process for an agent_id.
|
|
68
|
+
* Mirrors Rust `SessionManager::get_or_create_session`.
|
|
69
|
+
*/
|
|
70
|
+
getOrCreatePiSession(agentId: string): Promise<PiProcess>;
|
|
71
|
+
/**
|
|
72
|
+
* Forward a prompt to a Pi process for agent_id.
|
|
73
|
+
* Mirrors Rust `SessionManager::execute_command`.
|
|
74
|
+
*/
|
|
75
|
+
executeCommand(agentId: string, msgId: string, prompt: string): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Forward extension_ui_response to a Pi process.
|
|
78
|
+
* Mirrors Rust `SessionManager::forward_extension_ui_response`.
|
|
79
|
+
*/
|
|
80
|
+
forwardExtensionUIResponse(agentId: string, requestId: string, response: Record<string, unknown>): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Switch model for a Pi process.
|
|
83
|
+
* Mirrors Rust `SessionManager::set_model`.
|
|
84
|
+
*/
|
|
85
|
+
setModel(agentId: string, provider: string, modelId: string): Promise<void>;
|
|
86
|
+
/**
|
|
87
|
+
* Set thinking level for a Pi process.
|
|
88
|
+
* Mirrors Rust `SessionManager::set_thinking_level`.
|
|
89
|
+
*/
|
|
90
|
+
setThinkingLevel(agentId: string, level: string): Promise<void>;
|
|
91
|
+
/**
|
|
92
|
+
* Trigger compaction for a Pi process.
|
|
93
|
+
* Mirrors Rust `SessionManager::compact`.
|
|
94
|
+
*/
|
|
95
|
+
compact(agentId: string): Promise<void>;
|
|
96
|
+
/**
|
|
97
|
+
* Forward get_available_models to a Pi process.
|
|
98
|
+
* Mirrors Rust `SessionManager::get_available_models`.
|
|
99
|
+
*/
|
|
100
|
+
getAvailableModels(agentId: string, id: string): Promise<void>;
|
|
101
|
+
/**
|
|
102
|
+
* Terminate a Pi session for agent_id.
|
|
103
|
+
* Mirrors Rust `SessionManager::terminate_session`.
|
|
104
|
+
*/
|
|
105
|
+
terminateSession(agentId: string): Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Terminate all Pi sessions. Mirrors Rust `SessionManager::terminate_all`.
|
|
108
|
+
*/
|
|
109
|
+
terminateAll(): Promise<void>;
|
|
110
|
+
/** Get current heartbeat metadata. */
|
|
111
|
+
heartbeatMetadata(): HeartbeatMetadata;
|
|
112
|
+
/** Register a heartbeat handler. */
|
|
113
|
+
onHeartbeat(handler: HeartbeatHandler): void;
|
|
114
|
+
private emitHeartbeat;
|
|
115
|
+
/**
|
|
116
|
+
* Close all sessions (edge shutdown). Mirrors Rust `ShellManager::close_all`
|
|
117
|
+
* + `SessionManager::terminate_all`.
|
|
118
|
+
*/
|
|
119
|
+
closeAll(): Promise<void>;
|
|
120
|
+
/** Subscribe to raw wire frames from shell sessions. */
|
|
121
|
+
onWireFrame(handler: (frame: Buffer) => void): void;
|
|
122
|
+
/** Subscribe to SFTP response frames. */
|
|
123
|
+
onSftpFrame(handler: (sessionId: string, frameType: number, payload: Buffer) => void): void;
|
|
124
|
+
/** Subscribe to pi events (StreamMessage). */
|
|
125
|
+
onPiEvent(handler: (msg: StreamMessage) => void): void;
|
|
126
|
+
/** Subscribe to pi exit events. */
|
|
127
|
+
onPiExit(handler: (agentId: string) => void): void;
|
|
128
|
+
/** Subscribe to shell exit events. */
|
|
129
|
+
onShellExited(handler: (sessionId: string, code: number) => void): void;
|
|
130
|
+
/** Get the shell manager (for binary WS wiring). */
|
|
131
|
+
getShellManager(): ShellManager;
|
|
132
|
+
/** Get the SFTP forwarder (for binary WS wiring). */
|
|
133
|
+
getSftpForwarder(): SftpForwarder;
|
|
134
|
+
/** Get the session ID store (for persistence). */
|
|
135
|
+
getSessionIdStore(): SessionIdStore;
|
|
136
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session manager — mirrors `edge-client/src/session/manager.rs`.
|
|
3
|
+
*
|
|
4
|
+
* Owns PTY shell sessions + SFTP sessions + Pi RPC sessions.
|
|
5
|
+
* Keys are session_id UUIDs. Implements the request lifecycle:
|
|
6
|
+
* - Create a session: assign a UUID session_id, call underlying manager.
|
|
7
|
+
* - Incoming frames from binary WS routed by session_id.
|
|
8
|
+
* - Heartbeat metadata (activeSessionCount, shellSessionCount, piSessionCount).
|
|
9
|
+
* - Cleanup on edge-client shutdown (kill all child processes, flush SFTP).
|
|
10
|
+
*/
|
|
11
|
+
import { randomUUID } from 'node:crypto';
|
|
12
|
+
import { EventEmitter } from 'node:events';
|
|
13
|
+
import { ShellManager } from './shell.js';
|
|
14
|
+
import { SftpForwarder } from './sftp.js';
|
|
15
|
+
import { PiProcess } from './pi-rpc.js';
|
|
16
|
+
import { SessionIdStore } from './persistence.js';
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// SessionManager
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
export class SessionManager {
|
|
21
|
+
machineId;
|
|
22
|
+
piPath;
|
|
23
|
+
resources;
|
|
24
|
+
/** Active sessions keyed by session_id. */
|
|
25
|
+
sessions = new Map();
|
|
26
|
+
/** Shell manager (PTY sessions). */
|
|
27
|
+
shell;
|
|
28
|
+
/** SFTP forwarder (file browser RPC). */
|
|
29
|
+
sftp;
|
|
30
|
+
/** Pi processes keyed by agent_id (not session_id). */
|
|
31
|
+
piProcesses = new Map();
|
|
32
|
+
/** Persistence store for pi session IDs. */
|
|
33
|
+
sessionIdStore;
|
|
34
|
+
/** Event emitter for outgoing messages. */
|
|
35
|
+
emitter = new EventEmitter();
|
|
36
|
+
constructor(opts) {
|
|
37
|
+
this.machineId = opts.machineId;
|
|
38
|
+
this.piPath = opts.piPath ?? 'pi';
|
|
39
|
+
this.resources = opts.resources ?? { memory_max_mb: 2048, cpu_quota_percent: 50, tasks_max: 256 };
|
|
40
|
+
this.shell = new ShellManager({ enabled: opts.shellEnabled });
|
|
41
|
+
this.sftp = new SftpForwarder();
|
|
42
|
+
this.sessionIdStore = new SessionIdStore();
|
|
43
|
+
// Wire shell events → outbound
|
|
44
|
+
this.shell.onFrame((frame) => {
|
|
45
|
+
this.emitter.emit('wire_frame', frame);
|
|
46
|
+
});
|
|
47
|
+
this.shell.onExit((sessionId, code) => {
|
|
48
|
+
this.emitter.emit('shell_exited', sessionId, code);
|
|
49
|
+
// Clean up session entry
|
|
50
|
+
this.sessions.delete(sessionId);
|
|
51
|
+
this.emitHeartbeat();
|
|
52
|
+
});
|
|
53
|
+
// Wire sftp events → outbound
|
|
54
|
+
this.sftp.onFrame((sessionId, frameType, payload) => {
|
|
55
|
+
this.emitter.emit('sftp_frame', sessionId, frameType, payload);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
// -------------------------------------------------------------------------
|
|
59
|
+
// Shell sessions
|
|
60
|
+
// -------------------------------------------------------------------------
|
|
61
|
+
/**
|
|
62
|
+
* Open a new shell session. Mirrors Rust `ShellManager::open` lifecycle.
|
|
63
|
+
* Returns the assigned session_id.
|
|
64
|
+
*/
|
|
65
|
+
async openShellSession(cols, rows, shell) {
|
|
66
|
+
const sessionId = randomUUID();
|
|
67
|
+
await this.shell.open(sessionId, cols, rows, shell);
|
|
68
|
+
this.sessions.set(sessionId, {
|
|
69
|
+
sessionId,
|
|
70
|
+
type: 'shell',
|
|
71
|
+
createdAt: Date.now(),
|
|
72
|
+
});
|
|
73
|
+
this.emitHeartbeat();
|
|
74
|
+
return sessionId;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Handle a shell-related frame from the binary WS.
|
|
78
|
+
* Routes to the appropriate underlying manager.
|
|
79
|
+
*/
|
|
80
|
+
handleShellFrame(frameType, sessionId, payload) {
|
|
81
|
+
// Delegate to shell manager for Data, Resize, Close, OpenControl, CloseControl
|
|
82
|
+
this.shell.handleFrame(frameType, sessionId, payload);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Handle an SFTP frame from the binary WS.
|
|
86
|
+
* Delegates to the SFTP forwarder.
|
|
87
|
+
*/
|
|
88
|
+
handleSftpFrame(sessionId, frameType, payload) {
|
|
89
|
+
this.sftp.handleFrame(sessionId, frameType, payload);
|
|
90
|
+
}
|
|
91
|
+
// -------------------------------------------------------------------------
|
|
92
|
+
// Pi sessions
|
|
93
|
+
// -------------------------------------------------------------------------
|
|
94
|
+
/**
|
|
95
|
+
* Get or create a Pi process for an agent_id.
|
|
96
|
+
* Mirrors Rust `SessionManager::get_or_create_session`.
|
|
97
|
+
*/
|
|
98
|
+
async getOrCreatePiSession(agentId) {
|
|
99
|
+
const existing = this.piProcesses.get(agentId);
|
|
100
|
+
if (existing !== undefined && existing.isAlive()) {
|
|
101
|
+
return existing;
|
|
102
|
+
}
|
|
103
|
+
// If dead, remove and respawn
|
|
104
|
+
if (existing !== undefined) {
|
|
105
|
+
existing.terminate();
|
|
106
|
+
this.piProcesses.delete(agentId);
|
|
107
|
+
}
|
|
108
|
+
const sessionId = await this.sessionIdStore.getOrInit(agentId, () => randomUUID());
|
|
109
|
+
const proc = new PiProcess({
|
|
110
|
+
agentId,
|
|
111
|
+
piPath: this.piPath,
|
|
112
|
+
machineId: this.machineId,
|
|
113
|
+
resources: this.resources,
|
|
114
|
+
resumeSessionId: sessionId,
|
|
115
|
+
});
|
|
116
|
+
await proc.spawn({
|
|
117
|
+
agentId,
|
|
118
|
+
piPath: this.piPath,
|
|
119
|
+
machineId: this.machineId,
|
|
120
|
+
resources: this.resources,
|
|
121
|
+
resumeSessionId: sessionId,
|
|
122
|
+
});
|
|
123
|
+
// Wire events
|
|
124
|
+
proc.onEvent((msg) => {
|
|
125
|
+
this.emitter.emit('pi_event', msg);
|
|
126
|
+
});
|
|
127
|
+
proc.onExit((id) => {
|
|
128
|
+
this.emitter.emit('pi_exit', id);
|
|
129
|
+
});
|
|
130
|
+
this.piProcesses.set(agentId, proc);
|
|
131
|
+
this.emitHeartbeat();
|
|
132
|
+
return proc;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Forward a prompt to a Pi process for agent_id.
|
|
136
|
+
* Mirrors Rust `SessionManager::execute_command`.
|
|
137
|
+
*/
|
|
138
|
+
async executeCommand(agentId, msgId, prompt) {
|
|
139
|
+
const pi = await this.getOrCreatePiSession(agentId);
|
|
140
|
+
const behavior = pi.isStreaming() ? 'followUp' : undefined;
|
|
141
|
+
pi.sendPrompt(msgId, prompt, behavior);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Forward extension_ui_response to a Pi process.
|
|
145
|
+
* Mirrors Rust `SessionManager::forward_extension_ui_response`.
|
|
146
|
+
*/
|
|
147
|
+
async forwardExtensionUIResponse(agentId, requestId, response) {
|
|
148
|
+
const pi = this.piProcesses.get(agentId);
|
|
149
|
+
if (pi === undefined) {
|
|
150
|
+
// No active session — pi will expire the request
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
pi.sendExtensionUIResponse(requestId, response);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Switch model for a Pi process.
|
|
157
|
+
* Mirrors Rust `SessionManager::set_model`.
|
|
158
|
+
*/
|
|
159
|
+
async setModel(agentId, provider, modelId) {
|
|
160
|
+
const pi = this.piProcesses.get(agentId);
|
|
161
|
+
if (pi === undefined)
|
|
162
|
+
return;
|
|
163
|
+
pi.setModel(provider, modelId);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Set thinking level for a Pi process.
|
|
167
|
+
* Mirrors Rust `SessionManager::set_thinking_level`.
|
|
168
|
+
*/
|
|
169
|
+
async setThinkingLevel(agentId, level) {
|
|
170
|
+
const pi = this.piProcesses.get(agentId);
|
|
171
|
+
if (pi === undefined)
|
|
172
|
+
return;
|
|
173
|
+
pi.setThinkingLevel(level);
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Trigger compaction for a Pi process.
|
|
177
|
+
* Mirrors Rust `SessionManager::compact`.
|
|
178
|
+
*/
|
|
179
|
+
async compact(agentId) {
|
|
180
|
+
const pi = this.piProcesses.get(agentId);
|
|
181
|
+
if (pi === undefined)
|
|
182
|
+
return;
|
|
183
|
+
pi.compact();
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Forward get_available_models to a Pi process.
|
|
187
|
+
* Mirrors Rust `SessionManager::get_available_models`.
|
|
188
|
+
*/
|
|
189
|
+
async getAvailableModels(agentId, id) {
|
|
190
|
+
const pi = await this.getOrCreatePiSession(agentId);
|
|
191
|
+
pi.getAvailableModels(id);
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Terminate a Pi session for agent_id.
|
|
195
|
+
* Mirrors Rust `SessionManager::terminate_session`.
|
|
196
|
+
*/
|
|
197
|
+
async terminateSession(agentId) {
|
|
198
|
+
const pi = this.piProcesses.get(agentId);
|
|
199
|
+
if (pi !== undefined) {
|
|
200
|
+
pi.terminate();
|
|
201
|
+
this.piProcesses.delete(agentId);
|
|
202
|
+
this.emitHeartbeat();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Terminate all Pi sessions. Mirrors Rust `SessionManager::terminate_all`.
|
|
207
|
+
*/
|
|
208
|
+
async terminateAll() {
|
|
209
|
+
for (const [agentId, pi] of this.piProcesses) {
|
|
210
|
+
pi.terminate();
|
|
211
|
+
this.piProcesses.delete(agentId);
|
|
212
|
+
}
|
|
213
|
+
this.emitHeartbeat();
|
|
214
|
+
}
|
|
215
|
+
// -------------------------------------------------------------------------
|
|
216
|
+
// Heartbeat
|
|
217
|
+
// -------------------------------------------------------------------------
|
|
218
|
+
/** Get current heartbeat metadata. */
|
|
219
|
+
heartbeatMetadata() {
|
|
220
|
+
let shellCount = 0;
|
|
221
|
+
let piCount = 0;
|
|
222
|
+
for (const entry of this.sessions.values()) {
|
|
223
|
+
if (entry.type === 'shell')
|
|
224
|
+
shellCount++;
|
|
225
|
+
}
|
|
226
|
+
piCount = this.piProcesses.size;
|
|
227
|
+
return {
|
|
228
|
+
activeSessionCount: shellCount + piCount,
|
|
229
|
+
shellSessionCount: shellCount,
|
|
230
|
+
piSessionCount: piCount,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/** Register a heartbeat handler. */
|
|
234
|
+
onHeartbeat(handler) {
|
|
235
|
+
this.emitter.on('heartbeat', handler);
|
|
236
|
+
}
|
|
237
|
+
emitHeartbeat() {
|
|
238
|
+
this.emitter.emit('heartbeat', this.heartbeatMetadata());
|
|
239
|
+
}
|
|
240
|
+
// -------------------------------------------------------------------------
|
|
241
|
+
// Shutdown
|
|
242
|
+
// -------------------------------------------------------------------------
|
|
243
|
+
/**
|
|
244
|
+
* Close all sessions (edge shutdown). Mirrors Rust `ShellManager::close_all`
|
|
245
|
+
* + `SessionManager::terminate_all`.
|
|
246
|
+
*/
|
|
247
|
+
async closeAll() {
|
|
248
|
+
this.shell.closeAll();
|
|
249
|
+
await this.terminateAll();
|
|
250
|
+
this.sessions.clear();
|
|
251
|
+
this.emitHeartbeat();
|
|
252
|
+
}
|
|
253
|
+
// -------------------------------------------------------------------------
|
|
254
|
+
// Wire event subscriptions
|
|
255
|
+
// -------------------------------------------------------------------------
|
|
256
|
+
/** Subscribe to raw wire frames from shell sessions. */
|
|
257
|
+
onWireFrame(handler) {
|
|
258
|
+
this.emitter.on('wire_frame', handler);
|
|
259
|
+
}
|
|
260
|
+
/** Subscribe to SFTP response frames. */
|
|
261
|
+
onSftpFrame(handler) {
|
|
262
|
+
this.emitter.on('sftp_frame', handler);
|
|
263
|
+
}
|
|
264
|
+
/** Subscribe to pi events (StreamMessage). */
|
|
265
|
+
onPiEvent(handler) {
|
|
266
|
+
this.emitter.on('pi_event', handler);
|
|
267
|
+
}
|
|
268
|
+
/** Subscribe to pi exit events. */
|
|
269
|
+
onPiExit(handler) {
|
|
270
|
+
this.emitter.on('pi_exit', handler);
|
|
271
|
+
}
|
|
272
|
+
/** Subscribe to shell exit events. */
|
|
273
|
+
onShellExited(handler) {
|
|
274
|
+
this.emitter.on('shell_exited', handler);
|
|
275
|
+
}
|
|
276
|
+
// -------------------------------------------------------------------------
|
|
277
|
+
// Accessors
|
|
278
|
+
// -------------------------------------------------------------------------
|
|
279
|
+
/** Get the shell manager (for binary WS wiring). */
|
|
280
|
+
getShellManager() {
|
|
281
|
+
return this.shell;
|
|
282
|
+
}
|
|
283
|
+
/** Get the SFTP forwarder (for binary WS wiring). */
|
|
284
|
+
getSftpForwarder() {
|
|
285
|
+
return this.sftp;
|
|
286
|
+
}
|
|
287
|
+
/** Get the session ID store (for persistence). */
|
|
288
|
+
getSessionIdStore() {
|
|
289
|
+
return this.sessionIdStore;
|
|
290
|
+
}
|
|
291
|
+
}
|