@agent-idle/cli 0.1.2 → 0.1.4
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/index.js +187 -48
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -177,6 +177,18 @@ function resolveConvexUrl() {
|
|
|
177
177
|
}
|
|
178
178
|
var DEFAULT_CONVEX_URL = resolveConvexUrl();
|
|
179
179
|
var AUTH_URL = process.env.AGENT_IDLE_AUTH_URL ?? BUILD_AUTH_URL ?? "http://localhost:1420";
|
|
180
|
+
function cliAuthUrl(userCode2) {
|
|
181
|
+
try {
|
|
182
|
+
const url = new URL(AUTH_URL);
|
|
183
|
+
url.searchParams.set("login", "1");
|
|
184
|
+
if (userCode2) url.searchParams.set("code", userCode2);
|
|
185
|
+
return url.toString();
|
|
186
|
+
} catch {
|
|
187
|
+
const join2 = AUTH_URL.includes("?") ? "&" : "?";
|
|
188
|
+
const code = userCode2 ? `&code=${encodeURIComponent(userCode2)}` : "";
|
|
189
|
+
return `${AUTH_URL}${join2}login=1${code}`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
180
192
|
function ensureDir(path, mode) {
|
|
181
193
|
const dir = dirname(path);
|
|
182
194
|
mkdirSync(dir, { recursive: true, ...mode !== void 0 ? { mode } : {} });
|
|
@@ -210,6 +222,41 @@ function clearToken() {
|
|
|
210
222
|
}
|
|
211
223
|
}
|
|
212
224
|
|
|
225
|
+
// src/daemonControl.ts
|
|
226
|
+
import { spawn } from "child_process";
|
|
227
|
+
import { statSync } from "fs";
|
|
228
|
+
import { fileURLToPath } from "url";
|
|
229
|
+
function buildFingerprint() {
|
|
230
|
+
const script = fileURLToPath(new URL("./index.js", import.meta.url));
|
|
231
|
+
try {
|
|
232
|
+
return { script, mtimeMs: statSync(script).mtimeMs };
|
|
233
|
+
} catch {
|
|
234
|
+
return { script, mtimeMs: 0 };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function isNewerBuild(callerMtimeMs, own) {
|
|
238
|
+
return own.mtimeMs > 0 && Number.isFinite(callerMtimeMs) && callerMtimeMs > own.mtimeMs + 1;
|
|
239
|
+
}
|
|
240
|
+
function spawnDaemon() {
|
|
241
|
+
const indexPath = fileURLToPath(new URL("./index.js", import.meta.url));
|
|
242
|
+
const child = spawn(process.execPath, [indexPath, "daemon"], {
|
|
243
|
+
detached: true,
|
|
244
|
+
stdio: "ignore"
|
|
245
|
+
});
|
|
246
|
+
child.unref();
|
|
247
|
+
}
|
|
248
|
+
async function pingDaemon() {
|
|
249
|
+
try {
|
|
250
|
+
const controller = new AbortController();
|
|
251
|
+
const timer = setTimeout(() => controller.abort(), 500);
|
|
252
|
+
const res = await fetch(`${DAEMON_URL}/token`, { signal: controller.signal });
|
|
253
|
+
clearTimeout(timer);
|
|
254
|
+
return res.ok;
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
213
260
|
// src/outbox.ts
|
|
214
261
|
import { appendFileSync, chmodSync as chmodSync2, existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
215
262
|
function tighten() {
|
|
@@ -332,6 +379,7 @@ var RENEW_MS = 12e3;
|
|
|
332
379
|
var HELPER_CREDIT_FLUSH_MS = 2e3;
|
|
333
380
|
var HELPER_RETAIN_MS = 6e3;
|
|
334
381
|
var LIVENESS_POLL_MS = 5e3;
|
|
382
|
+
var OWN_BUILD = buildFingerprint();
|
|
335
383
|
var DEBUG = Boolean(process.env.AGENT_IDLE_DEBUG) || process.argv.includes("--debug");
|
|
336
384
|
function debug(...args) {
|
|
337
385
|
if (DEBUG) console.log("[agent-idle]", ...args);
|
|
@@ -492,8 +540,17 @@ var ALLOWED_ORIGINS = (() => {
|
|
|
492
540
|
// Vite dev / Tauri dev webview
|
|
493
541
|
"tauri://localhost",
|
|
494
542
|
"http://tauri.localhost",
|
|
495
|
-
"https://tauri.localhost"
|
|
543
|
+
"https://tauri.localhost",
|
|
496
544
|
// Tauri production webview schemes (platform-dependent)
|
|
545
|
+
// ALL first-party hosted-app origins, unconditionally. The daemon that owns the port
|
|
546
|
+
// is not always the build that opened the auth page (a dev daemon vs the npm CLI's
|
|
547
|
+
// prod page, or an old install vs a newer canonical domain) — if it only trusted its
|
|
548
|
+
// OWN AUTH_URL origin, the page's token post would 403 and `login` would hang. These
|
|
549
|
+
// endpoints only RECEIVE a token / serve local display hints; the secret-reading
|
|
550
|
+
// /token stays browser-denied regardless of origin.
|
|
551
|
+
"https://agent-idle.com",
|
|
552
|
+
"https://www.agent-idle.com",
|
|
553
|
+
"https://agent-idle-app.vercel.app"
|
|
497
554
|
]);
|
|
498
555
|
try {
|
|
499
556
|
out.add(new URL(AUTH_URL).origin);
|
|
@@ -874,6 +931,15 @@ function startDaemon() {
|
|
|
874
931
|
res.end(JSON.stringify({ token: readToken() }));
|
|
875
932
|
return;
|
|
876
933
|
}
|
|
934
|
+
if (req.method === "GET" && req.url === "/build") {
|
|
935
|
+
if (req.headers.origin) {
|
|
936
|
+
res.writeHead(403).end();
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
940
|
+
res.end(JSON.stringify({ ...OWN_BUILD, pid: process.pid }));
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
877
943
|
if (req.method === "GET" && req.url === "/sessions") {
|
|
878
944
|
res.writeHead(200, { ...cors, "Content-Type": "application/json" });
|
|
879
945
|
res.end(JSON.stringify(Object.fromEntries(sessionMeta)));
|
|
@@ -899,6 +965,11 @@ function startDaemon() {
|
|
|
899
965
|
setTimeout(() => process.exit(0), 50);
|
|
900
966
|
return;
|
|
901
967
|
}
|
|
968
|
+
const callerBuild = Number(req.headers["x-agent-idle-build"]);
|
|
969
|
+
if (isNewerBuild(callerBuild, OWN_BUILD)) {
|
|
970
|
+
debug(`caller build ${callerBuild} > own ${OWN_BUILD.mtimeMs} \u2014 retiring after this request`);
|
|
971
|
+
setTimeout(() => process.exit(0), 250);
|
|
972
|
+
}
|
|
902
973
|
if (req.method !== "POST") {
|
|
903
974
|
res.writeHead(404, cors).end();
|
|
904
975
|
return;
|
|
@@ -920,7 +991,59 @@ function startDaemon() {
|
|
|
920
991
|
res.writeHead(204, cors).end();
|
|
921
992
|
});
|
|
922
993
|
});
|
|
923
|
-
|
|
994
|
+
async function isOurPreGuardDaemon() {
|
|
995
|
+
try {
|
|
996
|
+
const controller = new AbortController();
|
|
997
|
+
const timer = setTimeout(() => controller.abort(), 800);
|
|
998
|
+
const res = await fetch(`${DAEMON_URL}/token`, { signal: controller.signal });
|
|
999
|
+
clearTimeout(timer);
|
|
1000
|
+
if (!res.ok) return false;
|
|
1001
|
+
const body = await res.json();
|
|
1002
|
+
return "token" in body;
|
|
1003
|
+
} catch {
|
|
1004
|
+
return false;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
async function takeoverIfStale() {
|
|
1008
|
+
try {
|
|
1009
|
+
const controller = new AbortController();
|
|
1010
|
+
const timer = setTimeout(() => controller.abort(), 800);
|
|
1011
|
+
const res = await fetch(`${DAEMON_URL}/build`, { signal: controller.signal });
|
|
1012
|
+
clearTimeout(timer);
|
|
1013
|
+
if (res.ok) {
|
|
1014
|
+
const incumbent = await res.json();
|
|
1015
|
+
if (!isNewerBuild(OWN_BUILD.mtimeMs, { script: "", mtimeMs: incumbent.mtimeMs ?? 0 })) {
|
|
1016
|
+
process.exit(0);
|
|
1017
|
+
}
|
|
1018
|
+
debug(`incumbent build ${incumbent.mtimeMs} is stale \u2014 asking it to retire`);
|
|
1019
|
+
} else {
|
|
1020
|
+
if (!await isOurPreGuardDaemon()) process.exit(0);
|
|
1021
|
+
debug("incumbent predates the build guard \u2014 asking it to retire");
|
|
1022
|
+
}
|
|
1023
|
+
await fetch(`${DAEMON_URL}/shutdown`, { method: "POST" }).catch(() => {
|
|
1024
|
+
});
|
|
1025
|
+
for (let i = 0; i < 20; i++) {
|
|
1026
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1027
|
+
if (!await pingDaemon()) {
|
|
1028
|
+
server.listen(DAEMON_PORT, "127.0.0.1", () => {
|
|
1029
|
+
console.log(`agent-idle daemon took over port ${DAEMON_PORT} from a stale build`);
|
|
1030
|
+
});
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
} catch {
|
|
1035
|
+
}
|
|
1036
|
+
process.exit(0);
|
|
1037
|
+
}
|
|
1038
|
+
let takeoverAttempted = false;
|
|
1039
|
+
server.on("error", (err) => {
|
|
1040
|
+
if (err.code === "EADDRINUSE" && !takeoverAttempted) {
|
|
1041
|
+
takeoverAttempted = true;
|
|
1042
|
+
void takeoverIfStale();
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
process.exit(0);
|
|
1046
|
+
});
|
|
924
1047
|
server.listen(DAEMON_PORT, "127.0.0.1", () => {
|
|
925
1048
|
console.log(`agent-idle daemon listening on http://127.0.0.1:${DAEMON_PORT}`);
|
|
926
1049
|
console.log(
|
|
@@ -934,44 +1057,6 @@ function startDaemon() {
|
|
|
934
1057
|
|
|
935
1058
|
// src/hook.ts
|
|
936
1059
|
import { execFileSync as execFileSync2 } from "child_process";
|
|
937
|
-
|
|
938
|
-
// src/daemonControl.ts
|
|
939
|
-
import { spawn } from "child_process";
|
|
940
|
-
import { fileURLToPath } from "url";
|
|
941
|
-
function spawnDaemon() {
|
|
942
|
-
const indexPath = fileURLToPath(new URL("./index.js", import.meta.url));
|
|
943
|
-
const child = spawn(process.execPath, [indexPath, "daemon"], {
|
|
944
|
-
detached: true,
|
|
945
|
-
stdio: "ignore"
|
|
946
|
-
});
|
|
947
|
-
child.unref();
|
|
948
|
-
}
|
|
949
|
-
async function pingDaemon() {
|
|
950
|
-
try {
|
|
951
|
-
const controller = new AbortController();
|
|
952
|
-
const timer = setTimeout(() => controller.abort(), 500);
|
|
953
|
-
const res = await fetch(`${DAEMON_URL}/token`, { signal: controller.signal });
|
|
954
|
-
clearTimeout(timer);
|
|
955
|
-
return res.ok;
|
|
956
|
-
} catch {
|
|
957
|
-
return false;
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
async function daemonToken() {
|
|
961
|
-
try {
|
|
962
|
-
const controller = new AbortController();
|
|
963
|
-
const timer = setTimeout(() => controller.abort(), 800);
|
|
964
|
-
const res = await fetch(`${DAEMON_URL}/token`, { signal: controller.signal });
|
|
965
|
-
clearTimeout(timer);
|
|
966
|
-
if (!res.ok) return null;
|
|
967
|
-
const { token } = await res.json();
|
|
968
|
-
return token ?? null;
|
|
969
|
-
} catch {
|
|
970
|
-
return null;
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
// src/hook.ts
|
|
975
1060
|
var SHELL_COMMS = /* @__PURE__ */ new Set([
|
|
976
1061
|
"sh",
|
|
977
1062
|
"bash",
|
|
@@ -1052,7 +1137,13 @@ async function postToDaemon(url, body) {
|
|
|
1052
1137
|
const timer = setTimeout(() => controller.abort(), 800);
|
|
1053
1138
|
await fetch(url, {
|
|
1054
1139
|
method: "POST",
|
|
1055
|
-
headers: {
|
|
1140
|
+
headers: {
|
|
1141
|
+
"Content-Type": "application/json",
|
|
1142
|
+
// Stale-daemon guard: the hook is spawned fresh per event, so this mtime always
|
|
1143
|
+
// reflects the build ON DISK. A daemon seeing a newer caller retires itself and
|
|
1144
|
+
// the next hook respawns the new build — no manual `kill` after updates.
|
|
1145
|
+
"x-agent-idle-build": String(buildFingerprint().mtimeMs)
|
|
1146
|
+
},
|
|
1056
1147
|
body,
|
|
1057
1148
|
signal: controller.signal
|
|
1058
1149
|
});
|
|
@@ -1187,7 +1278,46 @@ import { cancel, isCancel, multiselect } from "@clack/prompts";
|
|
|
1187
1278
|
|
|
1188
1279
|
// src/auth.ts
|
|
1189
1280
|
import { spawn as spawn2 } from "child_process";
|
|
1281
|
+
import { randomBytes, randomUUID as randomUUID2, webcrypto } from "crypto";
|
|
1282
|
+
import { ConvexHttpClient as ConvexHttpClient2 } from "convex/browser";
|
|
1283
|
+
import { makeFunctionReference as makeFunctionReference2 } from "convex/server";
|
|
1190
1284
|
var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1285
|
+
var createDeviceAuth = makeFunctionReference2("deviceAuth:create");
|
|
1286
|
+
var pollDeviceAuth = makeFunctionReference2("deviceAuth:poll");
|
|
1287
|
+
function userCode() {
|
|
1288
|
+
return randomBytes(5).toString("hex").toUpperCase();
|
|
1289
|
+
}
|
|
1290
|
+
function b64ToBytes(value) {
|
|
1291
|
+
return Buffer.from(value, "base64");
|
|
1292
|
+
}
|
|
1293
|
+
async function createDeviceKeyPair() {
|
|
1294
|
+
const keyPair = await webcrypto.subtle.generateKey(
|
|
1295
|
+
{
|
|
1296
|
+
name: "RSA-OAEP",
|
|
1297
|
+
modulusLength: 2048,
|
|
1298
|
+
publicExponent: new Uint8Array([1, 0, 1]),
|
|
1299
|
+
hash: "SHA-256"
|
|
1300
|
+
},
|
|
1301
|
+
true,
|
|
1302
|
+
["encrypt", "decrypt"]
|
|
1303
|
+
);
|
|
1304
|
+
const publicJwk = await webcrypto.subtle.exportKey("jwk", keyPair.publicKey);
|
|
1305
|
+
return { publicKeyJwk: JSON.stringify(publicJwk), privateKey: keyPair.privateKey };
|
|
1306
|
+
}
|
|
1307
|
+
async function decryptToken(payload, privateKey) {
|
|
1308
|
+
const rawKey = await webcrypto.subtle.decrypt(
|
|
1309
|
+
{ name: "RSA-OAEP" },
|
|
1310
|
+
privateKey,
|
|
1311
|
+
b64ToBytes(payload.encryptedKey)
|
|
1312
|
+
);
|
|
1313
|
+
const key = await webcrypto.subtle.importKey("raw", rawKey, { name: "AES-GCM" }, false, ["decrypt"]);
|
|
1314
|
+
const plaintext = await webcrypto.subtle.decrypt(
|
|
1315
|
+
{ name: "AES-GCM", iv: b64ToBytes(payload.iv) },
|
|
1316
|
+
key,
|
|
1317
|
+
b64ToBytes(payload.ciphertext)
|
|
1318
|
+
);
|
|
1319
|
+
return new TextDecoder().decode(plaintext);
|
|
1320
|
+
}
|
|
1191
1321
|
function openBrowser(url) {
|
|
1192
1322
|
const [bin, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
1193
1323
|
try {
|
|
@@ -1205,25 +1335,34 @@ async function signIn() {
|
|
|
1205
1335
|
);
|
|
1206
1336
|
return;
|
|
1207
1337
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1338
|
+
spawnDaemon();
|
|
1339
|
+
const settleBy = Date.now() + 5e3;
|
|
1340
|
+
while (Date.now() < settleBy && !await pingDaemon()) await sleep3(250);
|
|
1341
|
+
const client = new ConvexHttpClient2(resolveConvexUrl());
|
|
1342
|
+
const deviceId = randomUUID2();
|
|
1343
|
+
const code = userCode();
|
|
1344
|
+
const { publicKeyJwk, privateKey } = await createDeviceKeyPair();
|
|
1345
|
+
await client.mutation(createDeviceAuth, { deviceId, userCode: code, publicKeyJwk });
|
|
1346
|
+
const authUrl = cliAuthUrl(code);
|
|
1212
1347
|
console.log(`
|
|
1213
|
-
Opening ${
|
|
1348
|
+
Opening ${authUrl} to sign in (GitHub or email + password)\u2026`);
|
|
1349
|
+
console.log(`Your device code is ${code}.`);
|
|
1214
1350
|
console.log(
|
|
1215
1351
|
"If it doesn't open, visit that URL manually. Waiting for sign-in\u2026 (Ctrl-C to cancel)"
|
|
1216
1352
|
);
|
|
1217
|
-
openBrowser(
|
|
1353
|
+
openBrowser(authUrl);
|
|
1218
1354
|
const deadline = Date.now() + 12e4;
|
|
1219
1355
|
while (Date.now() < deadline) {
|
|
1220
1356
|
await sleep3(1500);
|
|
1221
|
-
|
|
1357
|
+
const res = await client.mutation(pollDeviceAuth, { deviceId });
|
|
1358
|
+
if (res.status === "approved") {
|
|
1359
|
+
writeToken(await decryptToken(res.encryptedToken, privateKey));
|
|
1222
1360
|
console.log(
|
|
1223
1361
|
"\u2713 Signed in \u2014 shared session established for the app, CLI, and daemon."
|
|
1224
1362
|
);
|
|
1225
1363
|
return;
|
|
1226
1364
|
}
|
|
1365
|
+
if (res.status === "expired") break;
|
|
1227
1366
|
}
|
|
1228
1367
|
console.log(
|
|
1229
1368
|
"Timed out waiting for sign-in. Re-run `agent-idle setup` once you've signed in."
|