@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 CHANGED
@@ -54,21 +54,22 @@ headless serve(本包 serve/)
54
54
  agent-server-stdio worker(@cjhyy/code-shell-capability-coding)
55
55
  ```
56
56
 
57
- - 浏览器是 core 协议的一等前端(`agent/run` / `agent/streamEvent` / `agent/approvalRequest` / `agent/approve` / `agent/cancel`),serve 只是一根经过认证的薄管道;所有已认证 tab 看到同一条事件流。
57
+ - 浏览器使用受限的 core 协议投影:serve 自己处理会话列表/详情,只允许向 worker 转发 `agent/run`、`agent/approve`、`agent/cancel`,并在转发前校验 workspace 与 session 归属。配置修改、任意 workspace 切换等原始 RPC 不对 Web 开放。
58
+ - 每个 tab 的请求 ID 会在 host 内重写,响应只回到发起 tab;`agent/streamEvent` 等通知才广播给所有已认证 tab,避免多 tab 使用相同本地 ID 时串包。
58
59
  - **访问控制只有 passcode + remember-cookie**(决策见 TODO 约束边界「服务端部署不做账号体系」):scrypt 哈希存储、防爆破锁定、轮换口令使所有旧 cookie 失效。无注册登录、无多用户。
59
60
  - 默认只绑 `127.0.0.1`;`--host 0.0.0.0` 暴露到局域网是运维者的显式选择。公网建议走反向代理/tunnel 终结 TLS。
60
- - 会话持久在磁盘(worker 的数据目录);serve 或 worker 重启后浏览器重连即恢复列表,worker 在下一条消息时按需重启。
61
+ - 会话、设置与凭据持久在 serve 独占的 worker data root(默认 `<data-dir>/worker`),不再读取宿主机默认 `~/.code-shell`;serve 或 worker 重启后浏览器重连即可恢复列表。
61
62
 
62
63
  ### CLI 参数
63
64
 
64
- | 参数 | 默认 | 说明 |
65
- | --------------- | --------------------- | ---------------------- |
66
- | `--cwd` | 当前目录 | worker 的 workspace 根 |
67
- | `--port` | 8790 | 监听端口 |
68
- | `--host` | 127.0.0.1 | 监听地址 |
69
- | `--passcode` | (生成) | 设置/轮换访问口令 |
70
- | `--data-dir` | `~/.code-shell/serve` | access.json 位置 |
71
- | `--static-root` | 自动解析 web dist-app | 覆盖静态资源目录 |
65
+ | 参数 | 默认 | 说明 |
66
+ | --------------- | --------------------- | -------------------------------------------- |
67
+ | `--cwd` | 当前目录 | worker 的 workspace 根 |
68
+ | `--port` | 8790 | 监听端口 |
69
+ | `--host` | 127.0.0.1 | 监听地址 |
70
+ | `--passcode` | (生成) | 设置/轮换访问口令 |
71
+ | `--data-dir` | `~/.code-shell/serve` | access.json 与隔离 worker data root 的父目录 |
72
+ | `--static-root` | 自动解析 web dist-app | 覆盖静态资源目录 |
72
73
 
73
74
  ### Web 客户端开发
74
75
 
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
- import { appendFile, lstat, mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { appendFile, lstat, mkdir, open, readdir, realpath, rename, rm, stat, writeFile, } from "node:fs/promises";
3
4
  import { basename, extname, join, relative, resolve, sep } from "node:path";
4
5
  import { sanitizeImageBytes } from "./image-byte-probe.js";
5
6
  const CODE_SHELL_DIR = ".code-shell";
@@ -10,6 +11,36 @@ export const DRAFT_ATTACHMENT_TTL_MS = 24 * 60 * 60 * 1000;
10
11
  export const SENT_ATTACHMENT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
11
12
  export const MAX_STAGED_IMAGE_BYTES = 10 * 1024 * 1024;
12
13
  export const MAX_STAGED_FILE_BYTES = 20 * 1024 * 1024;
14
+ const MAX_ATTACHMENT_NAME_CHARS = 1_024;
15
+ const MAX_ATTACHMENT_MIME_CHARS = 256;
16
+ const MAX_MANIFEST_LINE_BYTES = 128 * 1024;
17
+ const MAX_MANIFEST_SCAN_BYTES = 32 * 1024 * 1024;
18
+ const MAX_MANIFEST_RECORDS = 50_000;
19
+ const MAX_ATTACHMENT_SESSION_DIRS = 10_000;
20
+ const ATTACHMENT_ORIGINS = new Set([
21
+ "paste",
22
+ "os-drop",
23
+ "file-panel",
24
+ "picker",
25
+ "mention",
26
+ "generated",
27
+ "mobile",
28
+ "im-gateway",
29
+ "tool",
30
+ ]);
31
+ function assertAttachmentLabels(input) {
32
+ if ((input.name !== undefined &&
33
+ (typeof input.name !== "string" ||
34
+ input.name.length > MAX_ATTACHMENT_NAME_CHARS ||
35
+ input.name.includes("\0"))) ||
36
+ (input.mime !== undefined &&
37
+ (typeof input.mime !== "string" ||
38
+ input.mime.length > MAX_ATTACHMENT_MIME_CHARS ||
39
+ input.mime.includes("\0"))) ||
40
+ !ATTACHMENT_ORIGINS.has(input.origin)) {
41
+ throw new Error("invalid attachment metadata");
42
+ }
43
+ }
13
44
  const IMAGE_MIME_EXT = {
14
45
  "image/png": ".png",
15
46
  "image/jpeg": ".jpg",
@@ -31,6 +62,7 @@ export async function stageImageDataUrl(input) {
31
62
  }
32
63
  /** Stage already-transferred bytes without expanding them back through base64. */
33
64
  export async function stageImageBytes(input) {
65
+ assertAttachmentLabels(input);
34
66
  const mime = normalizeMime(input.mime);
35
67
  if (!ALLOWED_IMAGE_MIMES.has(mime))
36
68
  throw new Error(`unsupported image MIME ${input.mime}`);
@@ -39,14 +71,25 @@ export async function stageImageBytes(input) {
39
71
  }
40
72
  let buffer;
41
73
  if (input.sourceFile !== undefined) {
42
- const source = await lstat(input.sourceFile);
43
- if (!source.isFile() || source.isSymbolicLink()) {
44
- throw new Error("image source must be a regular file");
74
+ if (typeof input.sourceFile !== "string" ||
75
+ !input.sourceFile ||
76
+ input.sourceFile.length > 32_768 ||
77
+ input.sourceFile.includes("\0")) {
78
+ throw new Error("image source path is invalid");
79
+ }
80
+ const handle = await open(input.sourceFile, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
81
+ try {
82
+ const source = await handle.stat();
83
+ if (!source.isFile())
84
+ throw new Error("image source must be a regular file");
85
+ if (source.size > MAX_STAGED_IMAGE_BYTES) {
86
+ throw new Error(`image attachment exceeds size limit (${formatBytes(MAX_STAGED_IMAGE_BYTES)})`);
87
+ }
88
+ buffer = await handle.readFile();
45
89
  }
46
- if (source.size > MAX_STAGED_IMAGE_BYTES) {
47
- throw new Error(`image attachment exceeds size limit (${formatBytes(MAX_STAGED_IMAGE_BYTES)})`);
90
+ finally {
91
+ await handle.close();
48
92
  }
49
- buffer = await readFile(input.sourceFile);
50
93
  }
51
94
  else {
52
95
  buffer = Buffer.from(input.bytes);
@@ -54,6 +97,7 @@ export async function stageImageBytes(input) {
54
97
  return stageImageBuffer(input, mime, buffer);
55
98
  }
56
99
  async function stageImageBuffer(input, mime, buffer) {
100
+ assertAttachmentLabels(input);
57
101
  const cwd = await resolveExistingDirectory(input.cwd, "cwd");
58
102
  assertSafeSessionId(input.sessionId);
59
103
  if (buffer.byteLength > MAX_STAGED_IMAGE_BYTES) {
@@ -107,6 +151,7 @@ async function stageImageBuffer(input, mime, buffer) {
107
151
  }
108
152
  /** Stage an untrusted non-image file inside the canonical attachment root. */
109
153
  export async function stageFileBytes(input) {
154
+ assertAttachmentLabels(input);
110
155
  const cwd = await resolveExistingDirectory(input.cwd, "cwd");
111
156
  assertSafeSessionId(input.sessionId);
112
157
  const buffer = Buffer.from(input.bytes);
@@ -480,33 +525,121 @@ async function findExistingSessionFile(sessionDir, sha16) {
480
525
  catch {
481
526
  return null;
482
527
  }
483
- return entries.find((entry) => entry.startsWith(`${sha16}-`) && entry !== MANIFEST_FILE) ?? null;
528
+ for (const entry of entries) {
529
+ if (!entry.startsWith(`${sha16}-`) || entry === MANIFEST_FILE)
530
+ continue;
531
+ const info = await lstat(join(sessionDir, entry));
532
+ if (info.isSymbolicLink() || !info.isFile()) {
533
+ throw new Error("existing attachment hash entry must be a regular file, not a symlink");
534
+ }
535
+ return entry;
536
+ }
537
+ return null;
484
538
  }
485
539
  async function appendManifest(sessionDir, record) {
486
540
  const manifest = await safeJoin(sessionDir, MANIFEST_FILE, sessionDir);
487
- await appendFile(manifest, `${JSON.stringify(record)}\n`, "utf-8");
541
+ const serialized = JSON.stringify(record);
542
+ if (Buffer.byteLength(serialized, "utf8") > MAX_MANIFEST_LINE_BYTES) {
543
+ throw new Error("attachment manifest record exceeds the size limit");
544
+ }
545
+ await appendFile(manifest, `${serialized}\n`, { encoding: "utf-8", mode: 0o600 });
488
546
  }
489
547
  async function readManifest(sessionDir) {
490
548
  try {
491
549
  const manifest = await safeJoin(sessionDir, MANIFEST_FILE, sessionDir);
492
- const raw = await readFile(manifest, "utf-8");
550
+ const handle = await open(manifest, "r");
551
+ let raw;
552
+ try {
553
+ const stat = await handle.stat();
554
+ if (!stat.isFile())
555
+ return [];
556
+ const length = Math.min(stat.size, MAX_MANIFEST_SCAN_BYTES);
557
+ const start = stat.size - length;
558
+ const buffer = Buffer.allocUnsafe(length);
559
+ let total = 0;
560
+ while (total < length) {
561
+ const { bytesRead } = await handle.read(buffer, total, length - total, start + total);
562
+ if (bytesRead === 0)
563
+ break;
564
+ total += bytesRead;
565
+ }
566
+ let window = buffer.subarray(0, total);
567
+ if (start > 0) {
568
+ const newline = window.indexOf(0x0a);
569
+ if (newline < 0)
570
+ return [];
571
+ window = window.subarray(newline + 1);
572
+ }
573
+ raw = window.toString("utf8");
574
+ }
575
+ finally {
576
+ await handle.close();
577
+ }
493
578
  return raw
494
579
  .split("\n")
495
- .filter(Boolean)
580
+ .filter((line) => line.length > 0 && Buffer.byteLength(line, "utf8") <= MAX_MANIFEST_LINE_BYTES)
496
581
  .map((line) => {
497
582
  try {
498
- return JSON.parse(line);
583
+ return parseManifestRecord(JSON.parse(line));
499
584
  }
500
585
  catch {
501
586
  return null;
502
587
  }
503
588
  })
504
- .filter((record) => !!record);
589
+ .filter((record) => !!record)
590
+ .slice(-MAX_MANIFEST_RECORDS);
505
591
  }
506
592
  catch {
507
593
  return [];
508
594
  }
509
595
  }
596
+ function parseManifestRecord(value) {
597
+ if (!value || typeof value !== "object" || Array.isArray(value))
598
+ return null;
599
+ const raw = value;
600
+ if ((raw.event !== "staged" &&
601
+ raw.event !== "sent" &&
602
+ raw.event !== "removedFromDraft" &&
603
+ raw.event !== "cleanup") ||
604
+ typeof raw.sessionId !== "string") {
605
+ return null;
606
+ }
607
+ try {
608
+ assertSafeSessionId(raw.sessionId);
609
+ }
610
+ catch {
611
+ return null;
612
+ }
613
+ for (const field of [
614
+ "id",
615
+ "path",
616
+ "absPath",
617
+ "relPath",
618
+ "mime",
619
+ "sha256",
620
+ "originalName",
621
+ ]) {
622
+ const item = raw[field];
623
+ if (item !== undefined && (typeof item !== "string" || item.length > 32_768 || item.includes("\0"))) {
624
+ return null;
625
+ }
626
+ }
627
+ if (raw.kind !== undefined &&
628
+ raw.kind !== "image" &&
629
+ raw.kind !== "file" &&
630
+ raw.kind !== "directory") {
631
+ return null;
632
+ }
633
+ if (raw.origin !== undefined && !ATTACHMENT_ORIGINS.has(raw.origin)) {
634
+ return null;
635
+ }
636
+ for (const field of ["size", "createdAt", "sentAt", "removedAt", "cleanedAt"]) {
637
+ const item = raw[field];
638
+ if (item !== undefined && (typeof item !== "number" || !Number.isFinite(item)))
639
+ return null;
640
+ }
641
+ return raw;
642
+ }
510
643
  async function listSafeSessionDirs(root) {
511
644
  let entries;
512
645
  try {
@@ -517,6 +650,7 @@ async function listSafeSessionDirs(root) {
517
650
  }
518
651
  return entries
519
652
  .filter((entry) => entry.isDirectory())
653
+ .slice(0, MAX_ATTACHMENT_SESSION_DIRS)
520
654
  .map((entry) => entry.name)
521
655
  .filter((name) => {
522
656
  try {
@@ -1,3 +1,5 @@
1
+ export declare const MIN_ACCESS_PASSCODE_LENGTH = 4;
2
+ export declare const MAX_ACCESS_PASSCODE_LENGTH = 256;
1
3
  /** Minimal structural views of node:http req/res so `gate` is unit-testable
2
4
  * without a real server. */
3
5
  interface GateRequest {
@@ -43,6 +45,7 @@ export declare class AccessPasscode {
43
45
  private readonly lockoutMs;
44
46
  private readonly tokenMaxAgeMs;
45
47
  private failures;
48
+ private malformed;
46
49
  private lockedUntil;
47
50
  constructor(opts: AccessPasscodeOptions);
48
51
  isSet(): boolean;
@@ -71,6 +74,16 @@ export declare class AccessPasscode {
71
74
  * write). Used by the WS gate which cannot send a challenge page. */
72
75
  allows(req: GateRequest): boolean;
73
76
  private isLocked;
77
+ private recordFailure;
78
+ /**
79
+ * Count a request whose passcode could not even be a passcode.
80
+ *
81
+ * Kept separate from `recordFailure` so junk traffic cannot spend the
82
+ * credential-guessing budget and lock out the owner. The budget is a large
83
+ * multiple of `maxAttempts`: a determined flood is still throttled, but the
84
+ * handful of malformed submissions a real client produces never is.
85
+ */
86
+ private recordMalformed;
74
87
  private issueToken;
75
88
  private sign;
76
89
  private hashPasscode;
@@ -1,11 +1,17 @@
1
1
  import { createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  const DEFAULT_MAX_ATTEMPTS = 5;
5
+ /** Malformed submissions allowed per `maxAttempts` before throttling kicks in. */
6
+ const MALFORMED_ATTEMPT_MULTIPLIER = 20;
5
7
  const DEFAULT_LOCKOUT_MS = 60_000;
6
8
  const DEFAULT_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
7
9
  const COOKIE_NAME = "cs_access";
8
10
  const SCRYPT_KEYLEN = 32;
11
+ export const MIN_ACCESS_PASSCODE_LENGTH = 4;
12
+ export const MAX_ACCESS_PASSCODE_LENGTH = 256;
13
+ const MAX_REMEMBER_TOKEN_LENGTH = 128;
14
+ const MAX_ACCESS_RECORD_BYTES = 4 * 1024;
9
15
  /**
10
16
  * The public-tunnel access gate. A single shared passcode (set by the desktop
11
17
  * user) guards the tunnel; a phone that enters it correctly receives a signed
@@ -25,8 +31,15 @@ export class AccessPasscode {
25
31
  lockoutMs;
26
32
  tokenMaxAgeMs;
27
33
  failures = 0;
34
+ malformed = 0;
28
35
  lockedUntil = 0;
29
36
  constructor(opts) {
37
+ if (!opts || typeof opts.filePath !== "string" || !opts.filePath) {
38
+ throw new Error("Access passcode filePath is required");
39
+ }
40
+ assertPositiveSafeInteger("maxAttempts", opts.maxAttempts);
41
+ assertPositiveSafeInteger("lockoutMs", opts.lockoutMs);
42
+ assertPositiveSafeInteger("tokenMaxAgeMs", opts.tokenMaxAgeMs);
30
43
  this.filePath = opts.filePath;
31
44
  this.now = opts.now ?? Date.now;
32
45
  this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
@@ -38,6 +51,9 @@ export class AccessPasscode {
38
51
  }
39
52
  /** Set (or rotate) the passcode. Rotating `secret` invalidates old tokens. */
40
53
  set(passcode) {
54
+ if (!isValidPasscode(passcode)) {
55
+ throw new Error(`Access passcode must be ${MIN_ACCESS_PASSCODE_LENGTH}-${MAX_ACCESS_PASSCODE_LENGTH} characters`);
56
+ }
41
57
  const salt = randomBytes(16).toString("hex");
42
58
  const hash = this.hashPasscode(passcode, salt);
43
59
  const secret = randomBytes(32).toString("hex");
@@ -56,12 +72,21 @@ export class AccessPasscode {
56
72
  return null;
57
73
  if (this.isLocked())
58
74
  return null;
75
+ // Malformed input is rejected before the expensive hash, but it must NOT
76
+ // spend the credential-guessing budget. A value outside the length bounds
77
+ // is not a candidate passcode — no 4..256 check can be "guessed" by a
78
+ // 1-character string — so counting it deterred no attack while handing an
79
+ // unauthenticated caller a free lockout: five `?passcode=1` requests locked
80
+ // the legitimate owner out for the whole window, repeatable indefinitely.
81
+ // It still costs an attempt against a much larger malformed-input budget,
82
+ // so a script hammering junk is not free either.
83
+ if (!isValidPasscode(passcode)) {
84
+ this.recordMalformed();
85
+ return null;
86
+ }
59
87
  const candidate = this.hashPasscode(passcode, record.salt);
60
88
  if (!safeEqualHex(candidate, record.hash)) {
61
- this.failures++;
62
- if (this.failures >= this.maxAttempts) {
63
- this.lockedUntil = this.now() + this.lockoutMs;
64
- }
89
+ this.recordFailure();
65
90
  return null;
66
91
  }
67
92
  this.failures = 0;
@@ -73,6 +98,11 @@ export class AccessPasscode {
73
98
  * issue timestamp must be within the validity window (so a stolen/synced
74
99
  * cookie cannot grant access indefinitely). */
75
100
  verifyToken(token) {
101
+ if (typeof token !== "string" ||
102
+ token.length === 0 ||
103
+ token.length > MAX_REMEMBER_TOKEN_LENGTH) {
104
+ return false;
105
+ }
76
106
  const record = this.read();
77
107
  if (!record)
78
108
  return false;
@@ -89,7 +119,8 @@ export class AccessPasscode {
89
119
  const issuedAt = Number(payload.slice(payload.indexOf(".") + 1));
90
120
  if (!Number.isFinite(issuedAt))
91
121
  return false;
92
- return this.now() - issuedAt <= this.tokenMaxAgeMs;
122
+ const age = this.now() - issuedAt;
123
+ return age >= 0 && age <= this.tokenMaxAgeMs;
93
124
  }
94
125
  /**
95
126
  * HTTP gate. Returns true (allow) when the request carries a valid remember
@@ -132,6 +163,10 @@ export class AccessPasscode {
132
163
  res.end();
133
164
  return false;
134
165
  }
166
+ // Non-HTML/no-JS callers still use the historical GET fallback, so the
167
+ // passcode may appear in reverse-proxy access logs. Browser clients use
168
+ // the header-based fetch in challengePage and are redirected to a clean
169
+ // URL; deployments should redact the `passcode` query parameter in logs.
135
170
  return true;
136
171
  }
137
172
  wrongAttempt = true;
@@ -172,6 +207,27 @@ export class AccessPasscode {
172
207
  }
173
208
  return true;
174
209
  }
210
+ recordFailure() {
211
+ this.failures++;
212
+ if (this.failures >= this.maxAttempts) {
213
+ this.lockedUntil = this.now() + this.lockoutMs;
214
+ }
215
+ }
216
+ /**
217
+ * Count a request whose passcode could not even be a passcode.
218
+ *
219
+ * Kept separate from `recordFailure` so junk traffic cannot spend the
220
+ * credential-guessing budget and lock out the owner. The budget is a large
221
+ * multiple of `maxAttempts`: a determined flood is still throttled, but the
222
+ * handful of malformed submissions a real client produces never is.
223
+ */
224
+ recordMalformed() {
225
+ this.malformed++;
226
+ if (this.malformed >= this.maxAttempts * MALFORMED_ATTEMPT_MULTIPLIER) {
227
+ this.malformed = 0;
228
+ this.lockedUntil = this.now() + this.lockoutMs;
229
+ }
230
+ }
175
231
  issueToken(record) {
176
232
  const payload = `${randomBytes(12).toString("hex")}.${this.now()}`;
177
233
  const sig = this.sign(payload, record.secret);
@@ -186,20 +242,94 @@ export class AccessPasscode {
186
242
  read() {
187
243
  if (!existsSync(this.filePath))
188
244
  return undefined;
245
+ let fd;
189
246
  try {
190
- const parsed = JSON.parse(readFileSync(this.filePath, "utf-8"));
191
- if (parsed.hash && parsed.salt && parsed.secret) {
192
- return { hash: parsed.hash, salt: parsed.salt, secret: parsed.secret };
247
+ fd = openSync(this.filePath, "r");
248
+ const stat = fstatSync(fd);
249
+ if (!stat.isFile() || stat.size > MAX_ACCESS_RECORD_BYTES)
250
+ return undefined;
251
+ const buffer = Buffer.allocUnsafe(MAX_ACCESS_RECORD_BYTES + 1);
252
+ let total = 0;
253
+ while (total < buffer.byteLength) {
254
+ const count = readSync(fd, buffer, total, buffer.byteLength - total, total);
255
+ if (count === 0)
256
+ break;
257
+ total += count;
258
+ }
259
+ if (total > MAX_ACCESS_RECORD_BYTES)
260
+ return undefined;
261
+ const parsed = JSON.parse(buffer.subarray(0, total).toString("utf8"));
262
+ if (isAccessRecord(parsed)) {
263
+ return parsed;
193
264
  }
194
265
  return undefined;
195
266
  }
196
267
  catch {
197
268
  return undefined;
198
269
  }
270
+ finally {
271
+ if (fd !== undefined)
272
+ closeSync(fd);
273
+ }
199
274
  }
200
275
  write(record) {
201
- mkdirSync(dirname(this.filePath), { recursive: true });
202
- writeFileSync(this.filePath, JSON.stringify(record, null, 2), "utf-8");
276
+ const parent = dirname(this.filePath);
277
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
278
+ try {
279
+ chmodSync(parent, 0o700);
280
+ }
281
+ catch {
282
+ // Best effort on platforms/filesystems without POSIX permissions.
283
+ }
284
+ // access.json contains the HMAC signing secret. A direct overwrite can
285
+ // truncate the only valid record if the process crashes mid-write, and the
286
+ // default umask can make a newly-created file group/world-readable.
287
+ const tempPath = `${this.filePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
288
+ try {
289
+ writeFileSync(tempPath, JSON.stringify(record, null, 2), {
290
+ encoding: "utf-8",
291
+ flag: "wx",
292
+ mode: 0o600,
293
+ });
294
+ renameSync(tempPath, this.filePath);
295
+ try {
296
+ chmodSync(this.filePath, 0o600);
297
+ }
298
+ catch {
299
+ // Best effort on platforms/filesystems without POSIX permissions.
300
+ }
301
+ }
302
+ finally {
303
+ try {
304
+ rmSync(tempPath, { force: true });
305
+ }
306
+ catch {
307
+ // Never hide the original write/rename error with cleanup failure.
308
+ }
309
+ }
310
+ }
311
+ }
312
+ function isAccessRecord(value) {
313
+ if (!value || typeof value !== "object" || Array.isArray(value))
314
+ return false;
315
+ const record = value;
316
+ return (typeof record.hash === "string" &&
317
+ /^[0-9a-f]{64}$/i.test(record.hash) &&
318
+ typeof record.salt === "string" &&
319
+ /^[0-9a-f]{32}$/i.test(record.salt) &&
320
+ typeof record.secret === "string" &&
321
+ /^[0-9a-f]{64}$/i.test(record.secret));
322
+ }
323
+ function isValidPasscode(value) {
324
+ return (typeof value === "string" &&
325
+ value.length >= MIN_ACCESS_PASSCODE_LENGTH &&
326
+ value.length <= MAX_ACCESS_PASSCODE_LENGTH);
327
+ }
328
+ function assertPositiveSafeInteger(name, value) {
329
+ if (value === undefined)
330
+ return;
331
+ if (!Number.isSafeInteger(value) || value <= 0) {
332
+ throw new Error(`${name} must be a positive safe integer`);
203
333
  }
204
334
  }
