@cjhyy/code-shell-server 0.8.8 → 0.8.10
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 +11 -10
- package/dist/attachment-service.js +147 -13
- package/dist/mobile-remote/access-passcode.d.ts +13 -0
- package/dist/mobile-remote/access-passcode.js +147 -13
- package/dist/mobile-remote/mobile-attachments.d.ts +1 -5
- package/dist/mobile-remote/mobile-attachments.js +2 -4
- package/dist/mobile-remote/mobile-client-event-validator.d.ts +3 -0
- package/dist/mobile-remote/mobile-client-event-validator.js +230 -0
- package/dist/mobile-remote/mobile-limits.d.ts +6 -0
- package/dist/mobile-remote/mobile-limits.js +6 -0
- package/dist/mobile-remote/mobile-upload-service.d.ts +2 -1
- package/dist/mobile-remote/mobile-upload-service.js +11 -5
- package/dist/mobile-remote/pairing.d.ts +11 -0
- package/dist/mobile-remote/pairing.js +47 -2
- package/dist/mobile-remote/remote-host-manager.d.ts +5 -3
- package/dist/mobile-remote/remote-host-manager.js +105 -16
- package/dist/mobile-remote/room-manager.d.ts +9 -1
- package/dist/mobile-remote/room-manager.js +388 -55
- package/dist/mobile-remote/trusted-device-store.d.ts +3 -0
- package/dist/mobile-remote/trusted-device-store.js +215 -58
- package/dist/serve/headless-server.d.ts +5 -0
- package/dist/serve/headless-server.js +112 -15
- package/package.json +5 -4
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { MAX_MOBILE_ATTACHMENTS, MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
|
|
2
|
+
const PERMISSION_MODES = new Set(["default", "acceptEdits", "bypassPermissions"]);
|
|
3
|
+
const KINDS = new Set(["claude-code", "codex"]);
|
|
4
|
+
const SCOPES = new Set(["once", "session", "project"]);
|
|
5
|
+
const PATH_SCOPES = new Set(["file", "dir", "tool"]);
|
|
6
|
+
const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
|
|
7
|
+
const MAX_ID_LENGTH = 512;
|
|
8
|
+
const MAX_NAME_LENGTH = 512;
|
|
9
|
+
const MAX_PATH_LENGTH = 32_768;
|
|
10
|
+
const MAX_MESSAGE_LENGTH = 512 * 1024;
|
|
11
|
+
const MAX_HISTORY_LIMIT = 500;
|
|
12
|
+
/** Runtime validation at the WebSocket boundary; TypeScript types do not validate JSON. */
|
|
13
|
+
export function parseMobileClientEvent(value) {
|
|
14
|
+
if (!isRecord(value) || typeof value.type !== "string")
|
|
15
|
+
return undefined;
|
|
16
|
+
const event = value;
|
|
17
|
+
let valid;
|
|
18
|
+
switch (event.type) {
|
|
19
|
+
case "auth.device":
|
|
20
|
+
valid = boundedString(event.deviceId, MAX_ID_LENGTH) && boundedString(event.secretHash, 4096);
|
|
21
|
+
break;
|
|
22
|
+
case "pair.complete":
|
|
23
|
+
valid =
|
|
24
|
+
boundedString(event.token, MAX_ID_LENGTH) &&
|
|
25
|
+
boundedTrimmedString(event.name, MAX_NAME_LENGTH) &&
|
|
26
|
+
boundedString(event.secretHash, 4096);
|
|
27
|
+
break;
|
|
28
|
+
case "chat.send":
|
|
29
|
+
valid =
|
|
30
|
+
boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
|
|
31
|
+
optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
32
|
+
optionalBoundedString(event.clientMessageId, MAX_ID_LENGTH) &&
|
|
33
|
+
optionalAttachments(event.attachments);
|
|
34
|
+
break;
|
|
35
|
+
case "attachment.upload.begin":
|
|
36
|
+
valid =
|
|
37
|
+
boundedString(event.clientId, MAX_ID_LENGTH) &&
|
|
38
|
+
boundedString(event.name, MAX_NAME_LENGTH) &&
|
|
39
|
+
member(event.mime, IMAGE_MIMES) &&
|
|
40
|
+
isPositiveInteger(event.size) &&
|
|
41
|
+
event.size <= MAX_MOBILE_IMAGE_BYTES;
|
|
42
|
+
break;
|
|
43
|
+
case "session.select":
|
|
44
|
+
case "session.history":
|
|
45
|
+
valid = boundedString(event.sessionId, MAX_ID_LENGTH);
|
|
46
|
+
break;
|
|
47
|
+
case "session.create":
|
|
48
|
+
valid =
|
|
49
|
+
(event.cwd === undefined ||
|
|
50
|
+
event.cwd === null ||
|
|
51
|
+
boundedPath(event.cwd)) &&
|
|
52
|
+
optionalBoundedString(event.name, MAX_NAME_LENGTH);
|
|
53
|
+
break;
|
|
54
|
+
case "run.stop":
|
|
55
|
+
valid = optionalBoundedString(event.sessionId, MAX_ID_LENGTH);
|
|
56
|
+
break;
|
|
57
|
+
case "approval.respond":
|
|
58
|
+
valid =
|
|
59
|
+
boundedString(event.approvalId, MAX_ID_LENGTH) &&
|
|
60
|
+
(event.decision === "approve" || event.decision === "reject") &&
|
|
61
|
+
optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
62
|
+
optionalBoundedString(event.reason, MAX_MESSAGE_LENGTH) &&
|
|
63
|
+
optionalBoundedString(event.answer, MAX_MESSAGE_LENGTH) &&
|
|
64
|
+
optionalMember(event.scope, SCOPES) &&
|
|
65
|
+
optionalMember(event.pathScope, PATH_SCOPES);
|
|
66
|
+
break;
|
|
67
|
+
case "session.list":
|
|
68
|
+
case "room.list":
|
|
69
|
+
case "room.projects":
|
|
70
|
+
valid = true;
|
|
71
|
+
break;
|
|
72
|
+
case "session.sync":
|
|
73
|
+
valid =
|
|
74
|
+
boundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
75
|
+
optionalNonNegativeInteger(event.sinceSeq);
|
|
76
|
+
break;
|
|
77
|
+
case "permission.setMode":
|
|
78
|
+
valid =
|
|
79
|
+
optionalBoundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
80
|
+
member(event.mode, PERMISSION_MODES);
|
|
81
|
+
break;
|
|
82
|
+
case "model.set":
|
|
83
|
+
valid = boundedString(event.model, MAX_ID_LENGTH);
|
|
84
|
+
break;
|
|
85
|
+
case "goal.extend":
|
|
86
|
+
valid =
|
|
87
|
+
boundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
88
|
+
optionalPositiveInteger(event.addTurns) &&
|
|
89
|
+
optionalPositiveInteger(event.addTokenBudget) &&
|
|
90
|
+
optionalPositiveInteger(event.addTimeBudgetMs) &&
|
|
91
|
+
optionalPositiveInteger(event.addStopBlocks) &&
|
|
92
|
+
[event.addTurns, event.addTokenBudget, event.addTimeBudgetMs, event.addStopBlocks].some((amount) => amount !== undefined);
|
|
93
|
+
break;
|
|
94
|
+
case "goal.clear":
|
|
95
|
+
valid = boundedString(event.sessionId, MAX_ID_LENGTH);
|
|
96
|
+
break;
|
|
97
|
+
case "room.create":
|
|
98
|
+
valid =
|
|
99
|
+
boundedPath(event.cwd) &&
|
|
100
|
+
optionalBoundedString(event.name, MAX_NAME_LENGTH) &&
|
|
101
|
+
optionalMember(event.kind, KINDS) &&
|
|
102
|
+
optionalMember(event.permissionMode, PERMISSION_MODES);
|
|
103
|
+
break;
|
|
104
|
+
case "room.open":
|
|
105
|
+
case "room.close":
|
|
106
|
+
valid = boundedString(event.roomId, MAX_ID_LENGTH);
|
|
107
|
+
break;
|
|
108
|
+
case "room.send":
|
|
109
|
+
valid =
|
|
110
|
+
boundedString(event.roomId, MAX_ID_LENGTH) &&
|
|
111
|
+
boundedOptionalEmptyString(event.text, MAX_MESSAGE_LENGTH) &&
|
|
112
|
+
optionalBoundedString(event.clientMessageId, MAX_ID_LENGTH) &&
|
|
113
|
+
optionalAttachments(event.attachments);
|
|
114
|
+
break;
|
|
115
|
+
case "room.history":
|
|
116
|
+
valid =
|
|
117
|
+
boundedString(event.roomId, MAX_ID_LENGTH) &&
|
|
118
|
+
optionalNonNegativeInteger(event.sinceSeq);
|
|
119
|
+
break;
|
|
120
|
+
case "ccRoom.probe":
|
|
121
|
+
valid =
|
|
122
|
+
(event.force === undefined || typeof event.force === "boolean") &&
|
|
123
|
+
optionalMember(event.kind, KINDS);
|
|
124
|
+
break;
|
|
125
|
+
case "ccRoom.listSessions":
|
|
126
|
+
valid = boundedPath(event.cwd) && optionalMember(event.kind, KINDS);
|
|
127
|
+
break;
|
|
128
|
+
case "ccRoom.openSession":
|
|
129
|
+
valid =
|
|
130
|
+
boundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
131
|
+
boundedPath(event.cwd) &&
|
|
132
|
+
member(event.mode, PERMISSION_MODES) &&
|
|
133
|
+
optionalMember(event.kind, KINDS);
|
|
134
|
+
break;
|
|
135
|
+
case "ccRoom.subscribeTranscript":
|
|
136
|
+
valid =
|
|
137
|
+
boundedString(event.roomId, MAX_ID_LENGTH) &&
|
|
138
|
+
boundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
139
|
+
boundedPath(event.cwd) &&
|
|
140
|
+
isBoundedHistoryLimit(event.limit) &&
|
|
141
|
+
optionalMember(event.kind, KINDS);
|
|
142
|
+
break;
|
|
143
|
+
case "ccRoom.unsubscribeTranscript":
|
|
144
|
+
valid = boundedString(event.roomId, MAX_ID_LENGTH);
|
|
145
|
+
break;
|
|
146
|
+
case "ccRoom.readHistory":
|
|
147
|
+
valid =
|
|
148
|
+
boundedPath(event.cwd) &&
|
|
149
|
+
boundedString(event.sessionId, MAX_ID_LENGTH) &&
|
|
150
|
+
isBoundedHistoryLimit(event.limit) &&
|
|
151
|
+
optionalMember(event.kind, KINDS);
|
|
152
|
+
break;
|
|
153
|
+
case "ccRoom.respondApproval":
|
|
154
|
+
valid =
|
|
155
|
+
boundedString(event.roomId, MAX_ID_LENGTH) &&
|
|
156
|
+
boundedString(event.requestId, MAX_ID_LENGTH) &&
|
|
157
|
+
isApprovalDecision(event.decision);
|
|
158
|
+
break;
|
|
159
|
+
default:
|
|
160
|
+
valid = false;
|
|
161
|
+
}
|
|
162
|
+
return valid ? event : undefined;
|
|
163
|
+
}
|
|
164
|
+
function isRecord(value) {
|
|
165
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
166
|
+
}
|
|
167
|
+
function boundedString(value, maxLength) {
|
|
168
|
+
return typeof value === "string" && value.length > 0 && value.length <= maxLength;
|
|
169
|
+
}
|
|
170
|
+
function boundedOptionalEmptyString(value, maxLength) {
|
|
171
|
+
return typeof value === "string" && value.length <= maxLength;
|
|
172
|
+
}
|
|
173
|
+
function boundedTrimmedString(value, maxLength) {
|
|
174
|
+
return boundedString(value, maxLength) && value.trim().length > 0 && !value.includes("\0");
|
|
175
|
+
}
|
|
176
|
+
function boundedPath(value) {
|
|
177
|
+
return boundedString(value, MAX_PATH_LENGTH) && !value.includes("\0");
|
|
178
|
+
}
|
|
179
|
+
function optionalBoundedString(value, maxLength) {
|
|
180
|
+
return value === undefined || boundedString(value, maxLength);
|
|
181
|
+
}
|
|
182
|
+
function member(value, values) {
|
|
183
|
+
return typeof value === "string" && values.has(value);
|
|
184
|
+
}
|
|
185
|
+
function optionalMember(value, values) {
|
|
186
|
+
return value === undefined || member(value, values);
|
|
187
|
+
}
|
|
188
|
+
function isNonNegativeInteger(value) {
|
|
189
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
190
|
+
}
|
|
191
|
+
function isPositiveInteger(value) {
|
|
192
|
+
return isNonNegativeInteger(value) && value > 0;
|
|
193
|
+
}
|
|
194
|
+
function optionalNonNegativeInteger(value) {
|
|
195
|
+
return value === undefined || isNonNegativeInteger(value);
|
|
196
|
+
}
|
|
197
|
+
function optionalPositiveInteger(value) {
|
|
198
|
+
return value === undefined || isPositiveInteger(value);
|
|
199
|
+
}
|
|
200
|
+
function isBoundedHistoryLimit(value) {
|
|
201
|
+
return isPositiveInteger(value) && value <= MAX_HISTORY_LIMIT;
|
|
202
|
+
}
|
|
203
|
+
function optionalAttachments(value) {
|
|
204
|
+
return (value === undefined ||
|
|
205
|
+
(Array.isArray(value) &&
|
|
206
|
+
value.length <= MAX_MOBILE_ATTACHMENTS &&
|
|
207
|
+
value.every(isAttachment)));
|
|
208
|
+
}
|
|
209
|
+
function isAttachment(value) {
|
|
210
|
+
if (!isRecord(value) ||
|
|
211
|
+
!boundedString(value.clientId, MAX_ID_LENGTH) ||
|
|
212
|
+
!boundedString(value.name, MAX_NAME_LENGTH) ||
|
|
213
|
+
!member(value.mime, IMAGE_MIMES) ||
|
|
214
|
+
!isPositiveInteger(value.size) ||
|
|
215
|
+
value.size > MAX_MOBILE_IMAGE_BYTES) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
return value.transport === "inline"
|
|
219
|
+
? boundedString(value.dataUrl, MAX_MESSAGE_LENGTH)
|
|
220
|
+
: value.transport === "upload" && boundedString(value.uploadId, MAX_ID_LENGTH);
|
|
221
|
+
}
|
|
222
|
+
function isApprovalDecision(value) {
|
|
223
|
+
if (!isRecord(value))
|
|
224
|
+
return false;
|
|
225
|
+
if (value.behavior === "allow") {
|
|
226
|
+
return ((value.answer === undefined || boundedOptionalEmptyString(value.answer, MAX_MESSAGE_LENGTH)) &&
|
|
227
|
+
(value.updatedInput === undefined || isRecord(value.updatedInput)));
|
|
228
|
+
}
|
|
229
|
+
return (value.behavior === "deny" && boundedOptionalEmptyString(value.message, MAX_MESSAGE_LENGTH));
|
|
230
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Pure transport limits shared by validation, upload ticketing, and materialization. */
|
|
2
|
+
export declare const MAX_MOBILE_ATTACHMENTS = 4;
|
|
3
|
+
export declare const MAX_MOBILE_IMAGE_BYTES: number;
|
|
4
|
+
export declare const MAX_MOBILE_INLINE_IMAGE_BYTES: number;
|
|
5
|
+
export declare const MAX_MOBILE_INLINE_TOTAL_BYTES: number;
|
|
6
|
+
export declare const MAX_MOBILE_ATTACHMENT_TOTAL_BYTES: number;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Pure transport limits shared by validation, upload ticketing, and materialization. */
|
|
2
|
+
export const MAX_MOBILE_ATTACHMENTS = 4;
|
|
3
|
+
export const MAX_MOBILE_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
4
|
+
export const MAX_MOBILE_INLINE_IMAGE_BYTES = 256 * 1024;
|
|
5
|
+
export const MAX_MOBILE_INLINE_TOTAL_BYTES = 512 * 1024;
|
|
6
|
+
export const MAX_MOBILE_ATTACHMENT_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import type { MobileImageBase } from "./types.js";
|
|
3
|
-
export
|
|
3
|
+
export { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
|
|
4
4
|
export declare const MOBILE_UPLOAD_TTL_MS: number;
|
|
5
5
|
export declare const MAX_MOBILE_UPLOAD_TICKETS_PER_DEVICE = 16;
|
|
6
6
|
export declare const MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS = 3;
|
|
@@ -53,5 +53,6 @@ export declare class MobileUploadService {
|
|
|
53
53
|
private expire;
|
|
54
54
|
private cleanupRecordFiles;
|
|
55
55
|
private cleanupOrphanSpools;
|
|
56
|
+
private ensurePrivateRoot;
|
|
56
57
|
private assertClaimOwner;
|
|
57
58
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { createWriteStream } from "node:fs";
|
|
3
|
-
import { mkdir, readdir, rename, rm, stat } from "node:fs/promises";
|
|
3
|
+
import { chmod, mkdir, readdir, rename, rm, stat } from "node:fs/promises";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { pipeline } from "node:stream/promises";
|
|
6
6
|
import { Transform } from "node:stream";
|
|
7
|
-
|
|
7
|
+
import { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
|
|
8
|
+
export { MAX_MOBILE_IMAGE_BYTES } from "./mobile-limits.js";
|
|
8
9
|
export const MOBILE_UPLOAD_TTL_MS = 5 * 60 * 1000;
|
|
9
10
|
export const MAX_MOBILE_UPLOAD_TICKETS_PER_DEVICE = 16;
|
|
10
11
|
export const MAX_MOBILE_UPLOAD_CLAIM_ATTEMPTS = 3;
|
|
@@ -142,7 +143,7 @@ export class MobileUploadService {
|
|
|
142
143
|
// receives the existing 409 above while this first request runs normally.
|
|
143
144
|
record.status = "uploading";
|
|
144
145
|
try {
|
|
145
|
-
await
|
|
146
|
+
await this.ensurePrivateRoot();
|
|
146
147
|
}
|
|
147
148
|
catch {
|
|
148
149
|
if (this.uploads.get(uploadId) === record && record.status === "uploading") {
|
|
@@ -173,7 +174,7 @@ export class MobileUploadService {
|
|
|
173
174
|
controller.abort(new Error("upload ticket expired during transfer"));
|
|
174
175
|
}, Math.max(0, record.expiresAt - this.now()));
|
|
175
176
|
deadline.unref?.();
|
|
176
|
-
const transfer = pipeline(req, counter, createWriteStream(partPath, { flags: "wx" }), {
|
|
177
|
+
const transfer = pipeline(req, counter, createWriteStream(partPath, { flags: "wx", mode: 0o600 }), {
|
|
177
178
|
signal: controller.signal,
|
|
178
179
|
});
|
|
179
180
|
this.activeTransfers.set(uploadId, { controller, done: transfer });
|
|
@@ -332,7 +333,7 @@ export class MobileUploadService {
|
|
|
332
333
|
}
|
|
333
334
|
async cleanupOrphanSpools(now, removeAll) {
|
|
334
335
|
try {
|
|
335
|
-
await
|
|
336
|
+
await this.ensurePrivateRoot();
|
|
336
337
|
}
|
|
337
338
|
catch {
|
|
338
339
|
return;
|
|
@@ -356,6 +357,11 @@ export class MobileUploadService {
|
|
|
356
357
|
await rm(path, { force: true });
|
|
357
358
|
}));
|
|
358
359
|
}
|
|
360
|
+
async ensurePrivateRoot() {
|
|
361
|
+
await mkdir(this.opts.rootDir, { recursive: true, mode: 0o700 });
|
|
362
|
+
if (process.platform !== "win32")
|
|
363
|
+
await chmod(this.opts.rootDir, 0o700);
|
|
364
|
+
}
|
|
359
365
|
assertClaimOwner(deviceId, uploadId, claimId) {
|
|
360
366
|
const record = this.uploads.get(uploadId);
|
|
361
367
|
if (!record)
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import type { PairingToken } from "./types.js";
|
|
2
|
+
export declare const MAX_PENDING_PAIRING_TOKENS = 256;
|
|
2
3
|
export declare class PairingTokenManager {
|
|
3
4
|
private readonly now;
|
|
4
5
|
private tokens;
|
|
5
6
|
constructor(now?: () => number);
|
|
6
7
|
createToken(ttlMs?: number): PairingToken;
|
|
7
8
|
consume(value: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Run a synchronous commit while the token is valid, consuming it only when
|
|
11
|
+
* that commit succeeds. The validity timestamp is sampled once, so a commit
|
|
12
|
+
* that crosses the expiry millisecond cannot create a device yet report a
|
|
13
|
+
* false pairing failure.
|
|
14
|
+
*/
|
|
15
|
+
commit<T>(value: string, action: () => T): T | undefined;
|
|
16
|
+
/** Live token count for diagnostics/tests; expired entries are pruned on mint. */
|
|
17
|
+
get pendingCount(): number;
|
|
18
|
+
private prune;
|
|
8
19
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
+
export const MAX_PENDING_PAIRING_TOKENS = 256;
|
|
2
3
|
export class PairingTokenManager {
|
|
3
4
|
now;
|
|
4
5
|
tokens = new Map();
|
|
@@ -6,9 +7,23 @@ export class PairingTokenManager {
|
|
|
6
7
|
this.now = now;
|
|
7
8
|
}
|
|
8
9
|
createToken(ttlMs = 10 * 60 * 1000) {
|
|
10
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) {
|
|
11
|
+
throw new Error("Pairing token TTL must be a positive safe integer");
|
|
12
|
+
}
|
|
13
|
+
const now = this.now();
|
|
14
|
+
if (!Number.isSafeInteger(now) || now < 0 || now > Number.MAX_SAFE_INTEGER - ttlMs) {
|
|
15
|
+
throw new Error("Pairing token clock is invalid");
|
|
16
|
+
}
|
|
17
|
+
this.prune(now);
|
|
18
|
+
while (this.tokens.size >= MAX_PENDING_PAIRING_TOKENS) {
|
|
19
|
+
const oldest = this.tokens.keys().next().value;
|
|
20
|
+
if (oldest === undefined)
|
|
21
|
+
break;
|
|
22
|
+
this.tokens.delete(oldest);
|
|
23
|
+
}
|
|
9
24
|
const token = {
|
|
10
25
|
value: randomBytes(32).toString("base64url"),
|
|
11
|
-
expiresAt:
|
|
26
|
+
expiresAt: now + ttlMs,
|
|
12
27
|
};
|
|
13
28
|
this.tokens.set(token.value, token);
|
|
14
29
|
return token;
|
|
@@ -18,6 +33,36 @@ export class PairingTokenManager {
|
|
|
18
33
|
if (!token)
|
|
19
34
|
return false;
|
|
20
35
|
this.tokens.delete(value);
|
|
21
|
-
|
|
36
|
+
const now = this.now();
|
|
37
|
+
return Number.isSafeInteger(now) && now >= 0 && token.expiresAt >= now;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Run a synchronous commit while the token is valid, consuming it only when
|
|
41
|
+
* that commit succeeds. The validity timestamp is sampled once, so a commit
|
|
42
|
+
* that crosses the expiry millisecond cannot create a device yet report a
|
|
43
|
+
* false pairing failure.
|
|
44
|
+
*/
|
|
45
|
+
commit(value, action) {
|
|
46
|
+
const token = this.tokens.get(value);
|
|
47
|
+
if (!token)
|
|
48
|
+
return undefined;
|
|
49
|
+
const now = this.now();
|
|
50
|
+
if (!Number.isSafeInteger(now) || now < 0 || token.expiresAt < now) {
|
|
51
|
+
this.tokens.delete(value);
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const result = action();
|
|
55
|
+
this.tokens.delete(value);
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
/** Live token count for diagnostics/tests; expired entries are pruned on mint. */
|
|
59
|
+
get pendingCount() {
|
|
60
|
+
return this.tokens.size;
|
|
61
|
+
}
|
|
62
|
+
prune(now) {
|
|
63
|
+
for (const [value, token] of this.tokens) {
|
|
64
|
+
if (token.expiresAt < now)
|
|
65
|
+
this.tokens.delete(value);
|
|
66
|
+
}
|
|
22
67
|
}
|
|
23
68
|
}
|
|
@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
|
|
|
2
2
|
import { type WebSocket } from "ws";
|
|
3
3
|
import type { AccessPasscode } from "./access-passcode.js";
|
|
4
4
|
import type { TrustedDeviceStore } from "./trusted-device-store.js";
|
|
5
|
-
import type {
|
|
5
|
+
import type { MobileServerEvent } from "./types.js";
|
|
6
6
|
import type { MobileUploadService } from "./mobile-upload-service.js";
|
|
7
7
|
/**
|
|
8
8
|
* Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
|
|
@@ -33,7 +33,7 @@ export interface RemoteHostStarted {
|
|
|
33
33
|
}
|
|
34
34
|
export interface RemoteHostManagerOptions {
|
|
35
35
|
devices: TrustedDeviceStore;
|
|
36
|
-
onClientEvent: (event: unknown, ws: WebSocket) => void
|
|
36
|
+
onClientEvent: (event: unknown, ws: WebSocket) => void | Promise<void>;
|
|
37
37
|
/**
|
|
38
38
|
* Absolute path to the built mobile app (out/mobile). Defaults to the
|
|
39
39
|
* sibling of the bundled main (out/main → ../mobile). Overridable for tests.
|
|
@@ -52,6 +52,7 @@ export declare class RemoteHostManager extends EventEmitter {
|
|
|
52
52
|
private server?;
|
|
53
53
|
private wss?;
|
|
54
54
|
private started?;
|
|
55
|
+
private starting?;
|
|
55
56
|
private pairing;
|
|
56
57
|
/** ws → authenticated device id. A socket absent here is unauthenticated. */
|
|
57
58
|
private authed;
|
|
@@ -79,6 +80,7 @@ export declare class RemoteHostManager extends EventEmitter {
|
|
|
79
80
|
* socket goes away (online→offline). */
|
|
80
81
|
markOffline(deviceId: string): void;
|
|
81
82
|
start(options: RemoteHostStartOptions): Promise<RemoteHostStarted>;
|
|
83
|
+
private startOnce;
|
|
82
84
|
/**
|
|
83
85
|
* Override the base URL used to build pairing URLs (tunnel mode points this
|
|
84
86
|
* at the `https://*.trycloudflare.com` domain so the QR encodes the public
|
|
@@ -97,7 +99,7 @@ export declare class RemoteHostManager extends EventEmitter {
|
|
|
97
99
|
* trusted-device store (which rejects revoked devices), so the remote host
|
|
98
100
|
* never executes tools itself — it only gates transport.
|
|
99
101
|
*/
|
|
100
|
-
handleClientEvent(
|
|
102
|
+
handleClientEvent(input: unknown): MobileServerEvent | undefined;
|
|
101
103
|
/** Broadcast a server event to every connected, authenticated mobile socket. */
|
|
102
104
|
broadcast(event: MobileServerEvent): void;
|
|
103
105
|
/** Send a server event to ONLY the sockets belonging to one device. Used for
|
|
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { WebSocketServer } from "ws";
|
|
7
7
|
import { serveMobile } from "./mobile-static.js";
|
|
8
8
|
import { PairingTokenManager } from "./pairing.js";
|
|
9
|
+
import { parseMobileClientEvent } from "./mobile-client-event-validator.js";
|
|
9
10
|
/**
|
|
10
11
|
* Pick the Mac's real LAN IPv4 so a phone on the same Wi-Fi can reach the
|
|
11
12
|
* remote host. We bind to a concrete LAN address (NOT 0.0.0.0) per the design's
|
|
@@ -37,6 +38,7 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
37
38
|
server;
|
|
38
39
|
wss;
|
|
39
40
|
started;
|
|
41
|
+
starting;
|
|
40
42
|
pairing = new PairingTokenManager();
|
|
41
43
|
/** ws → authenticated device id. A socket absent here is unauthenticated. */
|
|
42
44
|
authed = new WeakMap();
|
|
@@ -91,6 +93,19 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
91
93
|
async start(options) {
|
|
92
94
|
if (this.started)
|
|
93
95
|
return this.started;
|
|
96
|
+
if (this.starting)
|
|
97
|
+
return this.starting;
|
|
98
|
+
const attempt = this.startOnce(options);
|
|
99
|
+
this.starting = attempt;
|
|
100
|
+
try {
|
|
101
|
+
return await attempt;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
if (this.starting === attempt)
|
|
105
|
+
this.starting = undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async startOnce(options) {
|
|
94
109
|
const tunnel = options.mode === "tunnel";
|
|
95
110
|
this.passcode = tunnel ? options.passcode : undefined;
|
|
96
111
|
const gate = this.passcode;
|
|
@@ -135,9 +150,19 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
135
150
|
this.wss = gate
|
|
136
151
|
? new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 })
|
|
137
152
|
: new WebSocketServer({ server, path: "/ws", maxPayload: 1024 * 1024 });
|
|
153
|
+
// ws re-emits its HTTP server's bind errors. Without an error listener a
|
|
154
|
+
// handled EADDRINUSE rejection still becomes an uncaught EventEmitter error.
|
|
155
|
+
this.wss.on("error", (error) => this.emit("host-error", error));
|
|
138
156
|
if (gate) {
|
|
139
157
|
server.on("upgrade", (req, socket, head) => {
|
|
140
|
-
|
|
158
|
+
let isWebSocketPath = false;
|
|
159
|
+
try {
|
|
160
|
+
isWebSocketPath = new URL(req.url ?? "", "http://localhost").pathname === "/ws";
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Invalid request targets are not valid WebSocket endpoints.
|
|
164
|
+
}
|
|
165
|
+
if (!isWebSocketPath) {
|
|
141
166
|
socket.destroy();
|
|
142
167
|
return;
|
|
143
168
|
}
|
|
@@ -155,18 +180,30 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
155
180
|
const viewerId = `viewer-${this.nextViewerId++}`;
|
|
156
181
|
this.viewers.set(ws, viewerId);
|
|
157
182
|
ws.on("message", (raw) => {
|
|
158
|
-
let
|
|
183
|
+
let parsed;
|
|
159
184
|
try {
|
|
160
|
-
|
|
185
|
+
parsed = JSON.parse(String(raw));
|
|
161
186
|
}
|
|
162
187
|
catch {
|
|
163
188
|
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
|
164
189
|
return;
|
|
165
190
|
}
|
|
191
|
+
const event = parseMobileClientEvent(parsed);
|
|
192
|
+
if (!event) {
|
|
193
|
+
ws.send(JSON.stringify({ type: "error", message: "Invalid client event" }));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
166
196
|
// Pairing/auth are handled inline and bind device identity to this
|
|
167
197
|
// socket. Everything else is gated: an unauthenticated socket cannot
|
|
168
198
|
// send chat/approval/run/job events (design §6.1).
|
|
169
|
-
|
|
199
|
+
let reply;
|
|
200
|
+
try {
|
|
201
|
+
reply = this.handleClientEvent(event);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
ws.send(JSON.stringify({ type: "error", message: "Client event failed" }));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
170
207
|
if (event.type === "auth.device" && reply?.type === "auth.ok") {
|
|
171
208
|
if (!this.authed.has(ws))
|
|
172
209
|
this.markOnline(reply.device.id);
|
|
@@ -188,7 +225,17 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
188
225
|
}
|
|
189
226
|
// Authenticated, non-auth event → hand to the main dispatcher, which
|
|
190
227
|
// routes chat/approval into the existing run/permission path.
|
|
191
|
-
|
|
228
|
+
try {
|
|
229
|
+
const dispatched = this.opts.onClientEvent({ ...event, deviceId: this.authed.get(ws), viewerId: this.viewers.get(ws) }, ws);
|
|
230
|
+
void Promise.resolve(dispatched).catch(() => {
|
|
231
|
+
if (ws.readyState === ws.OPEN) {
|
|
232
|
+
ws.send(JSON.stringify({ type: "error", message: "Client event failed" }));
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
ws.send(JSON.stringify({ type: "error", message: "Client event failed" }));
|
|
238
|
+
}
|
|
192
239
|
});
|
|
193
240
|
ws.on("close", () => {
|
|
194
241
|
this.releaseSocket(ws);
|
|
@@ -203,10 +250,35 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
203
250
|
: options.host === "lan"
|
|
204
251
|
? (resolveLanHost() ?? "127.0.0.1")
|
|
205
252
|
: options.host;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
253
|
+
try {
|
|
254
|
+
await new Promise((resolve, reject) => {
|
|
255
|
+
const onError = (error) => reject(error);
|
|
256
|
+
server.once("error", onError);
|
|
257
|
+
server.listen(options.port, bindHost, () => {
|
|
258
|
+
server.off("error", onError);
|
|
259
|
+
resolve();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
for (const client of this.wss?.clients ?? [])
|
|
265
|
+
client.terminate();
|
|
266
|
+
try {
|
|
267
|
+
this.wss?.close();
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
// A noServer WebSocketServer may not have entered a running state yet.
|
|
271
|
+
}
|
|
272
|
+
server.closeAllConnections?.();
|
|
273
|
+
if (server.listening) {
|
|
274
|
+
await new Promise((resolveClose) => server.close(() => resolveClose()));
|
|
275
|
+
}
|
|
276
|
+
if (this.server === server)
|
|
277
|
+
this.server = undefined;
|
|
278
|
+
this.wss = undefined;
|
|
279
|
+
this.passcode = undefined;
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
210
282
|
const addr = server.address();
|
|
211
283
|
const port = typeof addr === "object" && addr ? addr.port : options.port;
|
|
212
284
|
this.started = {
|
|
@@ -243,19 +315,34 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
243
315
|
* trusted-device store (which rejects revoked devices), so the remote host
|
|
244
316
|
* never executes tools itself — it only gates transport.
|
|
245
317
|
*/
|
|
246
|
-
handleClientEvent(
|
|
318
|
+
handleClientEvent(input) {
|
|
319
|
+
const event = parseMobileClientEvent(input);
|
|
320
|
+
if (!event)
|
|
321
|
+
return { type: "error", message: "Invalid client event" };
|
|
247
322
|
if (event.type === "pair.complete") {
|
|
248
|
-
|
|
323
|
+
let device;
|
|
324
|
+
try {
|
|
325
|
+
device = this.pairing.commit(event.token, () => this.opts.devices.addDevice({
|
|
326
|
+
name: event.name,
|
|
327
|
+
secretHash: event.secretHash,
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return { type: "pair.failed", message: "Trusted device store is unavailable" };
|
|
332
|
+
}
|
|
333
|
+
if (!device) {
|
|
249
334
|
return { type: "pair.failed", message: "Pairing token expired or invalid" };
|
|
250
335
|
}
|
|
251
|
-
const device = this.opts.devices.addDevice({
|
|
252
|
-
name: event.name,
|
|
253
|
-
secretHash: event.secretHash,
|
|
254
|
-
});
|
|
255
336
|
return { type: "pair.ok", device };
|
|
256
337
|
}
|
|
257
338
|
if (event.type === "auth.device") {
|
|
258
|
-
|
|
339
|
+
let device;
|
|
340
|
+
try {
|
|
341
|
+
device = this.opts.devices.authenticate(event.deviceId, event.secretHash);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return { type: "auth.failed", message: "Trusted device store is unavailable" };
|
|
345
|
+
}
|
|
259
346
|
if (!device)
|
|
260
347
|
return { type: "auth.failed", message: "Device is not trusted" };
|
|
261
348
|
return { type: "auth.ok", device };
|
|
@@ -309,6 +396,8 @@ export class RemoteHostManager extends EventEmitter {
|
|
|
309
396
|
this.markOffline(deviceId);
|
|
310
397
|
}
|
|
311
398
|
async stop() {
|
|
399
|
+
if (this.starting)
|
|
400
|
+
await this.starting.catch(() => undefined);
|
|
312
401
|
const server = this.server;
|
|
313
402
|
// Forcibly drop live WS sockets first. wss.close() alone waits for clients
|
|
314
403
|
// to disconnect, so a phone left connected would hang stop() indefinitely.
|
|
@@ -150,6 +150,9 @@ export declare class RoomManager {
|
|
|
150
150
|
* Holds the raw tool input so respondApproval can bake the user's answer into
|
|
151
151
|
* the `answers` record the CLI expects. Cleared on response. */
|
|
152
152
|
private pendingAskUser;
|
|
153
|
+
/** Original server-observed tool inputs for ordinary approvals. The response
|
|
154
|
+
* client may approve or deny, but must not replace the command/path it saw. */
|
|
155
|
+
private pendingApprovalInputs;
|
|
153
156
|
/** User turns are echoed immediately for responsive room UX. When a
|
|
154
157
|
* transcript follower later sees the same CLI-written user line, this queue
|
|
155
158
|
* suppresses that duplicate. */
|
|
@@ -174,15 +177,20 @@ export declare class RoomManager {
|
|
|
174
177
|
private pendingLastActive;
|
|
175
178
|
private lastActiveWrittenAt;
|
|
176
179
|
private now;
|
|
180
|
+
private readonly rootRealPath;
|
|
177
181
|
/** Drop all pending AskUser entries for a room. Called when its agent goes
|
|
178
182
|
* away (exit/close) so a request that can no longer be answered doesn't leak
|
|
179
183
|
* in the map (small memory leak) and its approval card doesn't hang until the
|
|
180
184
|
* 5-min timeout. Keys are `${roomId}:${requestId}`. */
|
|
181
|
-
private
|
|
185
|
+
private clearPendingApprovals;
|
|
182
186
|
constructor(opts: RoomManagerOptions);
|
|
187
|
+
private assertRootUnchanged;
|
|
183
188
|
private roomDir;
|
|
189
|
+
private checkedRoomFile;
|
|
184
190
|
private metaPath;
|
|
185
191
|
private msgPath;
|
|
192
|
+
private writeRoomMeta;
|
|
193
|
+
private appendRoomMessage;
|
|
186
194
|
createRoom(input: {
|
|
187
195
|
name?: string;
|
|
188
196
|
cwd: string;
|