@kortix/agent-tunnel 0.1.4 → 0.12.7
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 +65 -0
- package/dist/agent-cli.js +4676 -3174
- package/dist/client-cli.js +202 -493
- package/package.json +24 -29
- package/src/agent/agent.ts +67 -17
- package/src/agent/capabilities/desktop/cua-driver.ts +284 -0
- package/src/agent/capabilities/desktop.ts +100 -167
- package/src/agent/capabilities/enabled-registry.ts +24 -0
- package/src/agent/capabilities/filesystem.ts +136 -32
- package/src/agent/capabilities/index.ts +1 -1
- package/src/agent/capabilities/security.test.ts +204 -0
- package/src/agent/capabilities/shell.ts +37 -3
- package/src/agent/cli-device-auth.test.ts +179 -0
- package/src/agent/cli-help.test.ts +25 -0
- package/src/agent/cli.ts +475 -38
- package/src/agent/config.test.ts +53 -0
- package/src/agent/config.ts +169 -7
- package/src/agent/index.ts +1 -0
- package/src/agent/security/command-validator.ts +4 -2
- package/src/agent/security/path-validator.ts +73 -18
- package/src/agent/security/permission-guard.test.ts +52 -0
- package/src/agent/security/permission-guard.ts +35 -8
- package/src/agent/service.test.ts +63 -0
- package/src/agent/service.ts +410 -0
- package/src/client/cli.test.ts +150 -547
- package/src/client/cli.ts +116 -539
- package/src/client/index.ts +1 -1
- package/src/client/tools.ts +95 -356
- package/src/client/tunnel-client.ts +50 -80
- package/src/index.ts +7 -1
- package/src/node-ws-polyfill.test.ts +18 -0
- package/src/node-ws-polyfill.ts +5 -3
- package/src/server/heartbeat.ts +13 -6
- package/src/server/relay.test.ts +72 -0
- package/src/server/relay.ts +50 -9
- package/src/server/server.test.ts +33 -0
- package/src/server/server.ts +26 -6
- package/src/server/ws-handler.test.ts +158 -0
- package/src/server/ws-handler.ts +94 -36
- package/src/shared/crypto.ts +2 -3
- package/src/shared/index.ts +8 -0
- package/src/shared/permissions.ts +292 -0
- package/src/shared/types.ts +70 -41
- package/dist/agent/index.d.ts +0 -140
- package/dist/agent/index.js +0 -21
- package/dist/agent/index.js.map +0 -1
- package/dist/chunk-7N7GSU6K.js +0 -34
- package/dist/client/index.d.ts +0 -183
- package/dist/client/index.js +0 -8
- package/dist/client/index.js.map +0 -1
- package/dist/index.d.ts +0 -7
- package/dist/index.js +0 -55
- package/dist/index.js.map +0 -1
- package/dist/server/index.d.ts +0 -89
- package/dist/server/index.js +0 -14
- package/dist/server/index.js.map +0 -1
- package/dist/shared/index.d.ts +0 -10
- package/dist/shared/index.js +0 -20
- package/dist/shared/index.js.map +0 -1
- package/dist/types-Dpwrd8Ai.d.ts +0 -194
- package/src/agent/capabilities/desktop/atspi-helper.ts +0 -345
- package/src/agent/capabilities/desktop/csharp-helper.ts +0 -914
- package/src/agent/capabilities/desktop/linux-driver.ts +0 -368
- package/src/agent/capabilities/desktop/macos-driver.ts +0 -601
- package/src/agent/capabilities/desktop/swift-helper.ts +0 -736
- package/src/agent/capabilities/desktop/types.ts +0 -201
- package/src/agent/capabilities/desktop/windows-driver.ts +0 -220
|
@@ -1,193 +1,126 @@
|
|
|
1
|
-
import { platform } from 'os';
|
|
2
1
|
import type { Capability, RpcHandler } from './index';
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
2
|
+
import { CuaDriver } from './desktop/cua-driver';
|
|
3
|
+
import { desktopFeatureForMethod } from '../../shared/permissions';
|
|
4
|
+
import type { LocalPermission } from '../security/permission-guard';
|
|
5
|
+
|
|
6
|
+
const CUA_TOOLS = [
|
|
7
|
+
'bring_to_front',
|
|
8
|
+
'check_for_update',
|
|
9
|
+
'check_permissions',
|
|
10
|
+
'click',
|
|
11
|
+
'double_click',
|
|
12
|
+
'drag',
|
|
13
|
+
'end_session',
|
|
14
|
+
'get_accessibility_tree',
|
|
15
|
+
'get_agent_cursor_state',
|
|
16
|
+
'get_config',
|
|
17
|
+
'get_cursor_position',
|
|
18
|
+
'get_recording_state',
|
|
19
|
+
'get_screen_size',
|
|
20
|
+
'get_window_state',
|
|
21
|
+
'hotkey',
|
|
22
|
+
'kill_app',
|
|
23
|
+
'launch_app',
|
|
24
|
+
'list_apps',
|
|
25
|
+
'list_windows',
|
|
26
|
+
'move_cursor',
|
|
27
|
+
'page',
|
|
28
|
+
'press_key',
|
|
29
|
+
'replay_trajectory',
|
|
30
|
+
'right_click',
|
|
31
|
+
'scroll',
|
|
32
|
+
'set_agent_cursor_enabled',
|
|
33
|
+
'set_agent_cursor_motion',
|
|
34
|
+
'set_agent_cursor_style',
|
|
35
|
+
'set_config',
|
|
36
|
+
'set_value',
|
|
37
|
+
'start_recording',
|
|
38
|
+
'install_ffmpeg',
|
|
39
|
+
'start_session',
|
|
40
|
+
'stop_recording',
|
|
41
|
+
'type_text',
|
|
42
|
+
'zoom',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const LOCAL_ONLY_CUA_TOOLS = new Set(['check_for_update', 'install_ffmpeg']);
|
|
46
|
+
|
|
47
|
+
function assertRemotelyCallableTool(tool: unknown): asserts tool is string {
|
|
48
|
+
if (typeof tool !== 'string' || tool.length === 0) {
|
|
49
|
+
throw new Error('CUA tool name is required');
|
|
50
|
+
}
|
|
51
|
+
if (LOCAL_ONLY_CUA_TOOLS.has(tool)) {
|
|
52
|
+
throw new Error(`CUA tool "${tool}" is local-only and cannot run through Agent Tunnel`);
|
|
19
53
|
}
|
|
20
54
|
}
|
|
21
55
|
|
|
22
56
|
export function createDesktopCapability(): Capability {
|
|
23
|
-
const
|
|
57
|
+
const cua = new CuaDriver();
|
|
24
58
|
const methods = new Map<string, RpcHandler>();
|
|
25
59
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
methods.set('desktop.mouse.move', async (params) => {
|
|
45
|
-
await driver.mouseMove({
|
|
46
|
-
x: params.x as number,
|
|
47
|
-
y: params.y as number,
|
|
48
|
-
});
|
|
49
|
-
return { ok: true };
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
methods.set('desktop.mouse.drag', async (params) => {
|
|
53
|
-
await driver.mouseDrag({
|
|
54
|
-
fromX: params.fromX as number,
|
|
55
|
-
fromY: params.fromY as number,
|
|
56
|
-
toX: params.toX as number,
|
|
57
|
-
toY: params.toY as number,
|
|
58
|
-
button: params.button as any,
|
|
59
|
-
});
|
|
60
|
-
return { ok: true };
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
methods.set('desktop.mouse.scroll', async (params) => {
|
|
64
|
-
await driver.mouseScroll({
|
|
65
|
-
x: params.x as number,
|
|
66
|
-
y: params.y as number,
|
|
67
|
-
deltaX: params.deltaX as number | undefined,
|
|
68
|
-
deltaY: params.deltaY as number | undefined,
|
|
69
|
-
});
|
|
70
|
-
return { ok: true };
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
methods.set('desktop.mouse.position', async () => {
|
|
74
|
-
return driver.mousePosition();
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
methods.set('desktop.keyboard.type', async (params) => {
|
|
78
|
-
await driver.keyboardType({
|
|
79
|
-
text: params.text as string,
|
|
80
|
-
delay: params.delay as number | undefined,
|
|
81
|
-
});
|
|
82
|
-
return { ok: true };
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
methods.set('desktop.keyboard.key', async (params) => {
|
|
86
|
-
await driver.keyboardKey({
|
|
87
|
-
keys: params.keys as string[],
|
|
88
|
-
});
|
|
89
|
-
return { ok: true };
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
methods.set('desktop.window.list', async () => {
|
|
93
|
-
return { windows: await driver.windowList() };
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
methods.set('desktop.window.focus', async (params) => {
|
|
97
|
-
await driver.windowFocus(params.windowId as number);
|
|
98
|
-
return { ok: true };
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
methods.set('desktop.window.resize', async (params) => {
|
|
102
|
-
await driver.windowResize(params.windowId as number, {
|
|
103
|
-
x: params.x as number | undefined,
|
|
104
|
-
y: params.y as number | undefined,
|
|
105
|
-
width: params.width as number | undefined,
|
|
106
|
-
height: params.height as number | undefined,
|
|
107
|
-
});
|
|
108
|
-
return { ok: true };
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
methods.set('desktop.window.close', async (params) => {
|
|
112
|
-
await driver.windowClose(params.windowId as number);
|
|
113
|
-
return { ok: true };
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
methods.set('desktop.window.minimize', async (params) => {
|
|
117
|
-
await driver.windowMinimize(params.windowId as number);
|
|
118
|
-
return { ok: true };
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
methods.set('desktop.app.launch', async (params) => {
|
|
122
|
-
await driver.appLaunch(params.app as string);
|
|
123
|
-
return { ok: true };
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
methods.set('desktop.app.quit', async (params) => {
|
|
127
|
-
await driver.appQuit(params.app as string);
|
|
128
|
-
return { ok: true };
|
|
129
|
-
});
|
|
60
|
+
const assertDesktopPermission = (params: Record<string, unknown>, method: string): void => {
|
|
61
|
+
const permission = params.__permission as LocalPermission | undefined;
|
|
62
|
+
if (permission?.capability !== 'desktop') {
|
|
63
|
+
throw new Error('Permission denied: desktop permission required');
|
|
64
|
+
}
|
|
65
|
+
const features = Array.isArray(permission.scope?.features)
|
|
66
|
+
? permission.scope.features.filter((value): value is string => typeof value === 'string')
|
|
67
|
+
: [];
|
|
68
|
+
if (features.length === 0) return;
|
|
69
|
+
const feature = desktopFeatureForMethod(method, params);
|
|
70
|
+
if (!feature || !features.includes(feature)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Permission denied: desktop feature "${feature ?? 'unknown'}" is not allowed`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
130
76
|
|
|
131
|
-
methods.set('desktop.
|
|
132
|
-
|
|
77
|
+
methods.set('desktop.cua.ensure', async (params) => {
|
|
78
|
+
assertDesktopPermission(params, 'desktop.cua.ensure');
|
|
79
|
+
const binary = await cua.ensureInstalled();
|
|
80
|
+
const version = await cua.version().catch(() => undefined);
|
|
81
|
+
return { ok: true, binary, version };
|
|
133
82
|
});
|
|
134
83
|
|
|
135
|
-
methods.set('desktop.
|
|
136
|
-
|
|
84
|
+
methods.set('desktop.cua.start_daemon', async (params) => {
|
|
85
|
+
assertDesktopPermission(params, 'desktop.cua.start_daemon');
|
|
86
|
+
return cua.startDaemon();
|
|
137
87
|
});
|
|
138
88
|
|
|
139
|
-
methods.set('desktop.
|
|
140
|
-
|
|
141
|
-
return {
|
|
89
|
+
methods.set('desktop.cua.status', async (params) => {
|
|
90
|
+
assertDesktopPermission(params, 'desktop.cua.status');
|
|
91
|
+
return { status: await cua.status() };
|
|
142
92
|
});
|
|
143
93
|
|
|
144
|
-
methods.set('desktop.
|
|
145
|
-
|
|
94
|
+
methods.set('desktop.cua.version', async (params) => {
|
|
95
|
+
assertDesktopPermission(params, 'desktop.cua.version');
|
|
96
|
+
return { version: await cua.version() };
|
|
146
97
|
});
|
|
147
98
|
|
|
148
|
-
methods.set('desktop.
|
|
149
|
-
|
|
99
|
+
methods.set('desktop.cua.list_tools', async (params) => {
|
|
100
|
+
assertDesktopPermission(params, 'desktop.cua.list_tools');
|
|
101
|
+
return { tools: await cua.listTools() };
|
|
150
102
|
});
|
|
151
103
|
|
|
152
|
-
methods.set('desktop.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
maxDepth: params.maxDepth as number | undefined,
|
|
156
|
-
roles: params.roles as string[] | undefined,
|
|
157
|
-
});
|
|
104
|
+
methods.set('desktop.cua.describe', async (params) => {
|
|
105
|
+
assertDesktopPermission(params, 'desktop.cua.describe');
|
|
106
|
+
return { description: await cua.describe(params.tool as string) };
|
|
158
107
|
});
|
|
159
108
|
|
|
160
|
-
methods.set('desktop.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
109
|
+
methods.set('desktop.cua.call', async (params) => {
|
|
110
|
+
assertDesktopPermission(params, 'desktop.cua.call');
|
|
111
|
+
const tool = params.tool;
|
|
112
|
+
assertRemotelyCallableTool(tool);
|
|
113
|
+
const args = (params.args || {}) as Record<string, unknown>;
|
|
114
|
+
return cua.call(tool, args);
|
|
166
115
|
});
|
|
167
116
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
117
|
+
for (const tool of CUA_TOOLS) {
|
|
118
|
+
if (LOCAL_ONLY_CUA_TOOLS.has(tool)) continue;
|
|
119
|
+
methods.set(`desktop.cua.${tool}`, async (params) => {
|
|
120
|
+
assertDesktopPermission(params, `desktop.cua.${tool}`);
|
|
121
|
+
return cua.call(tool, params);
|
|
173
122
|
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
methods.set('desktop.ax.focus', async (params) => {
|
|
177
|
-
return driver.axFocus({
|
|
178
|
-
elementId: params.elementId as string,
|
|
179
|
-
pid: params.pid as number | undefined,
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
methods.set('desktop.ax.search', async (params) => {
|
|
184
|
-
return driver.axSearch({
|
|
185
|
-
query: params.query as string,
|
|
186
|
-
role: params.role as string | undefined,
|
|
187
|
-
pid: params.pid as number | undefined,
|
|
188
|
-
maxResults: params.maxResults as number | undefined,
|
|
189
|
-
});
|
|
190
|
-
});
|
|
123
|
+
}
|
|
191
124
|
|
|
192
125
|
return {
|
|
193
126
|
name: 'desktop',
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TunnelConfig } from '../config';
|
|
2
|
+
import { CapabilityRegistry } from './index';
|
|
3
|
+
import { createDesktopCapability } from './desktop';
|
|
4
|
+
import { findCuaDriverBinary } from './desktop/cua-driver';
|
|
5
|
+
import { createFilesystemCapability } from './filesystem';
|
|
6
|
+
import { createShellCapability } from './shell';
|
|
7
|
+
|
|
8
|
+
/** Build the local RPC surface from the browser-approved capability ceiling. */
|
|
9
|
+
export function createEnabledCapabilityRegistry(
|
|
10
|
+
config: TunnelConfig,
|
|
11
|
+
findDesktopDriver: () => string | null = findCuaDriverBinary,
|
|
12
|
+
): CapabilityRegistry {
|
|
13
|
+
const registry = new CapabilityRegistry();
|
|
14
|
+
const enabled = new Set(config.enabledCapabilities ?? ['filesystem', 'shell', 'desktop']);
|
|
15
|
+
if (enabled.has('filesystem')) registry.register(createFilesystemCapability(config));
|
|
16
|
+
if (enabled.has('shell')) registry.register(createShellCapability(config));
|
|
17
|
+
// Do not advertise Computer Use unless this process can execute the trusted
|
|
18
|
+
// local driver. The server must see the real handler surface, not the user's
|
|
19
|
+
// desired capability list.
|
|
20
|
+
if (enabled.has('desktop') && findDesktopDriver()) {
|
|
21
|
+
registry.register(createDesktopCapability());
|
|
22
|
+
}
|
|
23
|
+
return registry;
|
|
24
|
+
}
|
|
@@ -5,50 +5,154 @@
|
|
|
5
5
|
* even though the server already validates permissions.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { open, writeFile, readdir, stat, unlink, mkdir } from 'fs/promises';
|
|
9
9
|
import { join, dirname } from 'path';
|
|
10
10
|
import type { Capability, RpcHandler } from './index';
|
|
11
|
-
import { validatePath } from '../security/path-validator';
|
|
11
|
+
import { validatePath, validateWritePath } from '../security/path-validator';
|
|
12
12
|
import type { TunnelConfig } from '../config';
|
|
13
|
+
import type { LocalPermission } from '../security/permission-guard';
|
|
14
|
+
import { operationForMethod } from '../../shared/permissions';
|
|
15
|
+
|
|
16
|
+
function permissionFilesystemScope(params: Record<string, unknown>): {
|
|
17
|
+
paths?: string[];
|
|
18
|
+
allowedPaths?: string[];
|
|
19
|
+
blockedPaths?: string[];
|
|
20
|
+
excludePatterns?: string[];
|
|
21
|
+
operations?: string[];
|
|
22
|
+
maxFileSize?: number;
|
|
23
|
+
} {
|
|
24
|
+
const permission = params.__permission as LocalPermission | undefined;
|
|
25
|
+
if (permission?.capability !== 'filesystem') {
|
|
26
|
+
throw new Error('Permission denied: filesystem permission required');
|
|
27
|
+
}
|
|
28
|
+
return (permission.scope ?? {}) as {
|
|
29
|
+
paths?: string[];
|
|
30
|
+
blockedPaths?: string[];
|
|
31
|
+
excludePatterns?: string[];
|
|
32
|
+
operations?: string[];
|
|
33
|
+
maxFileSize?: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function scopedAllowedPaths(params: Record<string, unknown>): string[] {
|
|
38
|
+
const scope = permissionFilesystemScope(params);
|
|
39
|
+
return Array.isArray(scope.paths)
|
|
40
|
+
? scope.paths.filter((p: unknown): p is string => typeof p === 'string' && p.trim().length > 0)
|
|
41
|
+
: [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function effectiveBlockedPaths(config: TunnelConfig, params: Record<string, unknown>): string[] {
|
|
45
|
+
const scope = permissionFilesystemScope(params);
|
|
46
|
+
const scoped = Array.isArray(scope.blockedPaths)
|
|
47
|
+
? scope.blockedPaths.filter((p): p is string => typeof p === 'string' && p.trim().length > 0)
|
|
48
|
+
: [];
|
|
49
|
+
return [...config.blockedPaths, ...scoped];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateFilesystemPath(
|
|
53
|
+
path: string,
|
|
54
|
+
config: TunnelConfig,
|
|
55
|
+
params: Record<string, unknown>,
|
|
56
|
+
write = false,
|
|
57
|
+
): string {
|
|
58
|
+
const blocked = effectiveBlockedPaths(config, params);
|
|
59
|
+
const validator = write ? validateWritePath : validatePath;
|
|
60
|
+
const resolved = validator(path, config.allowedPaths, blocked);
|
|
61
|
+
const scoped = scopedAllowedPaths(params);
|
|
62
|
+
if (scoped.length > 0) validator(path, scoped, blocked);
|
|
63
|
+
|
|
64
|
+
const scope = permissionFilesystemScope(params);
|
|
65
|
+
const patterns = Array.isArray(scope.excludePatterns)
|
|
66
|
+
? scope.excludePatterns.filter((value): value is string => typeof value === 'string')
|
|
67
|
+
: [];
|
|
68
|
+
if (patterns.some((pattern) => matchGlob(path, pattern) || matchGlob(resolved, pattern))) {
|
|
69
|
+
throw new Error(`Permission denied: path "${path}" matches an excluded pattern`);
|
|
70
|
+
}
|
|
71
|
+
return resolved;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function matchGlob(path: string, pattern: string): boolean {
|
|
75
|
+
const regex = pattern
|
|
76
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
77
|
+
.replace(/\*\*/g, '{{GLOBSTAR}}')
|
|
78
|
+
.replace(/\*/g, '[^/\\\\]*')
|
|
79
|
+
.replace(/\{\{GLOBSTAR\}\}/g, '.*');
|
|
80
|
+
return new RegExp(`^${regex}$`).test(path);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseEncoding(value: unknown): 'utf8' | 'base64' {
|
|
84
|
+
if (value === undefined || value === 'utf-8' || value === 'utf8') return 'utf8';
|
|
85
|
+
if (value === 'base64') return 'base64';
|
|
86
|
+
throw new Error('Encoding must be "utf-8" or "base64"');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function assertFilesystemOperation(params: Record<string, unknown>, method: string): void {
|
|
90
|
+
const scope = permissionFilesystemScope(params);
|
|
91
|
+
const operations = Array.isArray(scope.operations)
|
|
92
|
+
? scope.operations.filter((value): value is string => typeof value === 'string')
|
|
93
|
+
: [];
|
|
94
|
+
const operation = operationForMethod(method);
|
|
95
|
+
if (operations.length > 0 && !operations.includes(operation)) {
|
|
96
|
+
throw new Error(`Permission denied: filesystem operation "${operation}" is not allowed`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
13
99
|
|
|
14
100
|
export function createFilesystemCapability(config: TunnelConfig): Capability {
|
|
15
101
|
const methods = new Map<string, RpcHandler>();
|
|
16
102
|
|
|
17
103
|
methods.set('fs.read', async (params) => {
|
|
104
|
+
assertFilesystemOperation(params, 'fs.read');
|
|
18
105
|
const path = params.path as string;
|
|
19
|
-
const encoding = (params.encoding
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
106
|
+
const encoding = parseEncoding(params.encoding);
|
|
107
|
+
|
|
108
|
+
validateFilesystemPath(path, config, params);
|
|
109
|
+
|
|
110
|
+
const handle = await open(path, 'r');
|
|
111
|
+
try {
|
|
112
|
+
const stats = await handle.stat();
|
|
113
|
+
const scope = permissionFilesystemScope(params);
|
|
114
|
+
const maxFileSize = Math.min(
|
|
115
|
+
config.maxFileSize,
|
|
116
|
+
typeof scope.maxFileSize === 'number' ? scope.maxFileSize : config.maxFileSize,
|
|
117
|
+
);
|
|
118
|
+
if (stats.size > maxFileSize) {
|
|
119
|
+
throw new Error(`File exceeds max size (${stats.size} > ${maxFileSize})`);
|
|
120
|
+
}
|
|
121
|
+
const content = await handle.readFile({ encoding });
|
|
122
|
+
return {
|
|
123
|
+
content,
|
|
124
|
+
size: stats.size,
|
|
125
|
+
encoding,
|
|
126
|
+
};
|
|
127
|
+
} finally {
|
|
128
|
+
await handle.close();
|
|
26
129
|
}
|
|
27
|
-
|
|
28
|
-
const content = await readFile(path, { encoding });
|
|
29
|
-
|
|
30
|
-
return {
|
|
31
|
-
content,
|
|
32
|
-
size: stats.size,
|
|
33
|
-
encoding,
|
|
34
|
-
};
|
|
35
130
|
});
|
|
36
131
|
|
|
37
|
-
|
|
38
132
|
methods.set('fs.write', async (params) => {
|
|
133
|
+
assertFilesystemOperation(params, 'fs.write');
|
|
39
134
|
const path = params.path as string;
|
|
40
135
|
const content = params.content as string;
|
|
41
|
-
const encoding = (params.encoding
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
136
|
+
const encoding = parseEncoding(params.encoding);
|
|
137
|
+
|
|
138
|
+
validateFilesystemPath(path, config, params, true);
|
|
139
|
+
|
|
140
|
+
const scope = permissionFilesystemScope(params);
|
|
141
|
+
const maxFileSize = Math.min(
|
|
142
|
+
config.maxFileSize,
|
|
143
|
+
typeof scope.maxFileSize === 'number' ? scope.maxFileSize : config.maxFileSize,
|
|
144
|
+
);
|
|
145
|
+
if (typeof content !== 'string') throw new Error('Content must be a string');
|
|
146
|
+
const contentBytes = Buffer.byteLength(content, encoding);
|
|
147
|
+
if (contentBytes > maxFileSize) {
|
|
148
|
+
throw new Error(`Content exceeds max size (${contentBytes} > ${maxFileSize})`);
|
|
47
149
|
}
|
|
48
150
|
|
|
49
151
|
await mkdir(dirname(path), { recursive: true });
|
|
152
|
+
validateFilesystemPath(path, config, params, true);
|
|
50
153
|
|
|
51
154
|
await writeFile(path, content, { encoding });
|
|
155
|
+
validateFilesystemPath(path, config, params);
|
|
52
156
|
const stats = await stat(path);
|
|
53
157
|
|
|
54
158
|
return {
|
|
@@ -57,12 +161,12 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
|
|
|
57
161
|
};
|
|
58
162
|
});
|
|
59
163
|
|
|
60
|
-
|
|
61
164
|
methods.set('fs.list', async (params) => {
|
|
165
|
+
assertFilesystemOperation(params, 'fs.list');
|
|
62
166
|
const path = params.path as string;
|
|
63
|
-
const recursive = params.recursive as boolean || false;
|
|
167
|
+
const recursive = (params.recursive as boolean) || false;
|
|
64
168
|
|
|
65
|
-
|
|
169
|
+
validateFilesystemPath(path, config, params);
|
|
66
170
|
|
|
67
171
|
const entries = await readdir(path, { withFileTypes: true });
|
|
68
172
|
|
|
@@ -88,19 +192,18 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
|
|
|
88
192
|
isSymlink: sub.isSymbolicLink(),
|
|
89
193
|
});
|
|
90
194
|
}
|
|
91
|
-
} catch {
|
|
92
|
-
}
|
|
195
|
+
} catch {}
|
|
93
196
|
}
|
|
94
197
|
}
|
|
95
198
|
|
|
96
199
|
return { entries: result, count: result.length };
|
|
97
200
|
});
|
|
98
201
|
|
|
99
|
-
|
|
100
202
|
methods.set('fs.stat', async (params) => {
|
|
203
|
+
assertFilesystemOperation(params, 'fs.stat');
|
|
101
204
|
const path = params.path as string;
|
|
102
205
|
|
|
103
|
-
|
|
206
|
+
validateFilesystemPath(path, config, params);
|
|
104
207
|
|
|
105
208
|
const stats = await stat(path);
|
|
106
209
|
|
|
@@ -117,9 +220,10 @@ export function createFilesystemCapability(config: TunnelConfig): Capability {
|
|
|
117
220
|
});
|
|
118
221
|
|
|
119
222
|
methods.set('fs.delete', async (params) => {
|
|
223
|
+
assertFilesystemOperation(params, 'fs.delete');
|
|
120
224
|
const path = params.path as string;
|
|
121
225
|
|
|
122
|
-
|
|
226
|
+
validateFilesystemPath(path, config, params);
|
|
123
227
|
|
|
124
228
|
await unlink(path);
|
|
125
229
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Capability Registry — extensible registry for tunnel capabilities.
|
|
3
3
|
*
|
|
4
|
-
* Each capability (filesystem, shell,
|
|
4
|
+
* Each capability (filesystem, shell, CUA-backed desktop, etc.) registers its
|
|
5
5
|
* RPC method handlers here. The TunnelAgent dispatches incoming
|
|
6
6
|
* JSON-RPC requests to the matching handler.
|
|
7
7
|
*/
|