205
335
  function readCookie(cookieHeader, name) {
@@ -288,6 +418,10 @@ function challengePage(url, wrong, locked) {
288
418
  : wrong
289
419
  ? "口令不正确,请重试。"
290
420
  : "请输入访问口令以继续。";
421
+ const cleanLocation = JSON.stringify(path + (params.size ? `?${params.toString()}` : ""))
422
+ .replace(/</g, "\\u003c")
423
+ .replace(/\u2028/g, "\\u2028")
424
+ .replace(/\u2029/g, "\\u2029");
291
425
  return `<!doctype html>
292
426
  <html lang="zh"><head><meta charset="utf-8">
293
427
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -311,7 +445,7 @@ function challengePage(url, wrong, locked) {
311
445
  <h1>手机遥控</h1>
312
446
  <p class="${wrong || locked ? "err" : ""}">${note}</p>
313
447
  ${hidden}
314
- <input id="passcode" type="password" name="passcode" placeholder="访问口令" autofocus required ${locked ? "disabled" : ""}>
448
+ <input id="passcode" type="password" name="passcode" placeholder="访问口令" minlength="${MIN_ACCESS_PASSCODE_LENGTH}" maxlength="${MAX_ACCESS_PASSCODE_LENGTH}" autofocus required ${locked ? "disabled" : ""}>
315
449
  <button type="submit" ${locked ? "disabled" : ""}>进入</button>
316
450
  </form>
317
451
  <script>
@@ -333,7 +467,7 @@ function challengePage(url, wrong, locked) {
333
467
  if (input) input.value = "";
334
468
  return;
335
469
  }
336
- window.location.replace(${JSON.stringify(path + (params.size ? `?${params.toString()}` : ""))});
470
+ window.location.replace(${cleanLocation});
337
471
  } catch {
338
472
  if (note) note.textContent = "连接失败,请重试。";
339
473
  if (note) note.className = "err";
@@ -1,10 +1,7 @@
1
1
  import { type InputAttachmentMeta } from "../attachment-service.js";
2
2
  import type { MobileAttachmentSummary, MobileImageAttachment } from "./types.js";
3
3
  import type { ClaimedMobileUpload, MobileUploadService } from "./mobile-upload-service.js";
4
- export declare const MAX_MOBILE_ATTACHMENTS = 4;
5
- export declare const MAX_MOBILE_INLINE_IMAGE_BYTES: number;
6
- export declare const MAX_MOBILE_INLINE_TOTAL_BYTES: number;
7
- export declare const MAX_MOBILE_ATTACHMENT_TOTAL_BYTES: number;
4
+ export { MAX_MOBILE_ATTACHMENTS, MAX_MOBILE_ATTACHMENT_TOTAL_BYTES, MAX_MOBILE_INLINE_IMAGE_BYTES, MAX_MOBILE_INLINE_TOTAL_BYTES, } from "./mobile-limits.js";
8
5
  interface MaterializeInput {
9
6
  deviceId: string;
10
7
  cwd: string;
@@ -18,4 +15,3 @@ export declare function materializeMobileAttachments(input: MaterializeInput): P
18
15
  claims: ClaimedMobileUpload[];
19
16
  summaries: MobileAttachmentSummary[];
20
17
  }>;
21
- export {};
@@ -1,8 +1,6 @@
1
1
  import { stageImageBytes } from "../attachment-service.js";
2
- export const MAX_MOBILE_ATTACHMENTS = 4;
3
- export const MAX_MOBILE_INLINE_IMAGE_BYTES = 256 * 1024;
4
- export const MAX_MOBILE_INLINE_TOTAL_BYTES = 512 * 1024;
5
- export const MAX_MOBILE_ATTACHMENT_TOTAL_BYTES = 20 * 1024 * 1024;
2
+ import { MAX_MOBILE_ATTACHMENTS, MAX_MOBILE_ATTACHMENT_TOTAL_BYTES, MAX_MOBILE_INLINE_IMAGE_BYTES, MAX_MOBILE_INLINE_TOTAL_BYTES, } from "./mobile-limits.js";
3
+ export { MAX_MOBILE_ATTACHMENTS, MAX_MOBILE_ATTACHMENT_TOTAL_BYTES, MAX_MOBILE_INLINE_IMAGE_BYTES, MAX_MOBILE_INLINE_TOTAL_BYTES, } from "./mobile-limits.js";
6
4
  const ALLOWED_MIMES = new Set([
7
5
  "image/png",
8
6
  "image/jpeg",
@@ -0,0 +1,3 @@
1
+ import type { MobileClientEvent } from "./types.js";
2
+ /** Runtime validation at the WebSocket boundary; TypeScript types do not validate JSON. */
3
+ export declare function parseMobileClientEvent(value: unknown): MobileClientEvent | undefined;