@triflux/remote 10.78.0 → 10.80.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.
@@ -1,9 +1,14 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
2
+ import { randomBytes, randomUUID } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import net from "node:net";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import {
8
+ assertWindowsAcl,
9
+ readWindowsAcls,
10
+ readWindowsPeerState,
11
+ } from "../../scripts/lib/native-peer-windows.mjs";
7
12
  import { JsonRpcWsUdsClient } from "../workers/lib/jsonrpc-ws-uds.mjs";
8
13
 
9
14
  const WAKE =
@@ -103,9 +108,23 @@ export async function sendCodexPeer({ client, threadId, message }) {
103
108
  export function assertPrivateFile(
104
109
  file,
105
110
  kind,
106
- { socket = false, privateMode = true } = {},
111
+ {
112
+ socket = false,
113
+ privateMode = true,
114
+ platform = process.platform,
115
+ fs: io = fs,
116
+ acls,
117
+ } = {},
107
118
  ) {
108
- const meta = fs.lstatSync(file);
119
+ const meta = io.lstatSync(file);
120
+ if (platform === "win32") {
121
+ if (meta.isSymbolicLink() || !meta.isFile())
122
+ throw new Error(`Unsafe ${kind}`);
123
+ assertWindowsAcl((acls || readWindowsAcls([file])).get(file), kind, {
124
+ privateMode,
125
+ });
126
+ return meta;
127
+ }
109
128
  if (
110
129
  meta.isSymbolicLink() ||
111
130
  meta.uid !== process.getuid() ||
@@ -133,8 +152,11 @@ export async function deliverCodex({
133
152
  message,
134
153
  home = os.homedir(),
135
154
  env = process.env,
155
+ platform = process.platform,
136
156
  clientFactory = (socketPath) => new JsonRpcWsUdsClient({ socketPath }),
137
157
  }) {
158
+ if (platform === "win32")
159
+ throw new Error("Windows 에서는 Codex 대상을 아직 지원하지 않는다");
138
160
  const client = clientFactory(resolveCodexSocket(home, env));
139
161
  try {
140
162
  await client.connect();
@@ -150,6 +172,8 @@ export async function deliverCodex({
150
172
  }
151
173
 
152
174
  function actualProcStart(pid) {
175
+ if (!Number.isSafeInteger(pid) || pid <= 0)
176
+ throw new Error("Invalid process id");
153
177
  return execFileSync("/bin/ps", ["-p", String(pid), "-o", "lstart="], {
154
178
  encoding: "utf8",
155
179
  timeout: 2000,
@@ -159,26 +183,79 @@ function actualProcStart(pid) {
159
183
  .replace(/\s+/g, " ");
160
184
  }
161
185
 
186
+ function readClaudeRegistry({ home, platform, fs: io, readWindowsState }) {
187
+ const paths = platform === "win32" ? path.win32 : path;
188
+ const dir = paths.join(home, ".claude", "sessions");
189
+ const names = io.readdirSync(dir);
190
+ let acls, procStarts;
191
+ if (platform === "win32") {
192
+ const parents = [home, paths.join(home, ".claude"), dir];
193
+ ({ acls, procStarts } = readWindowsState(
194
+ [
195
+ ...parents,
196
+ ...names
197
+ .filter(
198
+ (name) => /^\d+\.json$/.test(name) || /^\d+\..*\.key$/.test(name),
199
+ )
200
+ .map((name) => paths.join(dir, name)),
201
+ ],
202
+ {
203
+ pids: names
204
+ .filter((name) => /^\d+\.json$/.test(name))
205
+ .map((name) => Number(name.slice(0, -5)))
206
+ .filter((pid) => Number.isSafeInteger(pid) && pid > 0),
207
+ },
208
+ ));
209
+ for (const parent of parents) {
210
+ const meta = io.lstatSync(parent);
211
+ if (!meta.isDirectory() || meta.isSymbolicLink())
212
+ throw new Error("Unsafe Claude sessions directory");
213
+ assertWindowsAcl(acls.get(parent), "Claude sessions directory", {
214
+ privateMode: false,
215
+ });
216
+ }
217
+ }
218
+ const records = [];
219
+ for (const name of names.filter((name) => /^\d+\.json$/.test(name))) {
220
+ const file = paths.join(dir, name);
221
+ try {
222
+ assertPrivateFile(file, "Claude session", {
223
+ privateMode: false,
224
+ platform,
225
+ fs: io,
226
+ acls,
227
+ });
228
+ const record = JSON.parse(io.readFileSync(file, "utf8"));
229
+ if (
230
+ `${record.pid}.json` === name &&
231
+ typeof record.sessionId === "string" &&
232
+ record.sessionId
233
+ )
234
+ records.push(record);
235
+ } catch {}
236
+ }
237
+ return { dir, names, records, acls, procStarts };
238
+ }
239
+
162
240
  export function resolveClaudeReceiver({
163
241
  sessionId,
164
242
  home = os.homedir(),
165
- getProcStart = actualProcStart,
243
+ platform = process.platform,
244
+ hostname = os.hostname(),
245
+ getProcStart,
246
+ fs: io = fs,
247
+ readWindowsState = readWindowsPeerState,
248
+ registry = readClaudeRegistry({
249
+ home,
250
+ platform,
251
+ fs: io,
252
+ readWindowsState,
253
+ }),
166
254
  }) {
167
- const dir = path.join(home, ".claude", "sessions");
168
- const records = fs.readdirSync(dir).filter((x) => /^\d+\.json$/.test(x));
169
- const matches = [];
170
- for (const name of records) {
171
- const file = path.join(dir, name);
172
- let record;
173
- try {
174
- assertPrivateFile(file, "Claude session", { privateMode: false });
175
- record = JSON.parse(fs.readFileSync(file, "utf8"));
176
- } catch {
177
- continue;
178
- }
179
- if (record.sessionId === sessionId && `${record.pid}.json` === name)
180
- matches.push(record);
181
- }
255
+ const { dir, names, records, acls, procStarts } = registry;
256
+ getProcStart ??= (pid) =>
257
+ platform === "win32" ? procStarts.get(pid) : actualProcStart(pid);
258
+ const matches = records.filter((record) => record.sessionId === sessionId);
182
259
  if (matches.length !== 1)
183
260
  throw new Error("Claude session identity missing or ambiguous");
184
261
  const record = matches[0];
@@ -187,30 +264,53 @@ export function resolveClaudeReceiver({
187
264
  "Claude session is a parked parent; use its live continuation name",
188
265
  );
189
266
  const pid = Number(record.pid);
267
+ const windows = platform === "win32";
268
+ const normalizeStart = (start) =>
269
+ typeof start === "string"
270
+ ? windows
271
+ ? start
272
+ : start.replace(/\s+/g, " ")
273
+ : null;
274
+ const domainMatches = (domain) =>
275
+ windows
276
+ ? typeof domain === "string" &&
277
+ domain.toLowerCase() === `win32:${hostname}`.toLowerCase()
278
+ : domain === platform;
190
279
  if (
191
280
  !Number.isSafeInteger(pid) ||
192
281
  pid <= 0 ||
193
- record.pidDomain !== process.platform ||
194
- record.procStart?.replace(/\s+/g, " ") !== getProcStart(pid)
282
+ !domainMatches(record.pidDomain) ||
283
+ (windows && !/^\d+$/.test(record.procStart)) ||
284
+ normalizeStart(record.procStart) !== getProcStart(pid)
195
285
  )
196
286
  throw new Error("Claude process identity stale");
197
- const keyFiles = fs
198
- .readdirSync(dir)
199
- .filter((x) => x.startsWith(`${pid}.`) && x.endsWith(".key"));
287
+ const keyFiles = names.filter((name) =>
288
+ windows
289
+ ? new RegExp(`^${pid}\\.[0-9a-f]{64}\\.key$`).test(name)
290
+ : name.startsWith(`${pid}.`) && name.endsWith(".key"),
291
+ );
200
292
  if (keyFiles.length !== 1)
201
293
  throw new Error("Claude peer key missing or ambiguous");
202
- const keyFile = path.join(dir, keyFiles[0]);
203
- assertPrivateFile(keyFile, "Claude peer key");
204
- const key = JSON.parse(fs.readFileSync(keyFile, "utf8"));
294
+ const keyFile = (windows ? path.win32 : path).join(dir, keyFiles[0]);
295
+ assertPrivateFile(keyFile, "Claude peer key", { platform, fs: io, acls });
296
+ const key = JSON.parse(io.readFileSync(keyFile, "utf8"));
205
297
  if (
206
- key.procStart?.replace(/\s+/g, " ") !==
207
- record.procStart?.replace(/\s+/g, " ") ||
208
- key.pidDomain !== record.pidDomain ||
298
+ normalizeStart(windows ? key.procStartFt : key.procStart) !==
299
+ normalizeStart(record.procStart) ||
300
+ !domainMatches(key.pidDomain) ||
209
301
  typeof key.peerToken !== "string" ||
210
302
  !key.peerToken
211
303
  )
212
304
  throw new Error("Claude peer key identity mismatch");
213
305
  const socketPath = record.messagingSocketPath;
306
+ if (windows) {
307
+ if (
308
+ typeof socketPath !== "string" ||
309
+ !/^\\\\\.\\pipe\\LOCAL\\cc-msg-[0-9a-f]{32}$/.test(socketPath)
310
+ )
311
+ throw new Error("Invalid Claude peer socket");
312
+ return { socketPath, peerToken: key.peerToken };
313
+ }
214
314
  if (
215
315
  typeof socketPath !== "string" ||
216
316
  !path.isAbsolute(socketPath) ||
@@ -223,19 +323,22 @@ export function resolveClaudeReceiver({
223
323
 
224
324
  export function resolveClaudeRegistryTarget(
225
325
  query,
226
- { home = os.homedir(), getProcStart = actualProcStart } = {},
326
+ {
327
+ home = os.homedir(),
328
+ platform = process.platform,
329
+ hostname = os.hostname(),
330
+ getProcStart,
331
+ fs: io = fs,
332
+ readWindowsState = readWindowsPeerState,
333
+ } = {},
227
334
  ) {
228
- const dir = path.join(home, ".claude", "sessions");
229
- const records = [];
230
- for (const name of fs.readdirSync(dir).filter((x) => /^\d+\.json$/.test(x))) {
231
- const file = path.join(dir, name);
232
- try {
233
- assertPrivateFile(file, "Claude session", { privateMode: false });
234
- const record = JSON.parse(fs.readFileSync(file, "utf8"));
235
- if (`${record.pid}.json` === name && record.sessionId)
236
- records.push(record);
237
- } catch {}
238
- }
335
+ const registry = readClaudeRegistry({
336
+ home,
337
+ platform,
338
+ fs: io,
339
+ readWindowsState,
340
+ });
341
+ const { records } = registry;
239
342
  const exactId = records.filter(
240
343
  (x) => x.sessionId.toLowerCase() === query.toLowerCase(),
241
344
  );
@@ -254,35 +357,51 @@ export function resolveClaudeRegistryTarget(
254
357
  (x) =>
255
358
  !x.parkedJobId && (!parked.length || x.jobId === parked[0].parkedJobId),
256
359
  );
257
- const live = eligible.filter((x) => {
360
+ const live = [];
361
+ for (const x of eligible) {
258
362
  try {
259
- resolveClaudeReceiver({ sessionId: x.sessionId, home, getProcStart });
260
- return true;
261
- } catch {
262
- return false;
263
- }
264
- });
363
+ const receiver = resolveClaudeReceiver({
364
+ sessionId: x.sessionId,
365
+ home,
366
+ platform,
367
+ hostname,
368
+ getProcStart,
369
+ fs: io,
370
+ registry,
371
+ });
372
+ live.push({ ...x, receiver });
373
+ } catch {}
374
+ }
265
375
  if (live.length !== 1)
266
376
  throw new Error(
267
377
  live.length
268
378
  ? "Ambiguous live Claude targets"
269
379
  : "No live Claude continuation; parked parent is not a delivery target",
270
380
  );
271
- return { sessionId: live[0].sessionId, name: live[0].name };
381
+ return {
382
+ sessionId: live[0].sessionId,
383
+ name: live[0].name,
384
+ receiver: live[0].receiver,
385
+ };
272
386
  }
273
387
 
274
388
  export async function deliverClaude({
275
389
  sessionId,
276
390
  message,
277
391
  home = os.homedir(),
278
- receiver = resolveClaudeReceiver({ sessionId, home }),
392
+ platform = process.platform,
393
+ receiver = resolveClaudeReceiver({ sessionId, home, platform }),
394
+ net: transport = net,
279
395
  socketDir = path.dirname(receiver.socketPath),
280
396
  // 150ms 유지 후 리드의 peer 수신·응답을 확인해 8초 고정 대기를 줄였다.
281
397
  holdMs = 150,
282
398
  }) {
283
- const own = path.join(socketDir, `${process.pid}.sock`);
399
+ const own =
400
+ platform === "win32"
401
+ ? `\\\\.\\pipe\\LOCAL\\cc-msg-${randomBytes(16).toString("hex")}`
402
+ : path.join(socketDir, `${process.pid}.sock`);
284
403
  // 이 일회성 송신자는 회신을 라우팅하지 않는다. 열린 reply 소켓을 즉시 닫는다.
285
- const server = net.createServer((connection) => connection.destroy());
404
+ const server = transport.createServer((connection) => connection.destroy());
286
405
  await new Promise((resolve, reject) => {
287
406
  server.once("error", reject);
288
407
  server.listen(own, () => {
@@ -290,8 +409,8 @@ export async function deliverClaude({
290
409
  resolve();
291
410
  });
292
411
  });
293
- const inode = fs.lstatSync(own).ino;
294
- fs.chmodSync(own, 0o600);
412
+ const inode = platform === "win32" ? null : fs.lstatSync(own).ino;
413
+ if (platform !== "win32") fs.chmodSync(own, 0o600);
295
414
  const from = `uds:${own}`;
296
415
  const safe = message.replaceAll("&", "&amp;").replaceAll("<", "&lt;");
297
416
  const frame = {
@@ -307,7 +426,7 @@ export async function deliverClaude({
307
426
  try {
308
427
  try {
309
428
  await new Promise((resolve, reject) => {
310
- const conn = net.connect(receiver.socketPath);
429
+ const conn = transport.connect(receiver.socketPath);
311
430
  conn.setTimeout(5000, () =>
312
431
  conn.destroy(new Error("Claude peer connect timed out")),
313
432
  );
@@ -338,7 +457,8 @@ export async function deliverClaude({
338
457
  } finally {
339
458
  server.close();
340
459
  try {
341
- if (fs.lstatSync(own).ino === inode) fs.unlinkSync(own);
460
+ if (platform !== "win32" && fs.lstatSync(own).ino === inode)
461
+ fs.unlinkSync(own);
342
462
  } catch {}
343
463
  }
344
464
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/remote",
3
- "version": "10.78.0",
3
+ "version": "10.80.0",
4
4
  "description": "triflux remote — team mode, psmux, MCP workers.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -125,6 +125,7 @@ function trustedPackageTree(packageRoot, nodePath, tmuxPath) {
125
125
  const meta = existing(current);
126
126
  if (
127
127
  !meta ||
128
+ path.basename(current).toLowerCase() === ".git" ||
128
129
  meta.isSymbolicLink() ||
129
130
  ![0, process.getuid()].includes(meta.uid) ||
130
131
  (meta.mode & 0o002) !== 0
@@ -232,8 +233,8 @@ export function inspectPeerPermission({
232
233
  tmuxPath,
233
234
  platform = process.platform,
234
235
  } = {}) {
236
+ if (platform === "win32") return { status: "unsupported" };
235
237
  const plan = peerPermissionPlan({ home, packageRoot, nodePath, tmuxPath });
236
- if (platform === "win32") return { status: "unsupported", plan };
237
238
  const effectiveNodePath = nodePath || resolveStableNodeBin();
238
239
  if (
239
240
  !trustedPackageTree(
@@ -334,12 +335,9 @@ export function installPeerPermission({
334
335
  platform,
335
336
  });
336
337
  const { plan, status } = inspected;
338
+ if (status === "unsupported") return { status, changed: false };
337
339
  if (status === "approved") return { status: "approved", changed: false };
338
- if (
339
- status === "conflict" ||
340
- status === "unavailable" ||
341
- status === "unsupported"
342
- )
340
+ if (status === "conflict" || status === "unavailable")
343
341
  return {
344
342
  status,
345
343
  changed: false,
@@ -502,8 +500,10 @@ export async function offerPeerPermission({
502
500
  tmuxPath,
503
501
  platform,
504
502
  });
505
- if (state.status === "unsupported")
503
+ if (state.status === "unsupported") {
504
+ notice("Windows 는 자동 승인 없이 tfx-peer 를 직접 실행한다");
506
505
  return { status: "unsupported", changed: false };
506
+ }
507
507
  if (state.status === "approved")
508
508
  return { status: "approved", changed: false };
509
509
  if (fromUpdate) {
@@ -0,0 +1,137 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ const WINDOWS_POWERSHELL = String.raw`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`;
4
+
5
+ function windowsPowerShellEnv() {
6
+ // PowerShell 7의 모듈 경로를 빼서 Windows PowerShell 5.1이 기본값을 복원하게 한다.
7
+ return Object.fromEntries(
8
+ Object.entries(process.env).filter(([key]) =>
9
+ ["systemroot", "windir", "temp", "tmp", "userprofile"].includes(
10
+ key.toLowerCase(),
11
+ ),
12
+ ),
13
+ );
14
+ }
15
+
16
+ export function readWindowsPeerState(
17
+ files,
18
+ { pids = [], execFile = execFileSync } = {},
19
+ ) {
20
+ if (!files.length && !pids.length)
21
+ return { acls: new Map(), procStarts: new Map() };
22
+ const script = `
23
+ $ErrorActionPreference = 'Stop'
24
+ [Console]::InputEncoding = [Text.UTF8Encoding]::new($false)
25
+ [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
26
+ $request = ConvertFrom-Json ([Console]::In.ReadToEnd())
27
+ $userSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
28
+ $acls = @(foreach ($p in $request.paths) {
29
+ try {
30
+ $item = if ([System.IO.Directory]::Exists($p)) { [System.IO.DirectoryInfo]::new($p) } else { [System.IO.FileInfo]::new($p) }
31
+ $acl = $item.GetAccessControl()
32
+ [pscustomobject]@{
33
+ path = $p
34
+ userSid = $userSid
35
+ ownerSid = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
36
+ reparsePoint = [bool]($item.Attributes -band [IO.FileAttributes]::ReparsePoint)
37
+ access = @(foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) {
38
+ [pscustomobject]@{
39
+ sid = $rule.IdentityReference.Value
40
+ rights = [long]$rule.FileSystemRights
41
+ type = $rule.AccessControlType.ToString()
42
+ inheritanceFlags = [int]$rule.InheritanceFlags
43
+ propagationFlags = [int]$rule.PropagationFlags
44
+ }
45
+ })
46
+ }
47
+ } catch {
48
+ [pscustomobject]@{ path = $p; error = $_.Exception.Message }
49
+ }
50
+ })
51
+ $processes = @(foreach ($processId in $request.pids) {
52
+ try {
53
+ $start = [System.Diagnostics.Process]::GetProcessById($processId).StartTime.ToFileTimeUtc().ToString([Globalization.CultureInfo]::InvariantCulture)
54
+ [pscustomobject]@{ pid = $processId; procStart = $start }
55
+ } catch {
56
+ [pscustomobject]@{ pid = $processId; error = $_.Exception.Message }
57
+ }
58
+ })
59
+ ConvertTo-Json -InputObject @{ acls = $acls; processes = $processes } -Depth 5 -Compress
60
+ `;
61
+ const rows = JSON.parse(
62
+ execFile(
63
+ WINDOWS_POWERSHELL,
64
+ ["-NoProfile", "-NonInteractive", "-Command", script],
65
+ {
66
+ input: JSON.stringify({ paths: files, pids }),
67
+ encoding: "utf8",
68
+ timeout: 10000,
69
+ maxBuffer: 32 * 1024 * 1024,
70
+ windowsHide: true,
71
+ env: windowsPowerShellEnv(),
72
+ },
73
+ ),
74
+ );
75
+ if (!Array.isArray(rows.acls) || !Array.isArray(rows.processes))
76
+ throw new Error("Invalid Windows peer response");
77
+ return {
78
+ acls: new Map(
79
+ rows.acls
80
+ .filter((row) => !Object.hasOwn(row, "error"))
81
+ .map((row) => [row.path, row]),
82
+ ),
83
+ procStarts: new Map(
84
+ rows.processes
85
+ .filter(
86
+ (row) =>
87
+ !Object.hasOwn(row, "error") &&
88
+ typeof row.procStart === "string" &&
89
+ /^\d+$/.test(row.procStart),
90
+ )
91
+ .map((row) => [row.pid, row.procStart]),
92
+ ),
93
+ };
94
+ }
95
+
96
+ export function readWindowsAcls(files, options) {
97
+ return readWindowsPeerState(files, options).acls;
98
+ }
99
+
100
+ export function assertWindowsAcl(acl, kind, { privateMode = true } = {}) {
101
+ const trusted = new Set([acl?.userSid, "S-1-5-18", "S-1-5-32-544"]);
102
+ // 개별 쓰기·삭제·ACL 변경과 generic write/all을 함께 거른다.
103
+ const writes =
104
+ 0x2 |
105
+ 0x4 |
106
+ 0x10 |
107
+ 0x40 |
108
+ 0x100 |
109
+ 0x10000 |
110
+ 0x40000 |
111
+ 0x80000 |
112
+ 0x40000000 |
113
+ 0x10000000;
114
+ if (
115
+ !/^S-1-\d+(?:-\d+)+$/.test(acl?.userSid || "") ||
116
+ !trusted.has(acl.ownerSid) ||
117
+ acl.reparsePoint !== false ||
118
+ !Array.isArray(acl.access) ||
119
+ !acl.access.length ||
120
+ acl.access.some(
121
+ (ace) =>
122
+ !/^S-1-\d+(?:-\d+)+$/.test(ace.sid || "") ||
123
+ !["Allow", "Deny"].includes(ace.type) ||
124
+ !Number.isInteger(ace.rights) ||
125
+ ace.rights < -2147483648 ||
126
+ ace.rights > 0xffffffff ||
127
+ (ace.type === "Allow" &&
128
+ !(
129
+ Number.isInteger(ace.propagationFlags) &&
130
+ (ace.propagationFlags & 2) !== 0
131
+ ) &&
132
+ !trusted.has(ace.sid) &&
133
+ (privateMode || (ace.rights & writes) !== 0)),
134
+ )
135
+ )
136
+ throw new Error(`Unsafe ${kind}`);
137
+ }