@anusornneal/chat-relay 0.6.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/README.md +230 -0
- package/agent/file-manager.mjs +265 -0
- package/agent/local-agent.mjs +157 -0
- package/agent/process-manager.mjs +69 -0
- package/agent/terminal-manager.mjs +229 -0
- package/bin/chat-relay.js +5 -0
- package/cli/config.mjs +113 -0
- package/cli/device-login.mjs +168 -0
- package/cli/index.mjs +145 -0
- package/cli/remote.mjs +88 -0
- package/package.json +65 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
|
|
4
|
+
function execPowerShell(script, timeout = 15000) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
execFile(
|
|
7
|
+
"powershell.exe",
|
|
8
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
9
|
+
{ timeout, windowsHide: true, maxBuffer: 256 * 1024 },
|
|
10
|
+
(error, stdout, stderr) => {
|
|
11
|
+
if (error) {
|
|
12
|
+
reject(new Error(stderr?.trim() || error.message));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
resolve(stdout);
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ProcessManager {
|
|
22
|
+
async list(filter = "") {
|
|
23
|
+
const escaped = String(filter).replace(/'/g, "''");
|
|
24
|
+
const where = escaped
|
|
25
|
+
? ` | Where-Object { $_.Name -like '*${escaped}*' -or $_.CommandLine -like '*${escaped}*' }`
|
|
26
|
+
: "";
|
|
27
|
+
const script =
|
|
28
|
+
"$items = Get-CimInstance Win32_Process" + where +
|
|
29
|
+
" | Select-Object ProcessId,Name,CommandLine,ExecutablePath;" +
|
|
30
|
+
"$items | ConvertTo-Json -Compress -Depth 3";
|
|
31
|
+
const stdout = await execPowerShell(script);
|
|
32
|
+
if (!stdout.trim()) return [];
|
|
33
|
+
const parsed = JSON.parse(stdout);
|
|
34
|
+
return Array.isArray(parsed) ? parsed : [parsed];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async kill(pid) {
|
|
38
|
+
const value = Number(pid);
|
|
39
|
+
if (!Number.isInteger(value) || value <= 100 || value === process.pid) {
|
|
40
|
+
throw new Error("invalid_or_protected_pid");
|
|
41
|
+
}
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
execFile("taskkill.exe", ["/PID", String(value), "/T", "/F"], { windowsHide: true }, (error, stdout, stderr) => {
|
|
44
|
+
if (error) {
|
|
45
|
+
reject(new Error(stderr?.trim() || error.message));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
resolve({ ok: true, pid: value, output: stdout.trim() });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
info(extra = {}) {
|
|
54
|
+
return {
|
|
55
|
+
platform: process.platform,
|
|
56
|
+
arch: process.arch,
|
|
57
|
+
nodeVersion: process.version,
|
|
58
|
+
hostname: os.hostname(),
|
|
59
|
+
username: os.userInfo().username,
|
|
60
|
+
homeDir: os.homedir(),
|
|
61
|
+
cwd: process.cwd(),
|
|
62
|
+
cpus: os.cpus().length,
|
|
63
|
+
totalMemoryBytes: os.totalmem(),
|
|
64
|
+
freeMemoryBytes: os.freemem(),
|
|
65
|
+
uptimeSeconds: os.uptime(),
|
|
66
|
+
...extra,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
const MAX_EXEC_BUFFER = 48 * 1024;
|
|
6
|
+
const MAX_SESSION_BUFFER = 256 * 1024;
|
|
7
|
+
const MAX_READ_CHARS = 24 * 1024;
|
|
8
|
+
const MAX_INPUT_CHARS = 8 * 1024;
|
|
9
|
+
const MAX_SESSIONS = 8;
|
|
10
|
+
const ENDED_TTL_MS = 30 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
const BLOCKED_COMMANDS = [
|
|
13
|
+
/\b(mkfs|format|mount|umount|fdisk|dd|parted|diskpart|sudo|su|passwd)\b/i,
|
|
14
|
+
/\b(adduser|useradd|usermod|groupadd|chsh|visudo|shutdown|reboot|halt)\b/i,
|
|
15
|
+
/\b(poweroff|init|iptables|firewall|netsh|sfc|bcdedit|runas|cipher|takeown)\b/i,
|
|
16
|
+
/\b(reg|sc)\.exe\b/i,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function validateCommand(command) {
|
|
20
|
+
if (typeof command !== "string" || command.length === 0 || command.length > 4000) {
|
|
21
|
+
throw new Error("invalid_command");
|
|
22
|
+
}
|
|
23
|
+
if (BLOCKED_COMMANDS.some((pattern) => pattern.test(command))) {
|
|
24
|
+
throw new Error("command_blocked");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeTimeout(timeoutMs, fallback = 15000, max = 20000) {
|
|
29
|
+
const value = Number(timeoutMs);
|
|
30
|
+
if (!Number.isFinite(value)) return fallback;
|
|
31
|
+
return Math.min(Math.max(Math.trunc(value), 1000), max);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function safeCwd(cwd) {
|
|
35
|
+
if (cwd === undefined || cwd === null || cwd === "") return process.cwd();
|
|
36
|
+
if (typeof cwd !== "string" || cwd.length > 1024) throw new Error("invalid_cwd");
|
|
37
|
+
return cwd;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class TerminalManager {
|
|
41
|
+
constructor() {
|
|
42
|
+
this.sessions = new Map();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async exec(command, cwd, timeoutMs) {
|
|
46
|
+
validateCommand(command);
|
|
47
|
+
const resolvedCwd = safeCwd(cwd);
|
|
48
|
+
const timeout = normalizeTimeout(timeoutMs);
|
|
49
|
+
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
execFile(
|
|
52
|
+
"powershell.exe",
|
|
53
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command],
|
|
54
|
+
{
|
|
55
|
+
cwd: resolvedCwd,
|
|
56
|
+
timeout,
|
|
57
|
+
windowsHide: true,
|
|
58
|
+
maxBuffer: MAX_EXEC_BUFFER,
|
|
59
|
+
},
|
|
60
|
+
(error, stdout, stderr) => {
|
|
61
|
+
resolve({
|
|
62
|
+
ok: !error,
|
|
63
|
+
exitCode: typeof error?.code === "number" ? error.code : (error ? 1 : 0),
|
|
64
|
+
stdout: stdout ?? "",
|
|
65
|
+
stderr: stderr ?? "",
|
|
66
|
+
error: error ? error.message : null,
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
start(command, cwd) {
|
|
74
|
+
validateCommand(command);
|
|
75
|
+
return this.#spawnSession({
|
|
76
|
+
type: "command",
|
|
77
|
+
command,
|
|
78
|
+
cwd: safeCwd(cwd),
|
|
79
|
+
args: ["-NoLogo", "-NoProfile", "-Command", command],
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
startShell(cwd) {
|
|
84
|
+
return this.#spawnSession({
|
|
85
|
+
type: "shell",
|
|
86
|
+
command: "powershell",
|
|
87
|
+
cwd: safeCwd(cwd),
|
|
88
|
+
args: ["-NoLogo", "-NoProfile", "-NoExit", "-Command", "-"],
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
list() {
|
|
93
|
+
this.#cleanup();
|
|
94
|
+
return [...this.sessions.values()].map((session) => this.#summary(session));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
read(sessionId, afterSeq = 0, maxChars = MAX_READ_CHARS) {
|
|
98
|
+
this.#cleanup();
|
|
99
|
+
const session = this.#get(sessionId);
|
|
100
|
+
const limit = Math.min(Math.max(Number(maxChars) || MAX_READ_CHARS, 1024), MAX_READ_CHARS);
|
|
101
|
+
const oldestSeq = session.chunks[0]?.seq ?? session.nextSeq;
|
|
102
|
+
const chunks = [];
|
|
103
|
+
let chars = 0;
|
|
104
|
+
|
|
105
|
+
for (const chunk of session.chunks) {
|
|
106
|
+
if (chunk.seq <= afterSeq) continue;
|
|
107
|
+
if (chunks.length > 0 && chars + chunk.text.length > limit) break;
|
|
108
|
+
chunks.push(chunk);
|
|
109
|
+
chars += chunk.text.length;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
...this.#summary(session),
|
|
114
|
+
chunks,
|
|
115
|
+
oldestSeq,
|
|
116
|
+
nextSeq: chunks.at(-1)?.seq ?? afterSeq,
|
|
117
|
+
truncated: afterSeq < oldestSeq - 1,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
write(sessionId, input, appendNewline = true) {
|
|
122
|
+
const session = this.#get(sessionId);
|
|
123
|
+
if (session.status !== "running") throw new Error("session_not_running");
|
|
124
|
+
if (typeof input !== "string" || input.length === 0 || input.length > MAX_INPUT_CHARS) {
|
|
125
|
+
throw new Error("invalid_input");
|
|
126
|
+
}
|
|
127
|
+
session.process.stdin.write(input + (appendNewline ? os.EOL : ""));
|
|
128
|
+
return this.#summary(session);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async kill(sessionId) {
|
|
132
|
+
const session = this.#get(sessionId);
|
|
133
|
+
if (session.status !== "running") return this.#summary(session);
|
|
134
|
+
|
|
135
|
+
await new Promise((resolve) => {
|
|
136
|
+
execFile("taskkill.exe", ["/PID", String(session.pid), "/T", "/F"], () => resolve());
|
|
137
|
+
});
|
|
138
|
+
return this.#summary(session);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#spawnSession({ type, command, cwd, args }) {
|
|
142
|
+
this.#cleanup();
|
|
143
|
+
const running = [...this.sessions.values()].filter((session) => session.status === "running").length;
|
|
144
|
+
if (running >= MAX_SESSIONS) throw new Error("too_many_sessions");
|
|
145
|
+
|
|
146
|
+
const child = spawn("powershell.exe", args, {
|
|
147
|
+
cwd,
|
|
148
|
+
windowsHide: true,
|
|
149
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const session = {
|
|
153
|
+
id: randomUUID(),
|
|
154
|
+
type,
|
|
155
|
+
command,
|
|
156
|
+
cwd,
|
|
157
|
+
pid: child.pid,
|
|
158
|
+
process: child,
|
|
159
|
+
status: "running",
|
|
160
|
+
exitCode: null,
|
|
161
|
+
signal: null,
|
|
162
|
+
startedAt: new Date().toISOString(),
|
|
163
|
+
endedAt: null,
|
|
164
|
+
chunks: [],
|
|
165
|
+
nextSeq: 1,
|
|
166
|
+
totalChars: 0,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
this.sessions.set(session.id, session);
|
|
170
|
+
child.stdout.on("data", (data) => this.#append(session, "stdout", data));
|
|
171
|
+
child.stderr.on("data", (data) => this.#append(session, "stderr", data));
|
|
172
|
+
child.on("error", (error) => this.#append(session, "stderr", error.message + os.EOL));
|
|
173
|
+
child.on("exit", (code, signal) => {
|
|
174
|
+
session.status = "exited";
|
|
175
|
+
session.exitCode = code;
|
|
176
|
+
session.signal = signal;
|
|
177
|
+
session.endedAt = new Date().toISOString();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return this.#summary(session);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
#append(session, stream, data) {
|
|
184
|
+
const text = data.toString();
|
|
185
|
+
for (let offset = 0; offset < text.length; offset += 4096) {
|
|
186
|
+
const part = text.slice(offset, offset + 4096);
|
|
187
|
+
session.chunks.push({ seq: session.nextSeq++, stream, text: part });
|
|
188
|
+
session.totalChars += part.length;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
while (session.totalChars > MAX_SESSION_BUFFER && session.chunks.length > 1) {
|
|
192
|
+
const removed = session.chunks.shift();
|
|
193
|
+
session.totalChars -= removed.text.length;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#summary(session) {
|
|
198
|
+
return {
|
|
199
|
+
sessionId: session.id,
|
|
200
|
+
type: session.type,
|
|
201
|
+
command: session.command,
|
|
202
|
+
cwd: session.cwd,
|
|
203
|
+
pid: session.pid,
|
|
204
|
+
status: session.status,
|
|
205
|
+
exitCode: session.exitCode,
|
|
206
|
+
signal: session.signal,
|
|
207
|
+
startedAt: session.startedAt,
|
|
208
|
+
endedAt: session.endedAt,
|
|
209
|
+
nextSeq: session.nextSeq - 1,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#get(sessionId) {
|
|
214
|
+
if (typeof sessionId !== "string") throw new Error("invalid_session_id");
|
|
215
|
+
const session = this.sessions.get(sessionId);
|
|
216
|
+
if (!session) throw new Error("session_not_found");
|
|
217
|
+
return session;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
#cleanup() {
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
for (const [id, session] of this.sessions) {
|
|
223
|
+
if (session.status === "running" || !session.endedAt) continue;
|
|
224
|
+
if (now - Date.parse(session.endedAt) > ENDED_TTL_MS) {
|
|
225
|
+
this.sessions.delete(id);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
package/cli/config.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export function configDir() {
|
|
6
|
+
if (process.env.CHAT_RELAY_HOME) return path.resolve(process.env.CHAT_RELAY_HOME);
|
|
7
|
+
if (process.platform === "win32") {
|
|
8
|
+
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
9
|
+
return path.join(base, "chat-relay");
|
|
10
|
+
}
|
|
11
|
+
const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
12
|
+
return path.join(base, "chat-relay");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function configPath() {
|
|
16
|
+
return path.join(configDir(), "config.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function identityPath() {
|
|
20
|
+
return path.join(configDir(), "identity.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadIdentity() {
|
|
24
|
+
const file = identityPath();
|
|
25
|
+
if (!fs.existsSync(file)) return null;
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function saveIdentity(identity) {
|
|
34
|
+
const dir = configDir();
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
36
|
+
const file = identityPath();
|
|
37
|
+
fs.writeFileSync(file, JSON.stringify(identity, null, 2) + os.EOL, {
|
|
38
|
+
encoding: "utf8",
|
|
39
|
+
mode: 0o600,
|
|
40
|
+
});
|
|
41
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
42
|
+
return file;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseEnvFile(file) {
|
|
46
|
+
const result = {};
|
|
47
|
+
for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
48
|
+
const line = raw.trim();
|
|
49
|
+
if (!line || line.startsWith("#") || !line.includes("=")) continue;
|
|
50
|
+
const index = line.indexOf("=");
|
|
51
|
+
result[line.slice(0, index)] = line.slice(index + 1);
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function migrateLegacyConfig() {
|
|
57
|
+
const legacyPath = path.resolve(".dev.vars");
|
|
58
|
+
if (!fs.existsSync(legacyPath)) return null;
|
|
59
|
+
const legacy = parseEnvFile(legacyPath);
|
|
60
|
+
if (!legacy.RELAY_URL || !legacy.CALLER_TOKEN || !legacy.AGENT_TOKEN) return null;
|
|
61
|
+
|
|
62
|
+
const config = {
|
|
63
|
+
relayUrl: legacy.RELAY_URL.replace(/\/$/, ""),
|
|
64
|
+
user: { id: "owner", name: "Owner", login: null },
|
|
65
|
+
userToken: legacy.CALLER_TOKEN,
|
|
66
|
+
agentId: legacy.AGENT_ID || "default",
|
|
67
|
+
agentName: legacy.AGENT_NAME || "Primary PC",
|
|
68
|
+
agentToken: legacy.AGENT_TOKEN,
|
|
69
|
+
allowedRoots: legacy.ALLOWED_ROOTS || defaultAllowedRoot(),
|
|
70
|
+
terminalEnabled: legacy.TERMINAL_ENABLED !== "0",
|
|
71
|
+
migratedAt: new Date().toISOString(),
|
|
72
|
+
};
|
|
73
|
+
saveConfig(config);
|
|
74
|
+
saveIdentity({
|
|
75
|
+
agentId: config.agentId,
|
|
76
|
+
createdAt: new Date().toISOString(),
|
|
77
|
+
updatedAt: new Date().toISOString(),
|
|
78
|
+
});
|
|
79
|
+
return config;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function loadConfig() {
|
|
83
|
+
const file = configPath();
|
|
84
|
+
if (!fs.existsSync(file)) return migrateLegacyConfig();
|
|
85
|
+
try {
|
|
86
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
87
|
+
} catch {
|
|
88
|
+
throw new Error(`Invalid Chat Relay config: ${file}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function saveConfig(config) {
|
|
93
|
+
const dir = configDir();
|
|
94
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
95
|
+
const file = configPath();
|
|
96
|
+
fs.writeFileSync(file, JSON.stringify(config, null, 2) + os.EOL, {
|
|
97
|
+
encoding: "utf8",
|
|
98
|
+
mode: 0o600,
|
|
99
|
+
});
|
|
100
|
+
try { fs.chmodSync(file, 0o600); } catch {}
|
|
101
|
+
return file;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function clearConfig() {
|
|
105
|
+
const file = configPath();
|
|
106
|
+
if (fs.existsSync(file)) fs.rmSync(file, { force: true });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function defaultAllowedRoot() {
|
|
110
|
+
const projects = path.join(os.homedir(), "Projects");
|
|
111
|
+
if (fs.existsSync(projects)) return projects;
|
|
112
|
+
return os.homedir();
|
|
113
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import {
|
|
5
|
+
defaultAllowedRoot,
|
|
6
|
+
loadConfig,
|
|
7
|
+
loadIdentity,
|
|
8
|
+
saveConfig,
|
|
9
|
+
saveIdentity,
|
|
10
|
+
} from "./config.mjs";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_RELAY_URL = "https://chat-relay.anusorn-hank.workers.dev";
|
|
13
|
+
|
|
14
|
+
function sanitizeAgentId(value) {
|
|
15
|
+
const normalized = value.toLowerCase().trim()
|
|
16
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
17
|
+
.replace(/^-+|-+$/g, "")
|
|
18
|
+
.slice(0, 54) || "machine";
|
|
19
|
+
return normalized;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createAgentId(hostname) {
|
|
23
|
+
return `${sanitizeAgentId(hostname)}-${randomBytes(4).toString("hex")}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function requestJson(url, options = {}, timeoutMs = 10_000) {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(url, {
|
|
31
|
+
...options,
|
|
32
|
+
signal: controller.signal,
|
|
33
|
+
headers: {
|
|
34
|
+
accept: "application/json",
|
|
35
|
+
...(options.body ? { "content-type": "application/json" } : {}),
|
|
36
|
+
...(options.headers || {}),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const text = await response.text();
|
|
40
|
+
let data;
|
|
41
|
+
try { data = text ? JSON.parse(text) : {}; }
|
|
42
|
+
catch { data = { raw: text }; }
|
|
43
|
+
return { response, data };
|
|
44
|
+
} finally {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function openBrowser(url) {
|
|
50
|
+
if (process.env.CHAT_RELAY_NO_BROWSER === "1") return false;
|
|
51
|
+
try {
|
|
52
|
+
if (process.platform === "win32") {
|
|
53
|
+
const child = spawn("cmd.exe", ["/c", "start", "", url], {
|
|
54
|
+
detached: true,
|
|
55
|
+
stdio: "ignore",
|
|
56
|
+
windowsHide: true,
|
|
57
|
+
});
|
|
58
|
+
child.unref();
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
if (process.platform === "darwin") {
|
|
62
|
+
const child = spawn("open", [url], { detached: true, stdio: "ignore" });
|
|
63
|
+
child.unref();
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
67
|
+
child.unref();
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function login(options = {}) {
|
|
75
|
+
const existing = loadConfig() || {};
|
|
76
|
+
const relayUrl = String(options.relayUrl || existing.relayUrl || DEFAULT_RELAY_URL).replace(/\/$/, "");
|
|
77
|
+
const hostname = os.hostname();
|
|
78
|
+
const identity = loadIdentity() || {};
|
|
79
|
+
const requestedAgentId = options.agentId ||
|
|
80
|
+
existing.agentId ||
|
|
81
|
+
identity.agentId ||
|
|
82
|
+
createAgentId(hostname);
|
|
83
|
+
const agentName = options.agentName || existing.agentName || hostname;
|
|
84
|
+
const allowedRoots = options.allowedRoot || existing.allowedRoots || defaultAllowedRoot();
|
|
85
|
+
|
|
86
|
+
saveIdentity({
|
|
87
|
+
agentId: requestedAgentId,
|
|
88
|
+
createdAt: identity.createdAt || new Date().toISOString(),
|
|
89
|
+
updatedAt: new Date().toISOString(),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const { response, data } = await requestJson(`${relayUrl}/auth/device/start`, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
body: JSON.stringify({ agentId: requestedAgentId, agentName }),
|
|
95
|
+
});
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
throw new Error(`Unable to start device login: ${data.error || response.status}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log("");
|
|
101
|
+
console.log("Chat Relay sign in");
|
|
102
|
+
console.log("------------------");
|
|
103
|
+
console.log(`Code: ${data.userCode}`);
|
|
104
|
+
console.log(`Open: ${data.verificationUriComplete}`);
|
|
105
|
+
console.log("");
|
|
106
|
+
|
|
107
|
+
const opened = options.noOpen ? false : openBrowser(data.verificationUriComplete);
|
|
108
|
+
console.log(opened ? "Browser opened. Waiting for authorization..." : "Open the URL above to continue.");
|
|
109
|
+
|
|
110
|
+
const deadline = Date.now() + Number(data.expiresIn || 600) * 1000;
|
|
111
|
+
const intervalMs = Math.max(Number(data.interval || 2), 1) * 1000;
|
|
112
|
+
|
|
113
|
+
while (Date.now() < deadline) {
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
115
|
+
const tokenResult = await requestJson(`${relayUrl}/auth/device/token`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
body: JSON.stringify({ deviceCode: data.deviceCode }),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (tokenResult.response.status === 428 && tokenResult.data.error === "authorization_pending") {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!tokenResult.response.ok) {
|
|
124
|
+
throw new Error(`Device login failed: ${tokenResult.data.error || tokenResult.response.status}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const result = tokenResult.data;
|
|
128
|
+
|
|
129
|
+
if (existing.userToken && existing.relayUrl) {
|
|
130
|
+
const revoke = await requestJson(`${String(existing.relayUrl).replace(/\/$/, "")}/auth/session/revoke`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { authorization: `Bearer ${existing.userToken}` },
|
|
133
|
+
}).catch(() => null);
|
|
134
|
+
if (revoke && !revoke.response.ok && revoke.response.status !== 401) {
|
|
135
|
+
console.warn("Warning: previous Chat Relay session could not be revoked.");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
saveIdentity({
|
|
140
|
+
agentId: result.agent.id,
|
|
141
|
+
createdAt: identity.createdAt || new Date().toISOString(),
|
|
142
|
+
updatedAt: new Date().toISOString(),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const config = {
|
|
146
|
+
relayUrl,
|
|
147
|
+
user: result.user,
|
|
148
|
+
userToken: result.userToken,
|
|
149
|
+
userTokenExpiresAt: result.userTokenExpiresAt,
|
|
150
|
+
agentId: result.agent.id,
|
|
151
|
+
agentName: result.agent.name,
|
|
152
|
+
agentToken: result.agentToken,
|
|
153
|
+
allowedRoots,
|
|
154
|
+
terminalEnabled: true,
|
|
155
|
+
loggedInAt: new Date().toISOString(),
|
|
156
|
+
};
|
|
157
|
+
const file = saveConfig(config);
|
|
158
|
+
|
|
159
|
+
console.log(`Signed in as ${result.user.name}.`);
|
|
160
|
+
console.log(`Registered ${result.agent.name} (${result.agent.id}).`);
|
|
161
|
+
console.log(`Saved credentials to ${file}`);
|
|
162
|
+
return config;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
throw new Error("Device login expired. Run the login command again.");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export { DEFAULT_RELAY_URL, requestJson };
|