@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.
Files changed (2) hide show
  1. package/index.js +187 -48
  2. 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
- server.on("error", () => process.exit(0));
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: { "Content-Type": "application/json" },
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
- if (!await pingDaemon()) {
1209
- spawnDaemon();
1210
- await sleep3(700);
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 ${AUTH_URL} to sign in (GitHub or email + password)\u2026`);
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(AUTH_URL);
1353
+ openBrowser(authUrl);
1218
1354
  const deadline = Date.now() + 12e4;
1219
1355
  while (Date.now() < deadline) {
1220
1356
  await sleep3(1500);
1221
- if (await daemonToken() ?? readToken()) {
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."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-idle/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "agent-idle CLI: setup, ui, and the headless sensor daemon.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",