@triflux/remote 10.88.3 → 10.89.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hub/team/native-peer.mjs +59 -11
- package/hub/team/ssh-peer.mjs +255 -0
- package/package.json +1 -1
package/hub/team/native-peer.mjs
CHANGED
|
@@ -14,15 +14,47 @@ import { JsonRpcWsUdsClient } from "../workers/lib/jsonrpc-ws-uds.mjs";
|
|
|
14
14
|
const WAKE =
|
|
15
15
|
"A peer agent_message was delivered immediately before this tool output. Read it and respond within this session.";
|
|
16
16
|
|
|
17
|
+
export function parsePeerSpec(value) {
|
|
18
|
+
if (
|
|
19
|
+
typeof value !== "string" ||
|
|
20
|
+
/[\x00-\x1f\x7f-\x9f\u2028\u2029]/u.test(value) ||
|
|
21
|
+
!/^(claude|codex|agy|antigravity):[^:]+$/i.test(value)
|
|
22
|
+
)
|
|
23
|
+
throw new Error("Use exact claude:NAME, codex:NAME, or agy:NAME target");
|
|
24
|
+
const [rawCli, name] = value.split(":");
|
|
25
|
+
if (
|
|
26
|
+
!name.trim() ||
|
|
27
|
+
name !== name.trim() ||
|
|
28
|
+
name.startsWith("-") ||
|
|
29
|
+
name.length > 512
|
|
30
|
+
)
|
|
31
|
+
throw new Error("Invalid peer name");
|
|
32
|
+
const cli =
|
|
33
|
+
rawCli.toLowerCase() === "antigravity" ? "agy" : rawCli.toLowerCase();
|
|
34
|
+
return `${cli}:${name}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
17
37
|
export function parsePeerArgs(args) {
|
|
18
|
-
if (
|
|
19
|
-
throw new Error("Only send and self are supported");
|
|
38
|
+
if (!["send", "self", "remote-send"].includes(args[0]))
|
|
39
|
+
throw new Error("Only send, remote-send and self are supported");
|
|
40
|
+
const remote = args[0] === "remote-send";
|
|
20
41
|
const target = args[0] === "self" ? "--session" : "--to";
|
|
42
|
+
const allowed =
|
|
43
|
+
target === "--session"
|
|
44
|
+
? [target, "--message"]
|
|
45
|
+
: remote
|
|
46
|
+
? [target, "--message", "--from", "--host", "--reply-host"]
|
|
47
|
+
: [target, "--message", "--from"];
|
|
21
48
|
const parsed = {};
|
|
22
49
|
for (let i = 1; i < args.length; i += 2) {
|
|
23
50
|
const flag = args[i],
|
|
24
51
|
value = args[i + 1];
|
|
25
|
-
if (
|
|
52
|
+
if (
|
|
53
|
+
!allowed.includes(flag) ||
|
|
54
|
+
typeof value !== "string" ||
|
|
55
|
+
!value ||
|
|
56
|
+
Object.hasOwn(parsed, flag)
|
|
57
|
+
)
|
|
26
58
|
throw new Error(`Invalid peer argument: ${flag}`);
|
|
27
59
|
parsed[flag] = value;
|
|
28
60
|
}
|
|
@@ -36,14 +68,19 @@ export function parsePeerArgs(args) {
|
|
|
36
68
|
);
|
|
37
69
|
if (target === "--session")
|
|
38
70
|
return { sessionId: parsed["--session"], message: parsed["--message"] };
|
|
39
|
-
if (
|
|
40
|
-
throw new Error("
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
71
|
+
if (remote && (!parsed["--host"] || !parsed["--reply-host"]))
|
|
72
|
+
throw new Error("remote-send requires --host and --reply-host");
|
|
73
|
+
for (const flag of ["--host", "--reply-host"]) {
|
|
74
|
+
if (parsed[flag] && !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(parsed[flag]))
|
|
75
|
+
throw new Error(`Invalid host alias: ${flag}`);
|
|
76
|
+
}
|
|
44
77
|
return {
|
|
45
|
-
to:
|
|
78
|
+
to: parsePeerSpec(parsed["--to"]),
|
|
46
79
|
message: parsed["--message"],
|
|
80
|
+
...(parsed["--from"] ? { from: parsePeerSpec(parsed["--from"]) } : {}),
|
|
81
|
+
...(remote
|
|
82
|
+
? { host: parsed["--host"], replyHost: parsed["--reply-host"] }
|
|
83
|
+
: {}),
|
|
47
84
|
};
|
|
48
85
|
}
|
|
49
86
|
|
|
@@ -58,7 +95,12 @@ export function validateCodexTarget(thread, id) {
|
|
|
58
95
|
throw new Error("Codex thread is not an eligible loaded root user thread");
|
|
59
96
|
}
|
|
60
97
|
|
|
61
|
-
export async function sendCodexPeer({
|
|
98
|
+
export async function sendCodexPeer({
|
|
99
|
+
client,
|
|
100
|
+
threadId,
|
|
101
|
+
message,
|
|
102
|
+
onWriteStart,
|
|
103
|
+
}) {
|
|
62
104
|
let cursor;
|
|
63
105
|
let loaded = false;
|
|
64
106
|
do {
|
|
@@ -81,6 +123,7 @@ export async function sendCodexPeer({ client, threadId, message }) {
|
|
|
81
123
|
});
|
|
82
124
|
validateCodexTarget(read.thread, threadId);
|
|
83
125
|
try {
|
|
126
|
+
onWriteStart?.();
|
|
84
127
|
await client.request("thread/inject_items", {
|
|
85
128
|
threadId,
|
|
86
129
|
items: [
|
|
@@ -162,6 +205,7 @@ export function resolveCodexSocket(home = os.homedir(), env = process.env) {
|
|
|
162
205
|
export async function deliverCodex({
|
|
163
206
|
threadId,
|
|
164
207
|
message,
|
|
208
|
+
onWriteStart,
|
|
165
209
|
home = os.homedir(),
|
|
166
210
|
env = process.env,
|
|
167
211
|
platform = process.platform,
|
|
@@ -177,7 +221,7 @@ export async function deliverCodex({
|
|
|
177
221
|
capabilities: { experimentalApi: true },
|
|
178
222
|
});
|
|
179
223
|
client.notify("initialized", {});
|
|
180
|
-
return await sendCodexPeer({ client, threadId, message });
|
|
224
|
+
return await sendCodexPeer({ client, threadId, message, onWriteStart });
|
|
181
225
|
} finally {
|
|
182
226
|
client.close();
|
|
183
227
|
}
|
|
@@ -400,6 +444,7 @@ export function resolveClaudeRegistryTarget(
|
|
|
400
444
|
export async function deliverClaude({
|
|
401
445
|
sessionId,
|
|
402
446
|
message,
|
|
447
|
+
onWriteStart,
|
|
403
448
|
home = os.homedir(),
|
|
404
449
|
platform = process.platform,
|
|
405
450
|
receiver = resolveClaudeReceiver({ sessionId, home, platform }),
|
|
@@ -444,6 +489,7 @@ export async function deliverClaude({
|
|
|
444
489
|
);
|
|
445
490
|
conn.once("error", reject);
|
|
446
491
|
conn.once("connect", () => {
|
|
492
|
+
onWriteStart?.();
|
|
447
493
|
conn.write(
|
|
448
494
|
`${JSON.stringify({ type: "auth", token: receiver.peerToken })}\n${JSON.stringify(frame)}\n`,
|
|
449
495
|
);
|
|
@@ -480,6 +526,7 @@ export function deliverAgy({
|
|
|
480
526
|
appDataDir: targetAppDataDir = null,
|
|
481
527
|
requireExisting = false,
|
|
482
528
|
message,
|
|
529
|
+
onWriteStart,
|
|
483
530
|
sender = "unknown",
|
|
484
531
|
env = process.env,
|
|
485
532
|
home = null,
|
|
@@ -534,6 +581,7 @@ export function deliverAgy({
|
|
|
534
581
|
const jsonPath = path.join(messagesDir, `${msgId}.json`);
|
|
535
582
|
const markerPath = path.join(undeliveredDir, msgId);
|
|
536
583
|
try {
|
|
584
|
+
onWriteStart?.();
|
|
537
585
|
io.writeFileSync(jsonPath, JSON.stringify(payload, null, 2), {
|
|
538
586
|
mode: 0o600,
|
|
539
587
|
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { normalizeHost, userStateHostsPath } from "@triflux/core/hub/lib/hosts-compat.mjs";
|
|
6
|
+
import { parsePeerSpec } from "./native-peer.mjs";
|
|
7
|
+
|
|
8
|
+
export const PEER_WIRE_LIMIT = 65536;
|
|
9
|
+
export const PEER_RECEIVER_COMMAND =
|
|
10
|
+
'exec "$HOME/.claude/scripts/tfx-peer" _receive';
|
|
11
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
|
+
const ALIAS = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/;
|
|
13
|
+
|
|
14
|
+
// 사용자 설정만 읽는다. legacy migration과 source-tree 폴백은 호출하지 않는다.
|
|
15
|
+
export function resolveRemoteHost(
|
|
16
|
+
alias,
|
|
17
|
+
{ hostsPath = userStateHostsPath() } = {},
|
|
18
|
+
) {
|
|
19
|
+
if (typeof alias !== "string" || !ALIAS.test(alias))
|
|
20
|
+
throw new Error("Invalid registered host alias");
|
|
21
|
+
if (!hostsPath) throw new Error("User-state hosts.json is required");
|
|
22
|
+
const registry = JSON.parse(readFileSync(hostsPath, "utf8"));
|
|
23
|
+
const matches = Object.entries(registry.hosts || {}).filter(
|
|
24
|
+
([name, host]) =>
|
|
25
|
+
name === alias ||
|
|
26
|
+
(Array.isArray(host?.aliases) && host.aliases.includes(alias)),
|
|
27
|
+
);
|
|
28
|
+
if (matches.length !== 1)
|
|
29
|
+
throw new Error("Host alias is unregistered or ambiguous");
|
|
30
|
+
const [name, raw] = matches[0];
|
|
31
|
+
if (
|
|
32
|
+
!["darwin", "macos", "linux"].includes(
|
|
33
|
+
typeof raw.os === "string" ? raw.os.toLowerCase() : "",
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
throw new Error("Remote peer supports only Darwin and POSIX Linux hosts");
|
|
37
|
+
const host = normalizeHost(raw, name);
|
|
38
|
+
const address =
|
|
39
|
+
host.ssh?.host || host.tailscale?.dns || host.tailscale?.ip || host.name;
|
|
40
|
+
const user = host.ssh_user;
|
|
41
|
+
if (
|
|
42
|
+
typeof address !== "string" ||
|
|
43
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/.test(address) ||
|
|
44
|
+
(user != null &&
|
|
45
|
+
(typeof user !== "string" || !/^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/.test(user)))
|
|
46
|
+
)
|
|
47
|
+
throw new Error("Invalid registered SSH address/user");
|
|
48
|
+
if (
|
|
49
|
+
host.ssh?.port !== undefined &&
|
|
50
|
+
(!Number.isInteger(host.ssh.port) ||
|
|
51
|
+
host.ssh.port < 1 ||
|
|
52
|
+
host.ssh.port > 65535)
|
|
53
|
+
)
|
|
54
|
+
throw new Error("Invalid registered SSH port");
|
|
55
|
+
return host;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function validatePeerEnvelope(value) {
|
|
59
|
+
if (
|
|
60
|
+
!value ||
|
|
61
|
+
typeof value !== "object" ||
|
|
62
|
+
Array.isArray(value) ||
|
|
63
|
+
value.version !== 1
|
|
64
|
+
)
|
|
65
|
+
throw new Error("Unsupported peer protocol version");
|
|
66
|
+
if (
|
|
67
|
+
typeof value.correlationId !== "string" ||
|
|
68
|
+
!UUID.test(value.correlationId)
|
|
69
|
+
)
|
|
70
|
+
throw new Error("Invalid correlation ID");
|
|
71
|
+
for (const key of ["originHost", "replyHost"])
|
|
72
|
+
if (typeof value[key] !== "string" || !ALIAS.test(value[key]))
|
|
73
|
+
throw new Error(`Invalid ${key}`);
|
|
74
|
+
if (
|
|
75
|
+
typeof value.message !== "string" ||
|
|
76
|
+
!value.message ||
|
|
77
|
+
value.message.length > 8000
|
|
78
|
+
)
|
|
79
|
+
throw new Error("Peer message requires 1..8000 chars");
|
|
80
|
+
if (parsePeerSpec(value.to) !== value.to)
|
|
81
|
+
throw new Error("Noncanonical peer target");
|
|
82
|
+
const sender = value.sender;
|
|
83
|
+
if (!sender || !["claude", "codex", "agy"].includes(sender.cli))
|
|
84
|
+
throw new Error("Unknown sender; use --from CLI:NAME");
|
|
85
|
+
if (typeof sender.name !== "string") throw new Error("Invalid sender name");
|
|
86
|
+
parsePeerSpec(`${sender.cli}:${sender.name}`);
|
|
87
|
+
if (sender.name === "unknown")
|
|
88
|
+
throw new Error("Unknown sender; use --from CLI:NAME");
|
|
89
|
+
if (
|
|
90
|
+
sender.id !== undefined &&
|
|
91
|
+
(typeof sender.id !== "string" || !UUID.test(sender.id))
|
|
92
|
+
)
|
|
93
|
+
throw new Error("Invalid sender conversation ID");
|
|
94
|
+
const keys = [
|
|
95
|
+
"version",
|
|
96
|
+
"correlationId",
|
|
97
|
+
"to",
|
|
98
|
+
"message",
|
|
99
|
+
"originHost",
|
|
100
|
+
"replyHost",
|
|
101
|
+
"sender",
|
|
102
|
+
];
|
|
103
|
+
if (
|
|
104
|
+
Object.keys(value).some((k) => !keys.includes(k)) ||
|
|
105
|
+
Object.keys(sender).some((k) => !["cli", "name", "id"].includes(k))
|
|
106
|
+
)
|
|
107
|
+
throw new Error("Unexpected peer metadata");
|
|
108
|
+
if (Buffer.byteLength(JSON.stringify(value)) > PEER_WIRE_LIMIT)
|
|
109
|
+
throw new Error("Peer envelope too large");
|
|
110
|
+
return value;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function readPeerInput(input) {
|
|
114
|
+
const chunks = [];
|
|
115
|
+
let size = 0;
|
|
116
|
+
for await (const chunk of input) {
|
|
117
|
+
const bytes = Buffer.from(chunk);
|
|
118
|
+
size += bytes.length;
|
|
119
|
+
if (size > PEER_WIRE_LIMIT) throw new Error("Peer envelope too large");
|
|
120
|
+
chunks.push(bytes);
|
|
121
|
+
}
|
|
122
|
+
return validatePeerEnvelope(
|
|
123
|
+
JSON.parse(Buffer.concat(chunks).toString("utf8")),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function formatRemotePeerMessage(envelope) {
|
|
128
|
+
const { sender, correlationId, originHost, replyHost, message } = envelope;
|
|
129
|
+
const quote = (s) => `'${s.replaceAll("'", `'"'"'`)}'`;
|
|
130
|
+
const command = quote(path.join(homedir(), ".claude", "scripts", "tfx-peer"));
|
|
131
|
+
return [
|
|
132
|
+
`[tfx-peer from=${sender.cli}:${sender.name} msg=${correlationId} origin=${originHost}${sender.id ? ` fromId=${sender.id}` : ""}]`,
|
|
133
|
+
message,
|
|
134
|
+
`회신: ${command} remote-send --host ${quote(originHost)} --reply-host ${quote(replyHost)} --to ${quote(`${sender.cli}:${sender.id || sender.name}`)} --message '...'`,
|
|
135
|
+
].join("\n");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function validatePeerResponse(response, correlationId) {
|
|
139
|
+
const result = response?.result;
|
|
140
|
+
if (
|
|
141
|
+
response?.version !== 1 ||
|
|
142
|
+
response.correlationId !== correlationId ||
|
|
143
|
+
!["preflight", "delivery"].includes(response.stage) ||
|
|
144
|
+
!["sent", "written", "unknown", "partial", "rejected"].includes(
|
|
145
|
+
result?.status,
|
|
146
|
+
) ||
|
|
147
|
+
result.retrySafe !== false ||
|
|
148
|
+
result.modelReceipt !== false ||
|
|
149
|
+
(response.stage === "preflight") !== (result.status === "rejected") ||
|
|
150
|
+
(result.msgId !== undefined &&
|
|
151
|
+
(typeof result.msgId !== "string" ||
|
|
152
|
+
!result.msgId ||
|
|
153
|
+
result.msgId.length > 512)) ||
|
|
154
|
+
(result.error !== undefined && typeof result.error !== "string")
|
|
155
|
+
)
|
|
156
|
+
throw new Error("Invalid remote peer response version/ID/status");
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function sendRemotePeer(envelope, deps = {}) {
|
|
161
|
+
validatePeerEnvelope(envelope);
|
|
162
|
+
const platform = deps.platform || process.platform;
|
|
163
|
+
if (!["darwin", "linux"].includes(platform))
|
|
164
|
+
throw new Error("Remote peer supports only Darwin and POSIX Linux senders");
|
|
165
|
+
const lookup = deps.resolveRemoteHost || resolveRemoteHost;
|
|
166
|
+
const host = lookup(envelope.replyHost, deps);
|
|
167
|
+
const address =
|
|
168
|
+
host.ssh?.host || host.tailscale?.dns || host.tailscale?.ip || host.name;
|
|
169
|
+
const user = host.ssh_user;
|
|
170
|
+
const args = [
|
|
171
|
+
"-T",
|
|
172
|
+
"-o",
|
|
173
|
+
"BatchMode=yes",
|
|
174
|
+
"-o",
|
|
175
|
+
"ConnectTimeout=10",
|
|
176
|
+
"-o",
|
|
177
|
+
"ConnectionAttempts=1",
|
|
178
|
+
];
|
|
179
|
+
if (host.ssh?.port !== undefined) args.push("-p", String(host.ssh.port));
|
|
180
|
+
args.push("--", user ? `${user}@${address}` : address, PEER_RECEIVER_COMMAND);
|
|
181
|
+
const uncertain = (error) => ({
|
|
182
|
+
status: "unknown",
|
|
183
|
+
correlationId: envelope.correlationId,
|
|
184
|
+
retrySafe: false,
|
|
185
|
+
modelReceipt: false,
|
|
186
|
+
error,
|
|
187
|
+
});
|
|
188
|
+
const wire = await new Promise((resolve) => {
|
|
189
|
+
let child;
|
|
190
|
+
try {
|
|
191
|
+
child = (deps.spawn || spawn)("ssh", args, {
|
|
192
|
+
shell: false,
|
|
193
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
194
|
+
env: deps.env || process.env,
|
|
195
|
+
});
|
|
196
|
+
} catch (error) {
|
|
197
|
+
resolve({ error: error.message });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const stdout = [];
|
|
201
|
+
let size = 0,
|
|
202
|
+
error = null;
|
|
203
|
+
const stop = (reason) => {
|
|
204
|
+
error = reason;
|
|
205
|
+
child.kill("SIGKILL");
|
|
206
|
+
};
|
|
207
|
+
const timer = setTimeout(
|
|
208
|
+
() => stop("SSH peer timeout; delivery outcome unknown"),
|
|
209
|
+
deps.timeoutMs ?? 15000,
|
|
210
|
+
);
|
|
211
|
+
child.stdout.on("data", (chunk) => {
|
|
212
|
+
size += chunk.length;
|
|
213
|
+
if (size > PEER_WIRE_LIMIT) stop("Remote peer response too large");
|
|
214
|
+
else stdout.push(Buffer.from(chunk));
|
|
215
|
+
});
|
|
216
|
+
child.stderr.on("data", (chunk) => {
|
|
217
|
+
size += chunk.length;
|
|
218
|
+
if (size > PEER_WIRE_LIMIT) stop("SSH peer output too large");
|
|
219
|
+
});
|
|
220
|
+
child.stdin.on("error", (err) => {
|
|
221
|
+
error ||= `SSH stdin: ${err.code || err.message}`;
|
|
222
|
+
});
|
|
223
|
+
child.once("error", (err) => {
|
|
224
|
+
error ||= `SSH execution: ${err.message}`;
|
|
225
|
+
});
|
|
226
|
+
child.once("close", (code) => {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
resolve({ stdout: Buffer.concat(stdout).toString("utf8"), code, error });
|
|
229
|
+
});
|
|
230
|
+
child.stdin.end(JSON.stringify(envelope));
|
|
231
|
+
});
|
|
232
|
+
if (wire.error) return uncertain(wire.error);
|
|
233
|
+
try {
|
|
234
|
+
if (!wire.stdout?.trim())
|
|
235
|
+
throw new Error(
|
|
236
|
+
"Empty remote peer response; protocol unavailable or response lost",
|
|
237
|
+
);
|
|
238
|
+
const response = JSON.parse(wire.stdout);
|
|
239
|
+
const result = validatePeerResponse(response, envelope.correlationId);
|
|
240
|
+
const expectedCode =
|
|
241
|
+
result.status === "rejected"
|
|
242
|
+
? 1
|
|
243
|
+
: ["unknown", "partial"].includes(result.status)
|
|
244
|
+
? 2
|
|
245
|
+
: 0;
|
|
246
|
+
if (wire.code !== expectedCode)
|
|
247
|
+
return {
|
|
248
|
+
...result,
|
|
249
|
+
...uncertain("SSH exit/status mismatch; delivery outcome unknown"),
|
|
250
|
+
};
|
|
251
|
+
return { ...result, correlationId: envelope.correlationId };
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return uncertain(error.message);
|
|
254
|
+
}
|
|
255
|
+
}
|