@triflux/remote 10.76.1 → 10.77.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.
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { JsonRpcWsUdsClient } from "../workers/lib/jsonrpc-ws-uds.mjs";
|
|
8
|
+
|
|
9
|
+
const WAKE =
|
|
10
|
+
"A peer agent_message was delivered immediately before this tool output. Read it and respond within this session.";
|
|
11
|
+
|
|
12
|
+
export function parsePeerArgs(args) {
|
|
13
|
+
if (args[0] !== "send") throw new Error("Only send is supported");
|
|
14
|
+
const parsed = {};
|
|
15
|
+
for (let i = 1; i < args.length; i += 2) {
|
|
16
|
+
const flag = args[i],
|
|
17
|
+
value = args[i + 1];
|
|
18
|
+
if (!["--to", "--message"].includes(flag) || !value || parsed[flag])
|
|
19
|
+
throw new Error(`Invalid peer argument: ${flag}`);
|
|
20
|
+
parsed[flag] = value;
|
|
21
|
+
}
|
|
22
|
+
if (
|
|
23
|
+
!parsed["--to"] ||
|
|
24
|
+
!parsed["--message"] ||
|
|
25
|
+
parsed["--message"].length > 8000
|
|
26
|
+
)
|
|
27
|
+
throw new Error("send requires --to and --message (max 8000 chars)");
|
|
28
|
+
if (!/^(claude|codex):[^:]+$/.test(parsed["--to"]))
|
|
29
|
+
throw new Error("Use exact claude:NAME or codex:NAME target");
|
|
30
|
+
return { to: parsed["--to"], message: parsed["--message"] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateCodexTarget(thread, id) {
|
|
34
|
+
if (
|
|
35
|
+
thread?.id !== id ||
|
|
36
|
+
thread.canAcceptDirectInput !== true ||
|
|
37
|
+
thread.threadSource !== "user" ||
|
|
38
|
+
thread.parentThreadId ||
|
|
39
|
+
thread.agentRole
|
|
40
|
+
)
|
|
41
|
+
throw new Error("Codex thread is not an eligible loaded root user thread");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function sendCodexPeer({ client, threadId, message }) {
|
|
45
|
+
let cursor;
|
|
46
|
+
let loaded = false;
|
|
47
|
+
do {
|
|
48
|
+
const page = await client.request(
|
|
49
|
+
"thread/loaded/list",
|
|
50
|
+
cursor ? { cursor } : {},
|
|
51
|
+
);
|
|
52
|
+
loaded ||= (page.data || []).some(
|
|
53
|
+
(entry) => entry === threadId || entry?.id === threadId,
|
|
54
|
+
);
|
|
55
|
+
cursor = page.nextCursor;
|
|
56
|
+
} while (!loaded && cursor);
|
|
57
|
+
if (!loaded)
|
|
58
|
+
throw new Error(
|
|
59
|
+
"Codex target is not loaded in this app-server; start TUI with --remote unix://...",
|
|
60
|
+
);
|
|
61
|
+
const read = await client.request("thread/read", {
|
|
62
|
+
threadId,
|
|
63
|
+
includeTurns: false,
|
|
64
|
+
});
|
|
65
|
+
validateCodexTarget(read.thread, threadId);
|
|
66
|
+
try {
|
|
67
|
+
await client.request("thread/inject_items", {
|
|
68
|
+
threadId,
|
|
69
|
+
items: [
|
|
70
|
+
{
|
|
71
|
+
type: "agent_message",
|
|
72
|
+
author: "/tfx-peer",
|
|
73
|
+
recipient: "/root",
|
|
74
|
+
content: [{ type: "input_text", text: message }],
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
});
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return {
|
|
80
|
+
status: "unknown",
|
|
81
|
+
retrySafe: false,
|
|
82
|
+
modelReceipt: false,
|
|
83
|
+
error: `injection outcome unknown: ${error.message}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
await client.request("turn/start", {
|
|
88
|
+
threadId,
|
|
89
|
+
input: [],
|
|
90
|
+
toolOutput: { name: "tfx_peer_wake", output: WAKE, retain: true },
|
|
91
|
+
});
|
|
92
|
+
return { status: "sent", retrySafe: false, modelReceipt: false };
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
status: "partial",
|
|
96
|
+
retrySafe: false,
|
|
97
|
+
modelReceipt: false,
|
|
98
|
+
error: `wake failed after injection: ${error.message}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function assertPrivateFile(
|
|
104
|
+
file,
|
|
105
|
+
kind,
|
|
106
|
+
{ socket = false, privateMode = true } = {},
|
|
107
|
+
) {
|
|
108
|
+
const meta = fs.lstatSync(file);
|
|
109
|
+
if (
|
|
110
|
+
meta.isSymbolicLink() ||
|
|
111
|
+
meta.uid !== process.getuid() ||
|
|
112
|
+
(socket ? !meta.isSocket() : !meta.isFile()) ||
|
|
113
|
+
(meta.mode & 0o022) !== 0 ||
|
|
114
|
+
(privateMode && (meta.mode & 0o077) !== 0)
|
|
115
|
+
)
|
|
116
|
+
throw new Error(`Unsafe ${kind}`);
|
|
117
|
+
return meta;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolveCodexSocket(home = os.homedir()) {
|
|
121
|
+
const link = path.join(
|
|
122
|
+
home,
|
|
123
|
+
".codex",
|
|
124
|
+
"app-server-control",
|
|
125
|
+
"app-server-control.sock",
|
|
126
|
+
);
|
|
127
|
+
const resolved = fs.realpathSync(link);
|
|
128
|
+
assertPrivateFile(resolved, "Codex app-server socket", { socket: true });
|
|
129
|
+
return resolved;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function deliverCodex({
|
|
133
|
+
threadId,
|
|
134
|
+
message,
|
|
135
|
+
home = os.homedir(),
|
|
136
|
+
clientFactory = (socketPath) => new JsonRpcWsUdsClient({ socketPath }),
|
|
137
|
+
}) {
|
|
138
|
+
const client = clientFactory(resolveCodexSocket(home));
|
|
139
|
+
try {
|
|
140
|
+
await client.connect();
|
|
141
|
+
await client.request("initialize", {
|
|
142
|
+
clientInfo: { name: "triflux-peer", version: "1" },
|
|
143
|
+
capabilities: { experimentalApi: true },
|
|
144
|
+
});
|
|
145
|
+
client.notify("initialized", {});
|
|
146
|
+
return await sendCodexPeer({ client, threadId, message });
|
|
147
|
+
} finally {
|
|
148
|
+
client.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function actualProcStart(pid) {
|
|
153
|
+
return execFileSync("/bin/ps", ["-p", String(pid), "-o", "lstart="], {
|
|
154
|
+
encoding: "utf8",
|
|
155
|
+
timeout: 2000,
|
|
156
|
+
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
|
157
|
+
})
|
|
158
|
+
.trim()
|
|
159
|
+
.replace(/\s+/g, " ");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function resolveClaudeReceiver({
|
|
163
|
+
sessionId,
|
|
164
|
+
home = os.homedir(),
|
|
165
|
+
getProcStart = actualProcStart,
|
|
166
|
+
}) {
|
|
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
|
+
}
|
|
182
|
+
if (matches.length !== 1)
|
|
183
|
+
throw new Error("Claude session identity missing or ambiguous");
|
|
184
|
+
const record = matches[0];
|
|
185
|
+
if (record.parkedJobId)
|
|
186
|
+
throw new Error(
|
|
187
|
+
"Claude session is a parked parent; use its live continuation name",
|
|
188
|
+
);
|
|
189
|
+
const pid = Number(record.pid);
|
|
190
|
+
if (
|
|
191
|
+
!Number.isSafeInteger(pid) ||
|
|
192
|
+
pid <= 0 ||
|
|
193
|
+
record.pidDomain !== process.platform ||
|
|
194
|
+
record.procStart?.replace(/\s+/g, " ") !== getProcStart(pid)
|
|
195
|
+
)
|
|
196
|
+
throw new Error("Claude process identity stale");
|
|
197
|
+
const keyFiles = fs
|
|
198
|
+
.readdirSync(dir)
|
|
199
|
+
.filter((x) => x.startsWith(`${pid}.`) && x.endsWith(".key"));
|
|
200
|
+
if (keyFiles.length !== 1)
|
|
201
|
+
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"));
|
|
205
|
+
if (
|
|
206
|
+
key.procStart?.replace(/\s+/g, " ") !==
|
|
207
|
+
record.procStart?.replace(/\s+/g, " ") ||
|
|
208
|
+
key.pidDomain !== record.pidDomain ||
|
|
209
|
+
typeof key.peerToken !== "string" ||
|
|
210
|
+
!key.peerToken
|
|
211
|
+
)
|
|
212
|
+
throw new Error("Claude peer key identity mismatch");
|
|
213
|
+
const socketPath = record.messagingSocketPath;
|
|
214
|
+
if (
|
|
215
|
+
typeof socketPath !== "string" ||
|
|
216
|
+
!path.isAbsolute(socketPath) ||
|
|
217
|
+
path.basename(socketPath) !== `${pid}.sock`
|
|
218
|
+
)
|
|
219
|
+
throw new Error("Invalid Claude peer socket");
|
|
220
|
+
assertPrivateFile(socketPath, "Claude peer socket", { socket: true });
|
|
221
|
+
return { socketPath, peerToken: key.peerToken };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function resolveClaudeRegistryTarget(
|
|
225
|
+
query,
|
|
226
|
+
{ home = os.homedir(), getProcStart = actualProcStart } = {},
|
|
227
|
+
) {
|
|
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 && record.name)
|
|
236
|
+
records.push(record);
|
|
237
|
+
} catch {}
|
|
238
|
+
}
|
|
239
|
+
const exactId = records.filter(
|
|
240
|
+
(x) => x.sessionId.toLowerCase() === query.toLowerCase(),
|
|
241
|
+
);
|
|
242
|
+
const named = exactId.length
|
|
243
|
+
? exactId
|
|
244
|
+
: records.filter((x) => x.name.toLowerCase() === query.toLowerCase());
|
|
245
|
+
if (!named.length)
|
|
246
|
+
throw new Error("Claude target not found in local session registry");
|
|
247
|
+
if (exactId.length && exactId[0].parkedJobId)
|
|
248
|
+
throw new Error(
|
|
249
|
+
"Claude target is a parked parent; specify the live continuation name",
|
|
250
|
+
);
|
|
251
|
+
const parked = named.filter((x) => x.parkedJobId);
|
|
252
|
+
if (parked.length > 1) throw new Error("Ambiguous parked Claude parents");
|
|
253
|
+
const eligible = named.filter(
|
|
254
|
+
(x) =>
|
|
255
|
+
!x.parkedJobId && (!parked.length || x.jobId === parked[0].parkedJobId),
|
|
256
|
+
);
|
|
257
|
+
const live = eligible.filter((x) => {
|
|
258
|
+
try {
|
|
259
|
+
resolveClaudeReceiver({ sessionId: x.sessionId, home, getProcStart });
|
|
260
|
+
return true;
|
|
261
|
+
} catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
if (live.length !== 1)
|
|
266
|
+
throw new Error(
|
|
267
|
+
live.length
|
|
268
|
+
? "Ambiguous live Claude targets"
|
|
269
|
+
: "No live Claude continuation; parked parent is not a delivery target",
|
|
270
|
+
);
|
|
271
|
+
return { sessionId: live[0].sessionId, name: live[0].name };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function deliverClaude({
|
|
275
|
+
sessionId,
|
|
276
|
+
message,
|
|
277
|
+
home = os.homedir(),
|
|
278
|
+
receiver = resolveClaudeReceiver({ sessionId, home }),
|
|
279
|
+
socketDir = path.dirname(receiver.socketPath),
|
|
280
|
+
// 150ms 유지 후 리드의 peer 수신·응답을 확인해 8초 고정 대기를 줄였다.
|
|
281
|
+
holdMs = 150,
|
|
282
|
+
}) {
|
|
283
|
+
const own = path.join(socketDir, `${process.pid}.sock`);
|
|
284
|
+
// 이 일회성 송신자는 회신을 라우팅하지 않는다. 열린 reply 소켓을 즉시 닫는다.
|
|
285
|
+
const server = net.createServer((connection) => connection.destroy());
|
|
286
|
+
await new Promise((resolve, reject) => {
|
|
287
|
+
server.once("error", reject);
|
|
288
|
+
server.listen(own, () => {
|
|
289
|
+
server.removeListener("error", reject);
|
|
290
|
+
resolve();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
const inode = fs.lstatSync(own).ino;
|
|
294
|
+
fs.chmodSync(own, 0o600);
|
|
295
|
+
const from = `uds:${own}`;
|
|
296
|
+
const safe = message.replaceAll("&", "&").replaceAll("<", "<");
|
|
297
|
+
const frame = {
|
|
298
|
+
type: "user",
|
|
299
|
+
message: {
|
|
300
|
+
role: "user",
|
|
301
|
+
content: `<cross-session-message from="${from}" from-name="Triflux peer adapter" from-mode="prompting">\n${safe}\n</cross-session-message>`,
|
|
302
|
+
},
|
|
303
|
+
uuid: randomUUID(),
|
|
304
|
+
msg_id: randomUUID(),
|
|
305
|
+
from,
|
|
306
|
+
};
|
|
307
|
+
try {
|
|
308
|
+
try {
|
|
309
|
+
await new Promise((resolve, reject) => {
|
|
310
|
+
const conn = net.connect(receiver.socketPath);
|
|
311
|
+
conn.setTimeout(5000, () =>
|
|
312
|
+
conn.destroy(new Error("Claude peer connect timed out")),
|
|
313
|
+
);
|
|
314
|
+
conn.once("error", reject);
|
|
315
|
+
conn.once("connect", () => {
|
|
316
|
+
conn.write(
|
|
317
|
+
`${JSON.stringify({ type: "auth", token: receiver.peerToken })}\n${JSON.stringify(frame)}\n`,
|
|
318
|
+
);
|
|
319
|
+
conn.end();
|
|
320
|
+
});
|
|
321
|
+
conn.once("close", () => resolve());
|
|
322
|
+
});
|
|
323
|
+
} catch (error) {
|
|
324
|
+
return {
|
|
325
|
+
status: "unknown",
|
|
326
|
+
retrySafe: false,
|
|
327
|
+
modelReceipt: false,
|
|
328
|
+
error: `peer write outcome unknown: ${error.message}`,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
await new Promise((resolve) => setTimeout(resolve, holdMs));
|
|
332
|
+
return {
|
|
333
|
+
status: "written",
|
|
334
|
+
retrySafe: false,
|
|
335
|
+
modelReceipt: false,
|
|
336
|
+
msgId: frame.msg_id,
|
|
337
|
+
};
|
|
338
|
+
} finally {
|
|
339
|
+
server.close();
|
|
340
|
+
try {
|
|
341
|
+
if (fs.lstatSync(own).ino === inode) fs.unlinkSync(own);
|
|
342
|
+
} catch {}
|
|
343
|
+
}
|
|
344
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { resolveStableNodeBin } from "./stable-node.mjs";
|
|
5
|
+
|
|
6
|
+
const hash = (text) => createHash("sha256").update(text).digest("hex");
|
|
7
|
+
const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
8
|
+
const read = (file) =>
|
|
9
|
+
fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
|
|
10
|
+
const trustedTmuxPath = () =>
|
|
11
|
+
[
|
|
12
|
+
"/opt/homebrew/bin/tmux",
|
|
13
|
+
"/usr/local/bin/tmux",
|
|
14
|
+
"/usr/bin/tmux",
|
|
15
|
+
"/bin/tmux",
|
|
16
|
+
"/home/linuxbrew/.linuxbrew/bin/tmux",
|
|
17
|
+
].find((file) => existing(file));
|
|
18
|
+
function trustedHelperPath(file) {
|
|
19
|
+
if (!file || !path.isAbsolute(file)) return false;
|
|
20
|
+
const base = path.dirname(path.dirname(file));
|
|
21
|
+
let resolved;
|
|
22
|
+
try {
|
|
23
|
+
resolved = fs.realpathSync(file);
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const safe = (target, alias = false) => {
|
|
28
|
+
const rel = path.relative(base, target);
|
|
29
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
|
|
30
|
+
let current = base;
|
|
31
|
+
const parts = rel.split(path.sep);
|
|
32
|
+
const baseMeta = existing(base);
|
|
33
|
+
if (
|
|
34
|
+
!baseMeta?.isDirectory() ||
|
|
35
|
+
![0, process.getuid()].includes(baseMeta.uid) ||
|
|
36
|
+
(baseMeta.mode & 0o002) !== 0
|
|
37
|
+
)
|
|
38
|
+
return false;
|
|
39
|
+
for (let i = 0; i < parts.length; i++) {
|
|
40
|
+
current = path.join(current, parts[i]);
|
|
41
|
+
const meta = existing(current);
|
|
42
|
+
if (!meta || ![0, process.getuid()].includes(meta.uid)) return false;
|
|
43
|
+
if (meta.isSymbolicLink()) {
|
|
44
|
+
if (!alias || i !== parts.length - 1) return false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if ((meta.mode & 0o002) !== 0) return false;
|
|
48
|
+
if (i < parts.length - 1 ? !meta.isDirectory() : !meta.isFile())
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
};
|
|
53
|
+
return (
|
|
54
|
+
safe(file, true) &&
|
|
55
|
+
safe(resolved) &&
|
|
56
|
+
(existing(resolved).mode & 0o111) !== 0
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
function trustedPackageTree(packageRoot, nodePath, tmuxPath) {
|
|
60
|
+
if (!trustedHelperPath(tmuxPath)) return false;
|
|
61
|
+
for (const dir of ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]) {
|
|
62
|
+
let meta;
|
|
63
|
+
try {
|
|
64
|
+
meta = fs.statSync(dir);
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
if (!meta.isDirectory() || meta.uid !== 0 || (meta.mode & 0o002) !== 0)
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const prefix = path.dirname(path.dirname(nodePath));
|
|
72
|
+
try {
|
|
73
|
+
if (fs.realpathSync(packageRoot) !== packageRoot) return false;
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (
|
|
78
|
+
path.resolve(packageRoot) !==
|
|
79
|
+
path.join(prefix, "lib", "node_modules", "triflux")
|
|
80
|
+
)
|
|
81
|
+
return false;
|
|
82
|
+
const safePath = (target, { base = prefix, finalLink = false } = {}) => {
|
|
83
|
+
const rel = path.relative(base, target);
|
|
84
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
|
|
85
|
+
let current = base;
|
|
86
|
+
const parts = rel ? ["", ...rel.split(path.sep)] : [""];
|
|
87
|
+
for (let index = 0; index < parts.length; index++) {
|
|
88
|
+
if (index) current = path.join(current, parts[index]);
|
|
89
|
+
const meta = existing(current);
|
|
90
|
+
if (!meta || ![0, process.getuid()].includes(meta.uid)) return false;
|
|
91
|
+
if (meta.isSymbolicLink()) {
|
|
92
|
+
if (!finalLink || index !== parts.length - 1) return false;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if ((meta.mode & 0o002) !== 0) return false;
|
|
96
|
+
if (index < parts.length - 1 && !meta.isDirectory()) return false;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
};
|
|
100
|
+
let resolvedNode;
|
|
101
|
+
let resolvedPrefix;
|
|
102
|
+
try {
|
|
103
|
+
resolvedNode = fs.realpathSync(nodePath);
|
|
104
|
+
resolvedPrefix = fs.realpathSync(prefix);
|
|
105
|
+
} catch {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
!safePath(path.join(prefix, "lib", "node_modules")) ||
|
|
110
|
+
!safePath(nodePath, { finalLink: true }) ||
|
|
111
|
+
!safePath(resolvedNode, { base: resolvedPrefix }) ||
|
|
112
|
+
!existing(resolvedNode)?.isFile()
|
|
113
|
+
)
|
|
114
|
+
return false;
|
|
115
|
+
let ancestor = packageRoot;
|
|
116
|
+
while (true) {
|
|
117
|
+
if (existing(path.join(ancestor, ".git"))) return false;
|
|
118
|
+
const parent = path.dirname(ancestor);
|
|
119
|
+
if (parent === ancestor) break;
|
|
120
|
+
ancestor = parent;
|
|
121
|
+
}
|
|
122
|
+
const stack = [packageRoot];
|
|
123
|
+
while (stack.length) {
|
|
124
|
+
const current = stack.pop();
|
|
125
|
+
const meta = existing(current);
|
|
126
|
+
if (
|
|
127
|
+
!meta ||
|
|
128
|
+
meta.isSymbolicLink() ||
|
|
129
|
+
![0, process.getuid()].includes(meta.uid) ||
|
|
130
|
+
(meta.mode & 0o002) !== 0
|
|
131
|
+
)
|
|
132
|
+
return false;
|
|
133
|
+
if (meta.isDirectory())
|
|
134
|
+
for (const child of fs.readdirSync(current))
|
|
135
|
+
stack.push(path.join(current, child));
|
|
136
|
+
else if (!meta.isFile()) return false;
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
function existing(file) {
|
|
141
|
+
try {
|
|
142
|
+
return fs.lstatSync(file);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (error.code === "ENOENT") return null;
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function atomic(file, contents, mode = 0o600) {
|
|
149
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
150
|
+
const temp = `${file}.${process.pid}.${randomBytes(5).toString("hex")}.tmp`;
|
|
151
|
+
try {
|
|
152
|
+
fs.writeFileSync(temp, contents, { mode, flag: "wx" });
|
|
153
|
+
fs.renameSync(temp, file);
|
|
154
|
+
fs.chmodSync(file, mode);
|
|
155
|
+
} finally {
|
|
156
|
+
try {
|
|
157
|
+
fs.unlinkSync(temp);
|
|
158
|
+
} catch {}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function backup(file) {
|
|
162
|
+
if (!fs.existsSync(file)) return null;
|
|
163
|
+
const dest = `${file}.tfx-peer-backup-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
|
164
|
+
fs.copyFileSync(file, dest, fs.constants.COPYFILE_EXCL);
|
|
165
|
+
fs.chmodSync(dest, 0o600);
|
|
166
|
+
return dest;
|
|
167
|
+
}
|
|
168
|
+
function keepOnlyLatestSettingsBackup(file, latest) {
|
|
169
|
+
if (!latest) return;
|
|
170
|
+
const prefix = `${path.basename(file)}.tfx-peer-backup-`;
|
|
171
|
+
for (const name of fs.readdirSync(path.dirname(file))) {
|
|
172
|
+
if (
|
|
173
|
+
!name.startsWith(prefix) ||
|
|
174
|
+
!/^\d{13}-[0-9a-f]{8}$/.test(name.slice(prefix.length))
|
|
175
|
+
)
|
|
176
|
+
continue;
|
|
177
|
+
const candidate = path.join(path.dirname(file), name);
|
|
178
|
+
const meta = existing(candidate);
|
|
179
|
+
if (
|
|
180
|
+
candidate !== latest &&
|
|
181
|
+
meta?.isFile() &&
|
|
182
|
+
meta.uid === process.getuid() &&
|
|
183
|
+
(meta.mode & 0o777) === 0o600
|
|
184
|
+
)
|
|
185
|
+
fs.unlinkSync(candidate);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export function peerPermissionPlan({
|
|
189
|
+
home,
|
|
190
|
+
packageRoot,
|
|
191
|
+
nodePath = resolveStableNodeBin(),
|
|
192
|
+
tmuxPath = trustedTmuxPath(),
|
|
193
|
+
}) {
|
|
194
|
+
if (
|
|
195
|
+
!path.isAbsolute(home) ||
|
|
196
|
+
!path.isAbsolute(packageRoot) ||
|
|
197
|
+
!path.isAbsolute(nodePath) ||
|
|
198
|
+
(tmuxPath && !path.isAbsolute(tmuxPath))
|
|
199
|
+
)
|
|
200
|
+
throw new Error("Absolute paths required");
|
|
201
|
+
const wrapper = path.join(home, ".claude", "scripts", "tfx-peer");
|
|
202
|
+
const entry = path.join(packageRoot, "bin", "tfx-peer.mjs");
|
|
203
|
+
const script = `#!/bin/sh\nunset NODE_OPTIONS NODE_PATH PSMUX_BIN PSMUX_SHELL DYLD_INSERT_LIBRARIES LD_PRELOAD LD_LIBRARY_PATH\nPATH=${quote(`${tmuxPath ? `${path.dirname(tmuxPath)}:` : ""}/usr/bin:/bin:/usr/sbin:/sbin`)}\nexport PATH\nr=${quote(packageRoot)}\n[ "$(cd -P "$r" 2>/dev/null && pwd -P)" = "$r" ] && [ ! -e "$r/.git" ] || { echo 'tfx-peer: untrusted package tree' >&2; exit 126; }\nexec ${quote(nodePath)} ${quote(entry)} "$@"\n`;
|
|
204
|
+
const allow = `Bash(${wrapper} send:*)`;
|
|
205
|
+
const codexRule = `prefix_rule(pattern=[${JSON.stringify(wrapper)}, "send"], decision="allow")\n`;
|
|
206
|
+
return {
|
|
207
|
+
entry,
|
|
208
|
+
wrapper,
|
|
209
|
+
script,
|
|
210
|
+
allow,
|
|
211
|
+
codexRule,
|
|
212
|
+
settings: path.join(home, ".claude", "settings.json"),
|
|
213
|
+
rule: path.join(home, ".codex", "rules", "triflux-native-peer.rules"),
|
|
214
|
+
manifest: path.join(home, ".claude", "scripts", "tfx-peer-consent.json"),
|
|
215
|
+
contract: hash(
|
|
216
|
+
JSON.stringify({
|
|
217
|
+
wrapper,
|
|
218
|
+
entry,
|
|
219
|
+
nodePath,
|
|
220
|
+
tmuxPath,
|
|
221
|
+
script,
|
|
222
|
+
allow,
|
|
223
|
+
codexRule,
|
|
224
|
+
}),
|
|
225
|
+
),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
export function inspectPeerPermission({
|
|
229
|
+
home,
|
|
230
|
+
packageRoot,
|
|
231
|
+
nodePath,
|
|
232
|
+
tmuxPath,
|
|
233
|
+
platform = process.platform,
|
|
234
|
+
} = {}) {
|
|
235
|
+
const plan = peerPermissionPlan({ home, packageRoot, nodePath, tmuxPath });
|
|
236
|
+
if (platform === "win32") return { status: "unsupported", plan };
|
|
237
|
+
const effectiveNodePath = nodePath || resolveStableNodeBin();
|
|
238
|
+
if (
|
|
239
|
+
!trustedPackageTree(
|
|
240
|
+
packageRoot,
|
|
241
|
+
effectiveNodePath,
|
|
242
|
+
tmuxPath || trustedTmuxPath(),
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
return { status: "unavailable", plan };
|
|
246
|
+
const boundaries = [
|
|
247
|
+
home,
|
|
248
|
+
path.join(home, ".claude"),
|
|
249
|
+
path.join(home, ".claude", "scripts"),
|
|
250
|
+
path.join(home, ".codex"),
|
|
251
|
+
path.join(home, ".codex", "rules"),
|
|
252
|
+
];
|
|
253
|
+
if (
|
|
254
|
+
boundaries.some((dir) => {
|
|
255
|
+
const meta = existing(dir);
|
|
256
|
+
return (
|
|
257
|
+
meta &&
|
|
258
|
+
(!meta.isDirectory() ||
|
|
259
|
+
meta.isSymbolicLink() ||
|
|
260
|
+
meta.uid !== process.getuid() ||
|
|
261
|
+
(meta.mode & 0o022) !== 0)
|
|
262
|
+
);
|
|
263
|
+
})
|
|
264
|
+
)
|
|
265
|
+
return { status: "conflict", plan };
|
|
266
|
+
const entry = existing(plan.entry);
|
|
267
|
+
if (
|
|
268
|
+
!entry?.isFile() ||
|
|
269
|
+
entry.isSymbolicLink() ||
|
|
270
|
+
![0, process.getuid()].includes(entry.uid) ||
|
|
271
|
+
entry.mode & 0o002
|
|
272
|
+
)
|
|
273
|
+
return { status: "unavailable", plan };
|
|
274
|
+
if (
|
|
275
|
+
[plan.wrapper, plan.settings, plan.rule, plan.manifest].some((file) => {
|
|
276
|
+
const meta = existing(file);
|
|
277
|
+
return (
|
|
278
|
+
meta &&
|
|
279
|
+
(!meta.isFile() ||
|
|
280
|
+
meta.isSymbolicLink() ||
|
|
281
|
+
meta.uid !== process.getuid() ||
|
|
282
|
+
(meta.mode & 0o022) !== 0 ||
|
|
283
|
+
(file === plan.wrapper && (meta.mode & 0o777) !== 0o700))
|
|
284
|
+
);
|
|
285
|
+
})
|
|
286
|
+
)
|
|
287
|
+
return { status: "conflict", plan };
|
|
288
|
+
const previous = read(plan.manifest);
|
|
289
|
+
let manifest = null;
|
|
290
|
+
try {
|
|
291
|
+
manifest = previous ? JSON.parse(previous) : null;
|
|
292
|
+
} catch {
|
|
293
|
+
return { status: "modified", plan };
|
|
294
|
+
}
|
|
295
|
+
if (!manifest)
|
|
296
|
+
return {
|
|
297
|
+
status:
|
|
298
|
+
existing(plan.wrapper) || existing(plan.rule)
|
|
299
|
+
? "conflict"
|
|
300
|
+
: "unconfigured",
|
|
301
|
+
plan,
|
|
302
|
+
};
|
|
303
|
+
if (manifest.contract !== plan.contract)
|
|
304
|
+
return { status: "different-contract", plan };
|
|
305
|
+
const wrapper = read(plan.wrapper),
|
|
306
|
+
rule = read(plan.rule);
|
|
307
|
+
let settings;
|
|
308
|
+
try {
|
|
309
|
+
settings = JSON.parse(read(plan.settings) || "{}");
|
|
310
|
+
} catch {
|
|
311
|
+
return { status: "modified", plan };
|
|
312
|
+
}
|
|
313
|
+
if (
|
|
314
|
+
wrapper !== plan.script ||
|
|
315
|
+
rule !== plan.codexRule ||
|
|
316
|
+
!settings.permissions?.allow?.includes(plan.allow)
|
|
317
|
+
)
|
|
318
|
+
return { status: "modified", plan };
|
|
319
|
+
return { status: "approved", plan };
|
|
320
|
+
}
|
|
321
|
+
export function installPeerPermission({
|
|
322
|
+
home,
|
|
323
|
+
packageRoot,
|
|
324
|
+
nodePath,
|
|
325
|
+
tmuxPath,
|
|
326
|
+
consent = false,
|
|
327
|
+
platform = process.platform,
|
|
328
|
+
} = {}) {
|
|
329
|
+
const inspected = inspectPeerPermission({
|
|
330
|
+
home,
|
|
331
|
+
packageRoot,
|
|
332
|
+
nodePath,
|
|
333
|
+
tmuxPath,
|
|
334
|
+
platform,
|
|
335
|
+
});
|
|
336
|
+
const { plan, status } = inspected;
|
|
337
|
+
if (status === "approved") return { status: "approved", changed: false };
|
|
338
|
+
if (
|
|
339
|
+
status === "conflict" ||
|
|
340
|
+
status === "unavailable" ||
|
|
341
|
+
status === "unsupported"
|
|
342
|
+
)
|
|
343
|
+
return {
|
|
344
|
+
status,
|
|
345
|
+
changed: false,
|
|
346
|
+
error:
|
|
347
|
+
status === "unavailable"
|
|
348
|
+
? "Trusted peer adapter entry is absent or unsafe"
|
|
349
|
+
: "Existing peer approval files are not owned by this exact consent contract",
|
|
350
|
+
};
|
|
351
|
+
if (!consent)
|
|
352
|
+
return {
|
|
353
|
+
status,
|
|
354
|
+
changed: false,
|
|
355
|
+
preview: {
|
|
356
|
+
wrapper: plan.wrapper,
|
|
357
|
+
settings: plan.settings,
|
|
358
|
+
rule: plan.rule,
|
|
359
|
+
trustsPackageRoot: packageRoot,
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
const lock = `${plan.manifest}.lock`;
|
|
363
|
+
fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
|
|
364
|
+
let lockHandle;
|
|
365
|
+
try {
|
|
366
|
+
lockHandle = fs.openSync(lock, "wx", 0o600);
|
|
367
|
+
} catch {
|
|
368
|
+
return {
|
|
369
|
+
status: "busy",
|
|
370
|
+
changed: false,
|
|
371
|
+
error: "Peer approval install already in progress",
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const lockInode = fs.fstatSync(lockHandle).ino;
|
|
375
|
+
try {
|
|
376
|
+
const fresh = inspectPeerPermission({
|
|
377
|
+
home,
|
|
378
|
+
packageRoot,
|
|
379
|
+
nodePath,
|
|
380
|
+
tmuxPath,
|
|
381
|
+
platform,
|
|
382
|
+
});
|
|
383
|
+
if (fresh.status !== status)
|
|
384
|
+
return {
|
|
385
|
+
status: "conflict",
|
|
386
|
+
changed: false,
|
|
387
|
+
error: "Peer approval changed before locked install",
|
|
388
|
+
};
|
|
389
|
+
const settingsBefore = read(plan.settings);
|
|
390
|
+
const settingsMeta = existing(plan.settings);
|
|
391
|
+
let settings;
|
|
392
|
+
try {
|
|
393
|
+
settings = JSON.parse(settingsBefore || "{}");
|
|
394
|
+
} catch {
|
|
395
|
+
return {
|
|
396
|
+
status: "failed",
|
|
397
|
+
changed: false,
|
|
398
|
+
error: "Claude settings JSON invalid",
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (
|
|
402
|
+
settings.permissions &&
|
|
403
|
+
!Array.isArray(settings.permissions.allow) &&
|
|
404
|
+
settings.permissions.allow !== undefined
|
|
405
|
+
)
|
|
406
|
+
return {
|
|
407
|
+
status: "failed",
|
|
408
|
+
changed: false,
|
|
409
|
+
error: "Claude allow is not an array",
|
|
410
|
+
};
|
|
411
|
+
if (
|
|
412
|
+
(existing(plan.wrapper) && read(plan.wrapper) !== plan.script) ||
|
|
413
|
+
(existing(plan.rule) && read(plan.rule) !== plan.codexRule)
|
|
414
|
+
)
|
|
415
|
+
return {
|
|
416
|
+
status: "conflict",
|
|
417
|
+
changed: false,
|
|
418
|
+
error:
|
|
419
|
+
"Existing peer wrapper or rule was modified; remove it explicitly before regrant",
|
|
420
|
+
};
|
|
421
|
+
const allow = new Set(settings.permissions?.allow || []);
|
|
422
|
+
allow.add(plan.allow);
|
|
423
|
+
settings.permissions = {
|
|
424
|
+
...(settings.permissions || {}),
|
|
425
|
+
allow: [...allow],
|
|
426
|
+
};
|
|
427
|
+
const steps = [];
|
|
428
|
+
try {
|
|
429
|
+
if (
|
|
430
|
+
read(plan.settings) !== settingsBefore ||
|
|
431
|
+
existing(plan.settings)?.ino !== settingsMeta?.ino
|
|
432
|
+
)
|
|
433
|
+
return {
|
|
434
|
+
status: "conflict",
|
|
435
|
+
changed: false,
|
|
436
|
+
error: "Claude settings changed before install",
|
|
437
|
+
};
|
|
438
|
+
let settingsBackup = null;
|
|
439
|
+
for (const file of [
|
|
440
|
+
plan.wrapper,
|
|
441
|
+
plan.settings,
|
|
442
|
+
plan.rule,
|
|
443
|
+
plan.manifest,
|
|
444
|
+
])
|
|
445
|
+
if (file === plan.settings) settingsBackup = backup(file);
|
|
446
|
+
else backup(file);
|
|
447
|
+
atomic(plan.wrapper, plan.script, 0o700);
|
|
448
|
+
steps.push("wrapper");
|
|
449
|
+
if (
|
|
450
|
+
read(plan.settings) !== settingsBefore ||
|
|
451
|
+
existing(plan.settings)?.ino !== settingsMeta?.ino
|
|
452
|
+
)
|
|
453
|
+
throw new Error(
|
|
454
|
+
"Claude settings changed during install; existing deny preserved",
|
|
455
|
+
);
|
|
456
|
+
atomic(plan.settings, `${JSON.stringify(settings, null, 2)}\n`);
|
|
457
|
+
steps.push("claude");
|
|
458
|
+
atomic(plan.rule, plan.codexRule);
|
|
459
|
+
steps.push("codex");
|
|
460
|
+
atomic(
|
|
461
|
+
plan.manifest,
|
|
462
|
+
`${JSON.stringify({ contract: plan.contract, packageRoot, wrapper: plan.wrapper, allow: plan.allow, rule: plan.rule }, null, 2)}\n`,
|
|
463
|
+
);
|
|
464
|
+
steps.push("manifest");
|
|
465
|
+
try {
|
|
466
|
+
keepOnlyLatestSettingsBackup(plan.settings, settingsBackup);
|
|
467
|
+
} catch {}
|
|
468
|
+
return { status: "approved", changed: true, steps };
|
|
469
|
+
} catch (error) {
|
|
470
|
+
return {
|
|
471
|
+
status: "partial",
|
|
472
|
+
changed: steps.length > 0,
|
|
473
|
+
retrySafe: false,
|
|
474
|
+
steps,
|
|
475
|
+
error: error.message,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
} finally {
|
|
479
|
+
fs.closeSync(lockHandle);
|
|
480
|
+
try {
|
|
481
|
+
if (fs.lstatSync(lock).ino === lockInode) fs.unlinkSync(lock);
|
|
482
|
+
} catch {}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
export async function offerPeerPermission({
|
|
486
|
+
home,
|
|
487
|
+
packageRoot,
|
|
488
|
+
interactive = false,
|
|
489
|
+
fromUpdate = false,
|
|
490
|
+
verbose = false,
|
|
491
|
+
ask = async () => false,
|
|
492
|
+
notice = () => {},
|
|
493
|
+
warn = () => {},
|
|
494
|
+
nodePath,
|
|
495
|
+
tmuxPath,
|
|
496
|
+
platform = process.platform,
|
|
497
|
+
} = {}) {
|
|
498
|
+
const state = inspectPeerPermission({
|
|
499
|
+
home,
|
|
500
|
+
packageRoot,
|
|
501
|
+
nodePath,
|
|
502
|
+
tmuxPath,
|
|
503
|
+
platform,
|
|
504
|
+
});
|
|
505
|
+
if (state.status === "unsupported")
|
|
506
|
+
return { status: "unsupported", changed: false };
|
|
507
|
+
if (state.status === "approved")
|
|
508
|
+
return { status: "approved", changed: false };
|
|
509
|
+
if (fromUpdate) {
|
|
510
|
+
if (verbose)
|
|
511
|
+
notice(
|
|
512
|
+
`Native peer adapter: ${state.status}; update는 승인 설정을 바꾸지 않는다. 재승인은 tfx setup에서 명시적으로 선택한다.`,
|
|
513
|
+
);
|
|
514
|
+
return { status: state.status, changed: false };
|
|
515
|
+
}
|
|
516
|
+
const cleanup = `확인 대상: ${state.plan.wrapper}, ${state.plan.rule}, ${state.plan.manifest}, ${state.plan.manifest}.lock. Claude ${state.plan.settings}의 permissions.allow에서는 ${state.plan.allow} 항목만 확인한다. settings 전체나 다른 allow/deny는 삭제하지 않는다.`;
|
|
517
|
+
if (state.status === "conflict" || state.status === "unavailable") {
|
|
518
|
+
if (state.status === "conflict")
|
|
519
|
+
warn(
|
|
520
|
+
`Peer 승인 파일이 충돌한다. ${cleanup} 해당 어댑터 항목만 직접 정리한 뒤 재동의한다. 자동 삭제하지 않는다.`,
|
|
521
|
+
);
|
|
522
|
+
else {
|
|
523
|
+
notice(
|
|
524
|
+
"신뢰할 수 있는 설치본과 Node 런타임 경로가 없어 native peer 전역 승인은 불가하다. 권한을 완화하지 않는다.",
|
|
525
|
+
);
|
|
526
|
+
notice(
|
|
527
|
+
"이미 수동 설정한 ~/.codex/rules/tfx-live.rules는 기존 tfx-live ask/queue/tmux 릴레이에 쓸 수 있다. native tfx-peer 전송이나 동일 승인으로 취급하지 않는다.",
|
|
528
|
+
);
|
|
529
|
+
if (existing(state.plan.manifest))
|
|
530
|
+
notice(
|
|
531
|
+
`기존 승인은 설정에 남아 있을 수 있다. Claude ${state.plan.allow}와 Codex ${state.plan.rule}을 확인해 수동 철회한다. 자동 삭제하지 않는다.`,
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return { status: state.status, changed: false };
|
|
535
|
+
}
|
|
536
|
+
if (!interactive) {
|
|
537
|
+
notice(
|
|
538
|
+
"Native peer adapter 전역 승인은 선택 사항이다. tfx setup 에서 설정할 수 있다.",
|
|
539
|
+
);
|
|
540
|
+
return { status: "deferred", changed: false };
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
const settings = JSON.parse(read(state.plan.settings) || "{}");
|
|
544
|
+
if (
|
|
545
|
+
settings.permissions?.classifyAllShell === true ||
|
|
546
|
+
settings.classifyAllShell === true
|
|
547
|
+
)
|
|
548
|
+
notice(
|
|
549
|
+
"Claude classifyAllShell=true이며 관리형 deny도 이 승인보다 우선할 수 있다. 기존 설정은 바꾸지 않는다.",
|
|
550
|
+
);
|
|
551
|
+
} catch {}
|
|
552
|
+
notice(
|
|
553
|
+
`승인 예정: ${state.plan.wrapper} send, ${state.plan.settings}, ${state.plan.rule}. 이 경로의 향후 Triflux 패키지 업데이트도 신뢰한다. Bash/node 전체 권한은 확대하지 않는다.`,
|
|
554
|
+
);
|
|
555
|
+
if (
|
|
556
|
+
!(await ask(
|
|
557
|
+
"Claude·Codex에서 tfx-peer send 어댑터만 전역 자동 승인할까요?",
|
|
558
|
+
))
|
|
559
|
+
)
|
|
560
|
+
return { status: "declined", changed: false };
|
|
561
|
+
const result = installPeerPermission({
|
|
562
|
+
home,
|
|
563
|
+
packageRoot,
|
|
564
|
+
nodePath,
|
|
565
|
+
tmuxPath,
|
|
566
|
+
platform,
|
|
567
|
+
consent: true,
|
|
568
|
+
});
|
|
569
|
+
if (result.status === "busy")
|
|
570
|
+
warn(
|
|
571
|
+
`Peer adapter 승인 설치 잠금: ${state.plan.manifest}.lock. 실행 중인 설치가 없는지 확인한 뒤 직접 정리한다. 자동 삭제하지 않는다.`,
|
|
572
|
+
);
|
|
573
|
+
if (result.status === "conflict" || result.status === "partial")
|
|
574
|
+
warn(`Peer adapter 승인 ${result.status}: ${result.error}. ${cleanup}`);
|
|
575
|
+
else if (result.status === "failed")
|
|
576
|
+
warn(`Peer adapter 승인 실패: ${result.error}`);
|
|
577
|
+
return result;
|
|
578
|
+
}
|