@triflux/remote 10.88.3 → 10.89.1
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 +66 -12
- 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
|
|
|
@@ -51,14 +88,25 @@ export function validateCodexTarget(thread, id) {
|
|
|
51
88
|
if (
|
|
52
89
|
thread?.id !== id ||
|
|
53
90
|
thread.canAcceptDirectInput !== true ||
|
|
54
|
-
|
|
91
|
+
!(
|
|
92
|
+
thread.threadSource === "user" ||
|
|
93
|
+
(thread.threadSource === null &&
|
|
94
|
+
thread.parentThreadId === null &&
|
|
95
|
+
thread.agentRole === null &&
|
|
96
|
+
(thread.source === "cli" || thread.source === "vscode"))
|
|
97
|
+
) ||
|
|
55
98
|
thread.parentThreadId ||
|
|
56
99
|
thread.agentRole
|
|
57
100
|
)
|
|
58
101
|
throw new Error("Codex thread is not an eligible loaded root user thread");
|
|
59
102
|
}
|
|
60
103
|
|
|
61
|
-
export async function sendCodexPeer({
|
|
104
|
+
export async function sendCodexPeer({
|
|
105
|
+
client,
|
|
106
|
+
threadId,
|
|
107
|
+
message,
|
|
108
|
+
onWriteStart,
|
|
109
|
+
}) {
|
|
62
110
|
let cursor;
|
|
63
111
|
let loaded = false;
|
|
64
112
|
do {
|
|
@@ -81,6 +129,7 @@ export async function sendCodexPeer({ client, threadId, message }) {
|
|
|
81
129
|
});
|
|
82
130
|
validateCodexTarget(read.thread, threadId);
|
|
83
131
|
try {
|
|
132
|
+
onWriteStart?.();
|
|
84
133
|
await client.request("thread/inject_items", {
|
|
85
134
|
threadId,
|
|
86
135
|
items: [
|
|
@@ -162,6 +211,7 @@ export function resolveCodexSocket(home = os.homedir(), env = process.env) {
|
|
|
162
211
|
export async function deliverCodex({
|
|
163
212
|
threadId,
|
|
164
213
|
message,
|
|
214
|
+
onWriteStart,
|
|
165
215
|
home = os.homedir(),
|
|
166
216
|
env = process.env,
|
|
167
217
|
platform = process.platform,
|
|
@@ -177,7 +227,7 @@ export async function deliverCodex({
|
|
|
177
227
|
capabilities: { experimentalApi: true },
|
|
178
228
|
});
|
|
179
229
|
client.notify("initialized", {});
|
|
180
|
-
return await sendCodexPeer({ client, threadId, message });
|
|
230
|
+
return await sendCodexPeer({ client, threadId, message, onWriteStart });
|
|
181
231
|
} finally {
|
|
182
232
|
client.close();
|
|
183
233
|
}
|
|
@@ -400,6 +450,7 @@ export function resolveClaudeRegistryTarget(
|
|
|
400
450
|
export async function deliverClaude({
|
|
401
451
|
sessionId,
|
|
402
452
|
message,
|
|
453
|
+
onWriteStart,
|
|
403
454
|
home = os.homedir(),
|
|
404
455
|
platform = process.platform,
|
|
405
456
|
receiver = resolveClaudeReceiver({ sessionId, home, platform }),
|
|
@@ -444,6 +495,7 @@ export async function deliverClaude({
|
|
|
444
495
|
);
|
|
445
496
|
conn.once("error", reject);
|
|
446
497
|
conn.once("connect", () => {
|
|
498
|
+
onWriteStart?.();
|
|
447
499
|
conn.write(
|
|
448
500
|
`${JSON.stringify({ type: "auth", token: receiver.peerToken })}\n${JSON.stringify(frame)}\n`,
|
|
449
501
|
);
|
|
@@ -480,6 +532,7 @@ export function deliverAgy({
|
|
|
480
532
|
appDataDir: targetAppDataDir = null,
|
|
481
533
|
requireExisting = false,
|
|
482
534
|
message,
|
|
535
|
+
onWriteStart,
|
|
483
536
|
sender = "unknown",
|
|
484
537
|
env = process.env,
|
|
485
538
|
home = null,
|
|
@@ -534,6 +587,7 @@ export function deliverAgy({
|
|
|
534
587
|
const jsonPath = path.join(messagesDir, `${msgId}.json`);
|
|
535
588
|
const markerPath = path.join(undeliveredDir, msgId);
|
|
536
589
|
try {
|
|
590
|
+
onWriteStart?.();
|
|
537
591
|
io.writeFileSync(jsonPath, JSON.stringify(payload, null, 2), {
|
|
538
592
|
mode: 0o600,
|
|
539
593
|
});
|
|
@@ -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
|
+
}
|