airgate 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 (3) hide show
  1. package/README.md +5 -2
  2. package/dist/airgate.mjs +568 -132
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -37,10 +37,13 @@ with the result back here, or the whole project and conversation up and back.
37
37
  airgate run "get the failing tests passing" # a fresh pod; the result comes back as a patch
38
38
  airgate skill install # teach Claude Code when and how to use airgate
39
39
  claude mcp add airgate -- airgate mcp # airgate as tools (pod_run, pod_up, pod_down…)
40
- claude setup-token # then: airgate secrets set CLAUDE_CODE_OAUTH_TOKEN
40
+ airgate models connect claude --laptop # a token from `claude setup-token`, kept on this laptop
41
+ airgate models connect chatgpt --laptop # or Codex on your ChatGPT plan
41
42
  ```
42
43
 
43
- The last line signs Claude Code in for every pod, so tasks run without a login each time.
44
+ The last line lets this laptop sign the pods' model calls while airgate runs, so tasks run without
45
+ a login in each pod and airgate never holds your sign-in. Or keep one on airgate so pods work while
46
+ the laptop is off: `airgate models connect claude`. Guide: https://airgate.sh/docs/model-sign-in
44
47
  Guide: https://airgate.sh/docs/local-agents
45
48
 
46
49
  ## Links
package/dist/airgate.mjs CHANGED
@@ -301,6 +301,72 @@ function dotenvExample(names) {
301
301
  return names.map((n2) => `${n2}=`).join("\n") + (names.length ? "\n" : "");
302
302
  }
303
303
 
304
+ // ../sandbox/src/chatgpt.ts
305
+ var CHATGPT_CODEX_BASE = "https://chatgpt.com/backend-api/codex";
306
+ var CHATGPT_TOKEN_URL = "https://auth.openai.com/oauth/token";
307
+ var CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
308
+ function jwtClaims(token) {
309
+ const part = token?.split(".")[1];
310
+ if (!part) return null;
311
+ try {
312
+ const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
313
+ const bin = atob(b64 + "=".repeat((4 - b64.length % 4) % 4));
314
+ return JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))));
315
+ } catch {
316
+ return null;
317
+ }
318
+ }
319
+ function accountIdOf(t) {
320
+ if (typeof t.account_id === "string" && t.account_id) return t.account_id;
321
+ const auth = jwtClaims(typeof t.id_token === "string" ? t.id_token : void 0)?.["https://api.openai.com/auth"];
322
+ return typeof auth?.chatgpt_account_id === "string" ? auth.chatgpt_account_id : null;
323
+ }
324
+ function parseChatgptLogin(text2) {
325
+ let j;
326
+ try {
327
+ j = JSON.parse(text2);
328
+ } catch {
329
+ throw new Error("not a Codex login: expected the auth.json that `codex login` writes");
330
+ }
331
+ const t = j.tokens && typeof j.tokens === "object" ? j.tokens : j;
332
+ if (typeof t.access_token !== "string" || typeof t.refresh_token !== "string") {
333
+ throw new Error("this Codex login has no ChatGPT tokens: sign in with ChatGPT in `codex login`, not an API key");
334
+ }
335
+ const account = accountIdOf(t);
336
+ if (!account) throw new Error("this Codex login doesn't name a ChatGPT account");
337
+ return { access_token: t.access_token, refresh_token: t.refresh_token, id_token: typeof t.id_token === "string" ? t.id_token : void 0, account_id: account };
338
+ }
339
+ function chatgptNeedsRefresh(t, now = Date.now(), windowMs = 5 * 6e4) {
340
+ const exp = jwtClaims(t.access_token)?.exp;
341
+ return typeof exp !== "number" || exp * 1e3 - now < windowMs;
342
+ }
343
+ async function refreshChatgpt(t, o = {}) {
344
+ const res = await (o.fetch ?? fetch)(o.tokenUrl ?? CHATGPT_TOKEN_URL, {
345
+ method: "POST",
346
+ headers: { "content-type": "application/json" },
347
+ body: JSON.stringify({ client_id: CODEX_CLIENT_ID, grant_type: "refresh_token", refresh_token: t.refresh_token, scope: "openid profile email" }),
348
+ signal: AbortSignal.timeout(45e3)
349
+ });
350
+ if (!res.ok) {
351
+ const why = await res.text().catch(() => "");
352
+ throw new Error(
353
+ res.status === 400 || res.status === 401 ? "the ChatGPT sign-in expired or was revoked: connect it again (airgate models connect chatgpt)" : `couldn't renew the ChatGPT sign-in (${res.status}${why ? `: ${why.slice(0, 120)}` : ""})`
354
+ );
355
+ }
356
+ const r = await res.json();
357
+ if (!r.access_token) throw new Error("renewing the ChatGPT sign-in returned no access token");
358
+ return { ...t, access_token: r.access_token, refresh_token: r.refresh_token ?? t.refresh_token, id_token: r.id_token ?? t.id_token };
359
+ }
360
+ function chatgptUrl(path, base = CHATGPT_CODEX_BASE) {
361
+ return base + path.replace(/^\/?v1(?=\/|$)/, "").replace(/^(?!\/)/, "/");
362
+ }
363
+ function signChatgpt(t, headers) {
364
+ delete headers["x-api-key"];
365
+ headers.authorization = `Bearer ${t.access_token}`;
366
+ headers["chatgpt-account-id"] = t.account_id;
367
+ headers.originator ??= "codex_cli_rs";
368
+ }
369
+
304
370
  // src/api.ts
305
371
  var UPLOAD_PART = 6 * 1024 * 1024;
306
372
  var ApiError = class extends Error {
@@ -514,6 +580,20 @@ var Api = class {
514
580
  githubDisconnect(id) {
515
581
  return this.request("DELETE", `/v1/github/installations/${id}`);
516
582
  }
583
+ /* model sign-ins: connected to airgate (opt-in), and whether a laptop relay is connected */
584
+ modelsAuth() {
585
+ return this.request("GET", "/v1/models/auth");
586
+ }
587
+ connectModel(vendor, value, kind) {
588
+ return this.request("PUT", `/v1/models/auth/${enc(vendor)}`, { body: { value, ...kind ? { kind } : {} } });
589
+ }
590
+ disconnectModel(vendor) {
591
+ return this.request("DELETE", `/v1/models/auth/${enc(vendor)}`);
592
+ }
593
+ relayUrl() {
594
+ const q = new URLSearchParams({ token: this.tokenForUrl(), client: this.client });
595
+ return `${this.gateway.replace(/^http/, "ws")}/v1/relay?${q}`;
596
+ }
517
597
  secrets() {
518
598
  return this.request("GET", "/v1/secrets");
519
599
  }
@@ -4374,8 +4454,8 @@ function linked(s2, j, b2, ascii) {
4374
4454
  }
4375
4455
  return m;
4376
4456
  }
4377
- function place3(s2, j, c, k, join6, a = ARMS.get(c), ascii = false, round = false) {
4378
- if (join6 && a !== void 0) {
4457
+ function place3(s2, j, c, k, join8, a = ARMS.get(c), ascii = false, round = false) {
4458
+ if (join8 && a !== void 0) {
4379
4459
  const old = s2.chars[j], b2 = ascii ? asciiArms(old) : ARMS.get(old);
4380
4460
  if (b2 !== void 0) {
4381
4461
  const kept = linked(s2, j, b2, ascii);
@@ -4541,7 +4621,7 @@ function rect(s2, x, y, w, h, o) {
4541
4621
  if (title)
4542
4622
  for (const ch of title)
4543
4623
  code(ch);
4544
- const join6 = yes(o?.join, "join", true);
4624
+ const join8 = yes(o?.join, "join", true);
4545
4625
  const k = paint(s2, color), fk = fillColor === void 0 ? k : paint(s2, fillColor);
4546
4626
  if (!finite3(x, y, w, h))
4547
4627
  return { x: 0, y: 0, cols: 0, rows: 0 };
@@ -4564,7 +4644,7 @@ function rect(s2, x, y, w, h, o) {
4564
4644
  const put = (cx, cy, e, reach = 255) => {
4565
4645
  const j = s2.index(cx, cy), a = ascii ? EDGE_ARMS[e] : ARMS.get(ed[e]);
4566
4646
  if (j >= 0)
4567
- place3(s2, j, ed[e], k, join6, a === void 0 ? a : a & reach, ascii, round);
4647
+ place3(s2, j, ed[e], k, join8, a === void 0 ? a : a & reach, ascii, round);
4568
4648
  };
4569
4649
  const end = (i, n2, first, last) => n2 < 2 ? 255 : i === 0 ? 3 << first : i === n2 - 1 ? 3 << last : 255;
4570
4650
  const from = (o2, lo) => Math.max(lo, -o2), to = (o2, hi, n2) => Math.min(hi, n2 - o2);
@@ -4700,11 +4780,11 @@ function shape(s2, cx, cy, rx, ry, o, what, from = 0, span = 1) {
4700
4780
  const ch = char === "auto" ? "auto" : brush(char, `${/^[aeiou]/.test(what) ? "an" : "a"} ${what}'s char`);
4701
4781
  const f = fill === void 0 || fill === false ? null : brush(fill, "fill");
4702
4782
  const k = paint(s2, color), fk = fillColor === void 0 ? k : paint(s2, fillColor);
4703
- const join6 = yes(o?.join, "join", true);
4783
+ const join8 = yes(o?.join, "join", true);
4704
4784
  ellipseCells(s2.cols, s2.rows, cx, cy, rx, ry, f !== null, (x, y, edge, slope) => {
4705
4785
  const j = y * s2.cols + x;
4706
4786
  if (edge)
4707
- place3(s2, j, ch === "auto" ? slope : ch, k, join6 && ch !== "auto");
4787
+ place3(s2, j, ch === "auto" ? slope : ch, k, join8 && ch !== "auto");
4708
4788
  else
4709
4789
  s2.put(j, f, f === EMPTY ? NONE : fk);
4710
4790
  }, ch === "auto" ? s2.aspect : 0, from, span);
@@ -7019,14 +7099,14 @@ async function readAll2(stream) {
7019
7099
  for await (const c of stream) s2 += c.toString();
7020
7100
  return s2;
7021
7101
  }
7022
- async function readValue(ctx, name) {
7102
+ async function readValue(ctx, name, pipeHint = `printf %s "$VALUE" | airgate secrets set ${name}`) {
7023
7103
  if (ctx.io.stdin.isTTY) {
7024
7104
  const v2 = await ctx.io.prompt(`value for ${name} (hidden): `, { hidden: true });
7025
7105
  if (!v2) throw new CliError("empty value, nothing saved");
7026
7106
  return v2;
7027
7107
  }
7028
7108
  const v = (await readAll2(ctx.io.stdin)).replace(/\r?\n$/, "");
7029
- if (!v) throw new CliError("no value on stdin", `printf %s "$VALUE" | airgate secrets set ${name}`);
7109
+ if (!v) throw new CliError("no value on stdin", pipeHint);
7030
7110
  return v;
7031
7111
  }
7032
7112
  function parseDotenv2(text2) {
@@ -7648,7 +7728,7 @@ var log = {
7648
7728
  import { createInterface } from "node:readline";
7649
7729
 
7650
7730
  // src/version.ts
7651
- var VERSION = "0.1.2";
7731
+ var VERSION = "0.1.4";
7652
7732
 
7653
7733
  // src/commands/updown.ts
7654
7734
  import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync5, realpathSync as realpathSync2, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
@@ -7657,7 +7737,7 @@ import { basename, dirname as dirname2, isAbsolute, join as join4, relative as r
7657
7737
 
7658
7738
  // src/pack.ts
7659
7739
  import { spawn, spawnSync } from "node:child_process";
7660
- import { existsSync, lstatSync } from "node:fs";
7740
+ import { existsSync, lstatSync, readdirSync } from "node:fs";
7661
7741
  import { join as join2 } from "node:path";
7662
7742
  var UPLOAD_LIMIT = 32 * 6 * 1024 * 1024;
7663
7743
  var TooLargeError = class extends Error {
@@ -7667,6 +7747,11 @@ var TooLargeError = class extends Error {
7667
7747
  this.limit = limit;
7668
7748
  }
7669
7749
  };
7750
+ function isEnvFile(path) {
7751
+ const name = path.split("/").pop() ?? "";
7752
+ return /^\.env($|\.)/.test(name) && !/\.(example|sample|template|dist|defaults?|schema)$/.test(name);
7753
+ }
7754
+ var ENV_EXCLUDES = [".env", ".env.local", ".env.*.local", ".env.development", ".env.production", ".env.test", ".env.staging", ".envrc"].map((p) => `--exclude=${p}`);
7670
7755
  function git(cwd, args, input) {
7671
7756
  const r = spawnSync("git", args, { cwd, input, maxBuffer: 256 * 1024 * 1024 });
7672
7757
  return { status: r.status ?? (r.error ? 127 : 1), stdout: r.stdout?.toString("utf8") ?? "", stderr: r.stderr?.toString("utf8") ?? "", error: r.error };
@@ -7723,13 +7808,17 @@ function runTar(cwd, args, input, limit = UPLOAD_LIMIT) {
7723
7808
  }
7724
7809
  async function packProject(dir, limit = UPLOAD_LIMIT) {
7725
7810
  if (isGitRepo(dir)) {
7726
- const files = gitFiles(dir).filter((f) => !f.startsWith(".airgate/"));
7811
+ const tracked = new Set(git(dir, ["ls-files", "-c", "-z"]).stdout.split("\0"));
7812
+ const all = gitFiles(dir).filter((f) => !f.startsWith(".airgate/"));
7813
+ const skipped2 = all.filter((f) => (isEnvFile(f) || f.split("/").pop() === ".envrc") && !tracked.has(f));
7814
+ const files = all.filter((f) => !skipped2.includes(f));
7727
7815
  if (!files.length) throw new Error("no files to send (git sees nothing tracked or untracked here)");
7728
7816
  const archive2 = await runTar(dir, ["--null", "-T", "-", "-czf", "-"], files.join("\0") + "\0", limit);
7729
- return { archive: archive2, files: files.length, git: true };
7817
+ return { archive: archive2, files: files.length, git: true, skipped: skipped2 };
7730
7818
  }
7731
- const archive = await runTar(dir, ["--exclude=node_modules", "--exclude=.git", "--exclude=.airgate", "-czf", "-", "."], null, limit);
7732
- return { archive, files: -1, git: false };
7819
+ const skipped = readdirSync(dir).filter((f) => isEnvFile(f) || f === ".envrc");
7820
+ const archive = await runTar(dir, ["--exclude=node_modules", "--exclude=.git", "--exclude=.airgate", ...ENV_EXCLUDES, "-czf", "-", "."], null, limit);
7821
+ return { archive, files: -1, git: false, skipped };
7733
7822
  }
7734
7823
  var CLAUDE_CONFIG = ["settings.json", "CLAUDE.md", "skills", "agents", "commands"];
7735
7824
  async function packClaudeConfig(home, limit = UPLOAD_LIMIT) {
@@ -7751,7 +7840,7 @@ import { gunzipSync } from "node:zlib";
7751
7840
 
7752
7841
  // src/sessions.ts
7753
7842
  import { createHash, randomUUID } from "node:crypto";
7754
- import { closeSync, existsSync as existsSync2, mkdirSync as mkdirSync2, mkdtempSync, openSync, readdirSync, readFileSync as readFileSync4, readSync, realpathSync, renameSync as renameSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "node:fs";
7843
+ import { closeSync, existsSync as existsSync2, mkdirSync as mkdirSync2, mkdtempSync, openSync, readdirSync as readdirSync2, readFileSync as readFileSync4, readSync, realpathSync, renameSync as renameSync2, rmSync as rmSync2, statSync, writeFileSync as writeFileSync2 } from "node:fs";
7755
7844
  import { tmpdir } from "node:os";
7756
7845
  import { dirname, join as join3, relative } from "node:path";
7757
7846
  var POD_HOME = "/home/sprite";
@@ -7779,7 +7868,7 @@ function findClaudeSession(home, projectDir, id, env = {}) {
7779
7868
  const dir = join3(claudeHome(home, env), "projects", claudeKey(p));
7780
7869
  let names = [];
7781
7870
  try {
7782
- names = readdirSync(dir).filter((n2) => n2.endsWith(".jsonl"));
7871
+ names = readdirSync2(dir).filter((n2) => n2.endsWith(".jsonl"));
7783
7872
  } catch {
7784
7873
  continue;
7785
7874
  }
@@ -7810,7 +7899,7 @@ function codexRollouts(root, limit = 3e3) {
7810
7899
  const walk = (d, depth) => {
7811
7900
  let list2 = [];
7812
7901
  try {
7813
- list2 = readdirSync(d, { withFileTypes: true });
7902
+ list2 = readdirSync2(d, { withFileTypes: true });
7814
7903
  } catch {
7815
7904
  return;
7816
7905
  }
@@ -7865,7 +7954,7 @@ async function packSession(s2, projectDir, podPath, limit = UPLOAD_LIMIT) {
7865
7954
  names.push(`${s2.id}.jsonl`);
7866
7955
  if (s2.sidecar) {
7867
7956
  const copy2 = (src, dst) => {
7868
- for (const x of readdirSync(src, { withFileTypes: true })) {
7957
+ for (const x of readdirSync2(src, { withFileTypes: true })) {
7869
7958
  const a = join3(src, x.name);
7870
7959
  const b2 = join3(dst, x.name);
7871
7960
  if (x.isDirectory()) {
@@ -8279,10 +8368,11 @@ var up = {
8279
8368
  const state = { gateway: ctx.cfg.gateway, podId: pod.id, podName: pod.spec.name, dir: podDir, at: Date.now(), ...sent.length ? { sessions: sent } : {} };
8280
8369
  writeState(projectDir, state);
8281
8370
  if (ctx.json) {
8282
- return ui2.json({ pod, dir: podDir, bytes: packed.archive.length, files: packed.files, config: config?.parts ?? [], baseSet: based, sessions: resumed, secrets: envSent });
8371
+ return ui2.json({ pod, dir: podDir, bytes: packed.archive.length, files: packed.files, skipped: packed.skipped, config: config?.parts ?? [], baseSet: based, sessions: resumed, secrets: envSent });
8283
8372
  }
8284
8373
  ui2.out(`${ui2.c("ok", "up")}: ${name} \u2192 ${pod.spec.name}:~/${podDir} \xB7 ${packed.files >= 0 ? `${packed.files} files, ` : ""}${bytes(packed.archive.length)}`);
8285
8374
  if (config) ui2.out(`config: ~/.claude/{${config.parts.join(",")}}`);
8375
+ if (packed.skipped.length) ui2.out(ui2.c("dim", `kept here: ${packed.skipped.join(", ")} (env files go to a pod only as secrets: --env${packed.skipped.includes(".env") ? "" : " <file>"})`));
8286
8376
  if (!based) ui2.err(ui2.ce("warn", "couldn't set the down base; the first down will include the files you sent"));
8287
8377
  if (envSent) {
8288
8378
  ui2.out(`secrets for ${pod.spec.name}: ${envSent.sent.length ? envSent.sent.join(", ") : "none"}${envSent.skipped.length ? ui2.c("dim", ` (skipped: ${envSent.skipped.join(", ")})`) : ""}`);
@@ -8641,22 +8731,352 @@ async function runCommand(ctx, cmd, argv, cwd) {
8641
8731
  }
8642
8732
 
8643
8733
  // src/commands/run.ts
8644
- import { existsSync as existsSync4, readFileSync as readFileSync6, rmSync as rmSync3, statSync as statSync3, writeFileSync as writeFileSync4 } from "node:fs";
8734
+ import { existsSync as existsSync5, readFileSync as readFileSync8, rmSync as rmSync4, statSync as statSync3, writeFileSync as writeFileSync5 } from "node:fs";
8645
8735
  import { basename as basename2, resolve as resolve5 } from "node:path";
8646
8736
  import { randomBytes } from "node:crypto";
8647
- var SIGN_IN = {
8648
- "claude-code": { names: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], fix: "claude setup-token (on this laptop), then: airgate secrets set CLAUDE_CODE_OAUTH_TOKEN" },
8649
- codex: { names: ["OPENAI_API_KEY"], fix: "airgate secrets set OPENAI_API_KEY (or sign in to codex once in a pod you keep)" }
8737
+
8738
+ // src/relay.ts
8739
+ import { chmodSync as chmodSync2, existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync as renameSync3, writeFileSync as writeFileSync4 } from "node:fs";
8740
+ import { join as join5 } from "node:path";
8741
+ function vendorOf(name) {
8742
+ const n2 = (name ?? "").toLowerCase();
8743
+ if (n2 === "claude" || n2 === "anthropic" || n2 === "claude-code") return "anthropic";
8744
+ if (n2 === "openai" || n2 === "codex" || n2 === "chatgpt") return "openai";
8745
+ return null;
8746
+ }
8747
+ var vendorLabel = (v) => v === "anthropic" ? "Claude" : "OpenAI";
8748
+ var kindOf = (v, value) => v === "anthropic" && value.startsWith("sk-ant-oat") ? "oauth_token" : "api_key";
8749
+ var modelsPath = (env) => join5(configDir(env), "models.json");
8750
+ function readSaved(env) {
8751
+ try {
8752
+ return JSON.parse(readFileSync6(modelsPath(env), "utf8"));
8753
+ } catch {
8754
+ return {};
8755
+ }
8756
+ }
8757
+ function saveLocal(env, vendor, value, kind = value ? kindOf(vendor, value) : "api_key") {
8758
+ const all = readSaved(env);
8759
+ if (value) all[vendor] = { kind, value };
8760
+ else delete all[vendor];
8761
+ const dir = configDir(env);
8762
+ mkdirSync4(dir, { recursive: true, mode: 448 });
8763
+ const file = modelsPath(env);
8764
+ const tmp = `${file}.${process.pid}.tmp`;
8765
+ writeFileSync4(tmp, JSON.stringify(all, null, 2) + "\n", { mode: 384 });
8766
+ chmodSync2(tmp, 384);
8767
+ renameSync3(tmp, file);
8768
+ chmodSync2(file, 384);
8769
+ }
8770
+ function hasSavedFile(env) {
8771
+ return existsSync4(modelsPath(env));
8772
+ }
8773
+ function localAuth(env) {
8774
+ const saved = readSaved(env);
8775
+ const out = {};
8776
+ if (env.CLAUDE_CODE_OAUTH_TOKEN) out.anthropic = { kind: "oauth_token", value: env.CLAUDE_CODE_OAUTH_TOKEN, from: "CLAUDE_CODE_OAUTH_TOKEN" };
8777
+ else if (env.ANTHROPIC_API_KEY) out.anthropic = { kind: "api_key", value: env.ANTHROPIC_API_KEY, from: "ANTHROPIC_API_KEY" };
8778
+ else if (saved.anthropic) out.anthropic = { ...saved.anthropic, from: "this laptop" };
8779
+ if (env.OPENAI_API_KEY) out.openai = { kind: "api_key", value: env.OPENAI_API_KEY, from: "OPENAI_API_KEY" };
8780
+ else if (saved.openai) out.openai = { ...saved.openai, from: "this laptop" };
8781
+ return out;
8782
+ }
8783
+ function sign(vendor, auth, headers) {
8784
+ if (vendor === "openai") {
8785
+ headers.authorization = `Bearer ${auth.value}`;
8786
+ return;
8787
+ }
8788
+ if (auth.kind === "oauth_token") {
8789
+ delete headers["x-api-key"];
8790
+ headers.authorization = `Bearer ${auth.value}`;
8791
+ const beta = (headers["anthropic-beta"] ?? "").split(",").map((s2) => s2.trim()).filter(Boolean);
8792
+ if (!beta.includes("oauth-2025-04-20")) beta.push("oauth-2025-04-20");
8793
+ headers["anthropic-beta"] = beta.join(",");
8794
+ } else {
8795
+ delete headers.authorization;
8796
+ headers["x-api-key"] = auth.value;
8797
+ }
8798
+ }
8799
+ var UPSTREAM = { anthropic: "https://api.anthropic.com", openai: "https://api.openai.com" };
8800
+ async function chatgptReady(io, auth, f) {
8801
+ const t = JSON.parse(auth.value);
8802
+ if (!chatgptNeedsRefresh(t)) return t;
8803
+ const fresh = await refreshChatgpt(t, { fetch: f, tokenUrl: io.env.AIRGATE_RELAY_CHATGPT_TOKEN_URL || void 0 });
8804
+ auth.value = JSON.stringify(fresh);
8805
+ if (auth.from === "this laptop") saveLocal(io.env, "openai", auth.value, "chatgpt");
8806
+ return fresh;
8807
+ }
8808
+ function startRelay(o) {
8809
+ const vendors = Object.keys(o.auth).filter((v) => o.auth[v]);
8810
+ const inflight = /* @__PURE__ */ new Map();
8811
+ let ws = null;
8812
+ let stopped = false;
8813
+ let signed = 0;
8814
+ let attempt = 0;
8815
+ let resolveDone;
8816
+ const done = new Promise((r) => resolveDone = r);
8817
+ const base = (v) => o.io.env[v === "anthropic" ? "AIRGATE_RELAY_ANTHROPIC_URL" : "AIRGATE_RELAY_OPENAI_URL"] || UPSTREAM[v];
8818
+ const handle2 = async (sock, m) => {
8819
+ const send = (f) => sock.readyState === 1 && sock.send(JSON.stringify(f));
8820
+ const auth = o.auth[m.vendor];
8821
+ if (!auth) return send({ t: "error", id: m.id, message: `this laptop has no ${vendorLabel(m.vendor)} sign-in` });
8822
+ const headers = { ...m.headers };
8823
+ const ctl = new AbortController();
8824
+ inflight.set(m.id, ctl);
8825
+ try {
8826
+ let url = `${base(m.vendor)}${m.path}`;
8827
+ if (auth.kind === "chatgpt") {
8828
+ signChatgpt(await chatgptReady(o.io, auth, o.fetch), headers);
8829
+ url = chatgptUrl(m.path, o.io.env.AIRGATE_RELAY_CHATGPT_URL || CHATGPT_CODEX_BASE);
8830
+ } else sign(m.vendor, auth, headers);
8831
+ const res = await o.fetch(url, {
8832
+ method: m.method,
8833
+ headers,
8834
+ body: m.body ? Buffer.from(m.body, "base64") : void 0,
8835
+ signal: ctl.signal
8836
+ });
8837
+ const out = {};
8838
+ res.headers.forEach((v, k) => {
8839
+ if (!["content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"].includes(k)) out[k] = v;
8840
+ });
8841
+ send({ t: "head", id: m.id, status: res.status, headers: out });
8842
+ signed++;
8843
+ if (res.body) for await (const chunk of res.body) send({ t: "data", id: m.id, b64: Buffer.from(chunk).toString("base64") });
8844
+ send({ t: "end", id: m.id });
8845
+ } catch (e) {
8846
+ if (!ctl.signal.aborted) send({ t: "error", id: m.id, message: e instanceof Error ? e.message : String(e) });
8847
+ } finally {
8848
+ inflight.delete(m.id);
8849
+ }
8850
+ };
8851
+ const connect = () => {
8852
+ if (stopped) return;
8853
+ const sock = o.io.websocket(o.url);
8854
+ ws = sock;
8855
+ sock.onopen = () => {
8856
+ attempt = 0;
8857
+ sock.send(JSON.stringify({ t: "hello", vendors, client: `airgate ${VERSION}` }));
8858
+ };
8859
+ sock.onmessage = (ev) => {
8860
+ let m;
8861
+ try {
8862
+ m = JSON.parse(String(ev.data));
8863
+ } catch {
8864
+ return;
8865
+ }
8866
+ if (m.t === "req" && typeof m.id === "string") void handle2(sock, m);
8867
+ else if (m.t === "cancel" && typeof m.id === "string") inflight.get(m.id)?.abort();
8868
+ };
8869
+ sock.onerror = () => void 0;
8870
+ sock.onclose = (ev) => {
8871
+ for (const c of inflight.values()) c.abort();
8872
+ inflight.clear();
8873
+ if (stopped) return resolveDone();
8874
+ if (ev.code === 4401 || ev.code === 1008) {
8875
+ o.say("the gateway refused this laptop (sign in again: airgate login)");
8876
+ stopped = true;
8877
+ return resolveDone();
8878
+ }
8879
+ attempt++;
8880
+ setTimeout(connect, Math.min(15e3, 500 * 2 ** Math.min(attempt, 5)));
8881
+ };
8882
+ };
8883
+ connect();
8884
+ return {
8885
+ vendors,
8886
+ done,
8887
+ count: () => signed,
8888
+ stop() {
8889
+ stopped = true;
8890
+ try {
8891
+ ws?.close(1e3, "done");
8892
+ } catch {
8893
+ }
8894
+ resolveDone();
8895
+ }
8896
+ };
8897
+ }
8898
+
8899
+ // src/commands/models.ts
8900
+ import { spawnSync as spawnSync2 } from "node:child_process";
8901
+ import { mkdtempSync as mkdtempSync2, readFileSync as readFileSync7, rmSync as rmSync3 } from "node:fs";
8902
+ import { tmpdir as tmpdir2 } from "node:os";
8903
+ import { join as join6 } from "node:path";
8904
+ var HOW = {
8905
+ anthropic: "a Claude subscription token (run `claude setup-token` and paste what it prints) or an Anthropic API key",
8906
+ openai: "an OpenAI API key (for a ChatGPT plan: airgate models connect chatgpt)"
8907
+ };
8908
+ function relayIfSignedIn(ctx, say) {
8909
+ const auth = localAuth(ctx.io.env);
8910
+ if (!Object.keys(auth).length || !ctx.api.signedIn) return null;
8911
+ try {
8912
+ return startRelay({ io: ctx.io, url: ctx.api.relayUrl(), fetch: ctx.fetch, auth, say: say ?? (() => void 0) });
8913
+ } catch {
8914
+ return null;
8915
+ }
8916
+ }
8917
+ var KIND = { oauth_token: "subscription token", api_key: "API key", chatgpt: "ChatGPT plan" };
8918
+ async function connectChatgpt(ctx, laptop) {
8919
+ const { ui: ui2 } = ctx;
8920
+ let text2;
8921
+ if (!ctx.io.stdin.isTTY) {
8922
+ text2 = await readValue(ctx, "a Codex auth.json", "cat auth.json | airgate models connect chatgpt");
8923
+ } else {
8924
+ if (spawnSync2("codex", ["--version"], { stdio: "ignore" }).status !== 0) {
8925
+ throw new CliError("Codex is not installed on this laptop", "npm i -g @openai/codex, then: airgate models connect chatgpt");
8926
+ }
8927
+ const home = mkdtempSync2(join6(tmpdir2(), "airgate-codex-"));
8928
+ try {
8929
+ ui2.err(ui2.ce("dim", "signing in to ChatGPT for airgate: a separate Codex login, so the one on this laptop stays as it is."));
8930
+ const args = ["login", ...bool(ctx.args, "device") ? ["--device-auth"] : [], "-c", 'cli_auth_credentials_store="file"'];
8931
+ const r2 = spawnSync2("codex", args, { stdio: "inherit", env: { ...process.env, ...ctx.io.env, CODEX_HOME: home } });
8932
+ if (r2.status !== 0) throw new CliError("the ChatGPT sign-in did not finish", "try again, or with --device");
8933
+ try {
8934
+ text2 = readFileSync7(join6(home, "auth.json"), "utf8");
8935
+ } catch {
8936
+ throw new CliError("Codex didn't write a login file", "update Codex (npm i -g @openai/codex) and try again");
8937
+ }
8938
+ } finally {
8939
+ rmSync3(home, { recursive: true, force: true });
8940
+ }
8941
+ }
8942
+ let tokens2;
8943
+ try {
8944
+ tokens2 = parseChatgptLogin(text2);
8945
+ } catch (e) {
8946
+ throw new CliError(e.message);
8947
+ }
8948
+ const value = JSON.stringify(tokens2);
8949
+ if (laptop) {
8950
+ saveLocal(ctx.io.env, "openai", value, "chatgpt");
8951
+ if (ctx.json) return ui2.json({ ok: true, vendor: "openai", kind: "chatgpt", where: "laptop" });
8952
+ ui2.out(`${ui2.c("ok", "saved")} on this laptop only (ChatGPT plan). airgate doesn't get it.`);
8953
+ return void ui2.out(ui2.c("dim", "pods' Codex uses it while this laptop is connected: airgate relay (airgate run and airgate mcp do this on their own)."));
8954
+ }
8955
+ const r = await ctx.api.connectModel("openai", value, "chatgpt");
8956
+ if (ctx.json) return ui2.json({ ...r, where: "airgate" });
8957
+ ui2.out(`${ui2.c("ok", "connected")}: ChatGPT plan for Codex, kept encrypted on airgate (renewed there) and never sent to a pod.`);
8958
+ ui2.out(ui2.c("dim", "undo: airgate models disconnect chatgpt"));
8959
+ }
8960
+ var models = {
8961
+ name: "models",
8962
+ group: "account",
8963
+ usage: "[connect|disconnect <claude|openai|chatgpt> [--laptop]]",
8964
+ summary: "how pods' agents sign in to their model: in the pod, through this laptop, or connected to airgate",
8965
+ example: "airgate models connect claude --laptop",
8966
+ notes: [
8967
+ "three ways, pick per your comfort: sign in inside the pod (nothing stored with airgate); this laptop signs (airgate relay: airgate holds nothing, works while the laptop is connected); or connect a sign-in to airgate (encrypted on the gateway, never sent to a pod).",
8968
+ "connect <claude|openai|chatgpt>: saves it on airgate. with --laptop: saves it only on this laptop (~/.config/airgate/models.json), for airgate relay.",
8969
+ "claude takes a subscription token (claude setup-token) or an API key; openai takes an API key. the value is read from a hidden prompt or stdin, never from the command line.",
8970
+ "chatgpt (Codex on a ChatGPT plan) runs `codex login` into a fresh folder, so airgate gets its own Codex login and never the one on this laptop (sharing one would sign you out here). --device uses the device-code login. or pipe a Codex auth.json in.",
8971
+ "this laptop also uses CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY or OPENAI_API_KEY from its environment."
8972
+ ],
8973
+ flags: {
8974
+ laptop: { type: "boolean", help: "keep the sign-in on this laptop only (for airgate relay)" },
8975
+ device: { type: "boolean", help: "chatgpt: sign in with a device code instead of this browser" }
8976
+ },
8977
+ async run(ctx) {
8978
+ const { ui: ui2 } = ctx;
8979
+ const [action, who, extra] = ctx.args.positionals;
8980
+ if (extra) throw new UsageError(`unexpected "${extra}"`, "values are read from a hidden prompt or stdin, never from arguments");
8981
+ const laptop = bool(ctx.args, "laptop");
8982
+ if (action === "connect" || action === "disconnect") {
8983
+ const vendor = vendorOf(who);
8984
+ if (!vendor) throw new UsageError("which one? claude or openai", `airgate models ${action} claude`);
8985
+ if (action === "disconnect") {
8986
+ if (laptop) {
8987
+ saveLocal(ctx.io.env, vendor, null);
8988
+ if (ctx.json) return ui2.json({ ok: true, vendor, where: "laptop" });
8989
+ return void ui2.out(`${vendorLabel(vendor)}: removed from this laptop.`);
8990
+ }
8991
+ const r2 = await ctx.api.disconnectModel(vendor);
8992
+ if (ctx.json) return ui2.json({ ...r2, vendor, where: "airgate" });
8993
+ return void ui2.out(r2.ok ? `${vendorLabel(vendor)}: disconnected from airgate. airgate no longer holds it.` : `${vendorLabel(vendor)} wasn't connected to airgate.`);
8994
+ }
8995
+ if (who?.toLowerCase() === "chatgpt") return connectChatgpt(ctx, laptop);
8996
+ if (!ctx.json && ctx.io.stdin.isTTY) ui2.err(ui2.ce("dim", `paste ${HOW[vendor]}.`));
8997
+ const value = (await readValue(ctx, vendor === "anthropic" ? "your Claude sign-in" : "your OpenAI key", `printf %s "$KEY" | airgate models connect ${who}${laptop ? " --laptop" : ""}`)).trim();
8998
+ if (value.length < 20 || /\s/.test(value)) throw new CliError("that doesn't look like a key or token", HOW[vendor]);
8999
+ if (laptop) {
9000
+ saveLocal(ctx.io.env, vendor, value);
9001
+ if (ctx.json) return ui2.json({ ok: true, vendor, kind: kindOf(vendor, value), where: "laptop" });
9002
+ ui2.out(`${ui2.c("ok", "saved")} on this laptop only (${kindOf(vendor, value) === "oauth_token" ? "subscription token" : "API key"}). airgate doesn't get it.`);
9003
+ return void ui2.out(ui2.c("dim", "pods use it while this laptop is connected: airgate relay (airgate run and airgate mcp do this on their own)."));
9004
+ }
9005
+ const r = await ctx.api.connectModel(vendor, value);
9006
+ if (ctx.json) return ui2.json({ ...r, where: "airgate" });
9007
+ ui2.out(`${ui2.c("ok", "connected")}: ${vendorLabel(vendor)} (${KIND[r.kind] ?? r.kind}), kept encrypted on airgate and never sent to a pod.`);
9008
+ return void ui2.out(ui2.c("dim", `every pod's ${vendor === "anthropic" ? "Claude Code" : "Codex"} can use it now, unless signed in inside the pod. undo: airgate models disconnect ${who}`));
9009
+ }
9010
+ if (action) throw new UsageError(`unknown "${action}"`, "airgate models \xB7 airgate models connect claude [--laptop] \xB7 airgate models disconnect claude [--laptop]");
9011
+ const st = await ctx.api.modelsAuth();
9012
+ const local = localAuth(ctx.io.env);
9013
+ if (ctx.json) return ui2.json({ ...st, thisLaptop: Object.fromEntries(Object.entries(local).map(([v, a]) => [v, { kind: a.kind, from: a.from }])) });
9014
+ const rows2 = ["anthropic", "openai"].map((v) => {
9015
+ const c = st.connected.find((x) => x.vendor === v);
9016
+ const l = local[v];
9017
+ return [
9018
+ vendorLabel(v),
9019
+ c ? ui2.c("ok", `connected to airgate (${KIND[c.kind] ?? c.kind})`) : ui2.c("dim", "not on airgate"),
9020
+ l ? `this laptop: ${l.kind === "chatgpt" ? "ChatGPT plan" : l.from}${st.laptop.connected && st.laptop.vendors.includes(v) ? ui2.c("ok", " \xB7 relaying") : ""}` : ui2.c("dim", "not on this laptop")
9021
+ ];
9022
+ });
9023
+ ui2.table(rows2, { head: ["model", "airgate", "laptop"] });
9024
+ ui2.out();
9025
+ ui2.out(ui2.c("dim", "a pod uses, in order: a sign-in made inside it \xB7 a connected laptop (airgate relay) \xB7 one connected to airgate."));
9026
+ if (!st.connected.length && !Object.keys(local).length) ui2.out(ui2.c("dim", "set one up: airgate models connect claude --laptop (airgate holds nothing) or airgate models connect claude."));
9027
+ if (!hasSavedFile(ctx.io.env) && Object.keys(local).length) ui2.out(ui2.c("dim", "this laptop signs from its environment variables."));
9028
+ }
9029
+ };
9030
+ var relay = {
9031
+ name: "relay",
9032
+ group: "account",
9033
+ usage: "",
9034
+ summary: "sign your pods' model calls from this laptop while it runs: airgate never holds your sign-in",
9035
+ example: "airgate relay",
9036
+ notes: [
9037
+ "uses this laptop's Claude / OpenAI sign-in (see airgate models). only pods with no sign-in of their own use it, and only while this runs.",
9038
+ "airgate run and airgate mcp start it on their own when this laptop has a sign-in."
9039
+ ],
9040
+ async run(ctx) {
9041
+ const { ui: ui2 } = ctx;
9042
+ const auth = localAuth(ctx.io.env);
9043
+ if (!Object.keys(auth).length) throw new CliError("no model sign-in on this laptop", "airgate models connect claude --laptop (or set CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY / OPENAI_API_KEY)");
9044
+ const r = startRelay({ io: ctx.io, url: ctx.api.relayUrl(), fetch: ctx.fetch, auth, say: (l) => ui2.err(ui2.ce("warn", l)) });
9045
+ const stop = () => r.stop();
9046
+ ctx.io.signals.on("SIGINT", stop);
9047
+ ctx.io.signal?.addEventListener("abort", stop);
9048
+ if (!ctx.json) {
9049
+ ui2.out(`relaying ${r.vendors.map(vendorLabel).join(" and ")} for your pods from this laptop. ${ui2.c("dim", "ctrl-c to stop.")}`);
9050
+ for (const v of r.vendors) ui2.out(ui2.c("dim", ` ${vendorLabel(v)}: ${auth[v].from}`));
9051
+ }
9052
+ try {
9053
+ await r.done;
9054
+ } finally {
9055
+ ctx.io.signals.off("SIGINT", stop);
9056
+ ctx.io.signal?.removeEventListener("abort", stop);
9057
+ }
9058
+ if (ctx.json) return ui2.json({ signed: r.count() });
9059
+ ui2.out(`stopped. ${r.count()} model call(s) signed.`);
9060
+ }
9061
+ };
9062
+
9063
+ // src/commands/run.ts
9064
+ var MODEL = {
9065
+ "claude-code": { vendor: "anthropic", secrets: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] },
9066
+ codex: { vendor: "openai", secrets: ["OPENAI_API_KEY"] }
8650
9067
  };
8651
9068
  var shortId = () => randomBytes(2).toString("hex");
8652
9069
  var runName = (base) => `${base.slice(0, 52)}-run-${shortId()}`;
8653
- async function signInWarning(ctx, agent) {
8654
- const need2 = SIGN_IN[agent];
8655
- if (!need2) return null;
9070
+ async function signInWarning(ctx, agent, relaying) {
9071
+ const m = MODEL[agent];
9072
+ if (!m || relaying.includes(m.vendor)) return null;
9073
+ const st = await ctx.api.modelsAuth().catch(() => null);
9074
+ if (st?.connected.some((c) => c.vendor === m.vendor)) return null;
8656
9075
  const list2 = await ctx.api.secrets().catch(() => null);
8657
- if (!list2) return null;
8658
- if (list2.account.some((s2) => need2.names.includes(s2.name))) return null;
8659
- return `no ${need2.names.join(" or ")} secret: ${AGENTS[agent].label} may not be signed in inside the pod. fix once for every pod: ${need2.fix}`;
9076
+ if (list2?.account.some((s2) => m.secrets.includes(s2.name))) return null;
9077
+ if (!st && !list2) return null;
9078
+ const who = m.vendor === "anthropic" ? "claude" : "chatgpt";
9079
+ return `${AGENTS[agent].label} has no sign-in for a fresh pod. pick one: airgate models connect ${who} --laptop (this laptop signs; airgate holds nothing), or airgate models connect ${who} (kept on airgate)`;
8660
9080
  }
8661
9081
  async function finish(ctx, pod, keep) {
8662
9082
  if (keep === "destroy") await ctx.ui.spin(`deleting ${pod.spec.name}`, ctx.api.destroy(pod.id));
@@ -8673,7 +9093,7 @@ var runTask = {
8673
9093
  "--no-apply prints the patch instead of applying it (the pod sleeps, so `airgate down --pod <it>` can still apply it); --keep keeps the pod (asleep).",
8674
9094
  "--repo owner/name (or a github link): runs on the repo itself instead (private repos need GitHub connected: airgate github connect). the work stays on a branch in the pod; --pr asks the agent to open a pull request, which waits in your airlock. the pod sleeps afterwards (free).",
8675
9095
  "if the task fails, the patch does not apply, or it runs past --timeout (default 60 minutes), the pod is kept so nothing is lost.",
8676
- "the agent needs a sign-in that works in any pod: for Claude Code run `claude setup-token`, then `airgate secrets set CLAUDE_CODE_OAUTH_TOKEN`."
9096
+ "the pod's agent needs a model sign-in: while run runs, this laptop signs for it if it has one (airgate models connect claude --laptop); or connect one to airgate (airgate models connect claude). see: airgate help models"
8677
9097
  ],
8678
9098
  flags: {
8679
9099
  repo: { type: "string", value: "owner/name", help: "run on a GitHub repo instead of this folder" },
@@ -8698,109 +9118,118 @@ var runTask = {
8698
9118
  if (!Number.isFinite(minutes) || minutes <= 0) throw new UsageError("--timeout: minutes, e.g. 30");
8699
9119
  const repo = str(ctx.args, "repo");
8700
9120
  const warnings = [];
8701
- const warn = await signInWarning(ctx, agent);
9121
+ const relayer = relayIfSignedIn(ctx);
9122
+ if (relayer && !ctx.json) ui2.err(ui2.ce("dim", `this laptop signs the pod's ${relayer.vendors.map(vendorLabel).join(" / ")} calls while it runs (airgate holds nothing)`));
9123
+ const warn = await signInWarning(ctx, agent, relayer?.vendors ?? []);
8702
9124
  if (warn) {
8703
9125
  warnings.push(warn);
8704
9126
  if (!ctx.json) ui2.err(ui2.ce("warn", warn));
8705
9127
  }
8706
- const work = async (pod2, text3) => {
8707
- let r2 = await ui2.spin(`handing ${pod2.spec.name} the task`, ctx.api.startTask(pod2.id, text3));
8708
- r2 = await ui2.spin(`${pod2.spec.name} is working (${AGENTS[agent].label})`, () => waitRun(ctx, pod2.id, r2, minutes * 6e4));
8709
- const log2 = await ctx.api.runLog(pod2.id, r2.id).catch(() => "");
8710
- return { run: r2, log: log2, done: r2.endedAt !== void 0, ok: r2.endedAt !== void 0 && r2.exitCode === 0 };
8711
- };
8712
- if (repo) {
8713
- if (dirArg) throw new UsageError("a folder or --repo, not both");
8714
- const look = await ctx.api.inspectRepo(repo).catch((e) => {
8715
- if (e instanceof ApiError && e.status === 404) return null;
8716
- throw e;
8717
- });
8718
- if (look && !look.reachable) {
8719
- const gh = await ctx.api.github().catch(() => null);
8720
- if (gh && !gh.configured) throw new CliError(`${look.repo} is private, and private repos need the Airgate GitHub App, which isn't set up on this gateway yet`, 'run it from a local clone instead: cd into it, then airgate run "\u2026"');
8721
- throw new CliError(`airgate can't reach ${look.repo} (private, and not shared with Airgate)`, "connect GitHub and install the app on it: airgate github connect");
9128
+ try {
9129
+ return await go();
9130
+ } finally {
9131
+ relayer?.stop();
9132
+ }
9133
+ async function go() {
9134
+ const work = async (pod2, text3) => {
9135
+ let r2 = await ui2.spin(`handing ${pod2.spec.name} the task`, ctx.api.startTask(pod2.id, text3));
9136
+ r2 = await ui2.spin(`${pod2.spec.name} is working (${AGENTS[agent].label})`, () => waitRun(ctx, pod2.id, r2, minutes * 6e4));
9137
+ const log2 = await ctx.api.runLog(pod2.id, r2.id).catch(() => "");
9138
+ return { run: r2, log: log2, done: r2.endedAt !== void 0, ok: r2.endedAt !== void 0 && r2.exitCode === 0 };
9139
+ };
9140
+ if (repo) {
9141
+ if (dirArg) throw new UsageError("a folder or --repo, not both");
9142
+ const look = await ctx.api.inspectRepo(repo).catch((e) => {
9143
+ if (e instanceof ApiError && e.status === 404) return null;
9144
+ throw e;
9145
+ });
9146
+ if (look && !look.reachable) {
9147
+ const gh = await ctx.api.github().catch(() => null);
9148
+ if (gh && !gh.configured) throw new CliError(`${look.repo} is private, and private repos need the Airgate GitHub App, which isn't set up on this gateway yet`, 'run it from a local clone instead: cd into it, then airgate run "\u2026"');
9149
+ throw new CliError(`airgate can't reach ${look.repo} (private, and not shared with Airgate)`, "connect GitHub and install the app on it: airgate github connect");
9150
+ }
9151
+ const name2 = runName((look?.repo ?? repo).split("/").pop() ?? "repo");
9152
+ const spec = { name: name2, agent, size: size2, repo: look?.repo ?? repo, masking: true, network: "allowlist", allow: DEFAULT_ALLOW, autoStopMinutes: null, autoCheckpoint: true };
9153
+ const pod2 = await ui2.spin(`launching ${name2} on ${spec.repo}`, async () => waitReady(ctx, await ctx.api.launch(spec)));
9154
+ const envFile2 = str(ctx.args, "env");
9155
+ if (envFile2) await ui2.spin(`sending ${envFile2} as secrets`, sendDotenv(ctx, envFile2, pod2.id));
9156
+ const text3 = task2 + (bool(ctx.args, "pr") ? '\n\nWhen you are done: commit on a new branch and open a pull request with `airgate pr --title "\u2026"` (it waits for my approval).' : "\n\nWhen you are done: commit your work on a new branch here and say which branch.");
9157
+ const r2 = await work(pod2, text3);
9158
+ await finish(ctx, pod2, r2.done ? "sleep" : "running");
9159
+ if (ctx.json) return ui2.json({ pod: { id: pod2.id, name: name2 }, repo: spec.repo, run: r2.run, ok: r2.ok, log: r2.log, pod_state: r2.done ? "sleeping" : "running", warnings });
9160
+ ctx.io.stdout.write(r2.log.endsWith("\n") || !r2.log ? r2.log : r2.log + "\n");
9161
+ if (!r2.done) ui2.err(ui2.ce("warn", `still working after ${minutes} min; it carries on. follow it: airgate log ${name2}`));
9162
+ ui2.out(`${r2.ok ? ui2.c("ok", "done") : ui2.c("bad", "failed")} on ${name2}${r2.done ? " (asleep now, free)" : ""}. look: airgate attach ${name2}${bool(ctx.args, "pr") ? " \xB7 the pull request: airgate airlock" : ""}`);
9163
+ return r2.ok ? 0 : 1;
8722
9164
  }
8723
- const name2 = runName((look?.repo ?? repo).split("/").pop() ?? "repo");
8724
- const spec = { name: name2, agent, size: size2, repo: look?.repo ?? repo, masking: true, network: "allowlist", allow: DEFAULT_ALLOW, autoStopMinutes: null, autoCheckpoint: true };
8725
- const pod2 = await ui2.spin(`launching ${name2} on ${spec.repo}`, async () => waitReady(ctx, await ctx.api.launch(spec)));
8726
- const envFile2 = str(ctx.args, "env");
8727
- if (envFile2) await ui2.spin(`sending ${envFile2} as secrets`, sendDotenv(ctx, envFile2, pod2.id));
8728
- const text3 = task2 + (bool(ctx.args, "pr") ? '\n\nWhen you are done: commit on a new branch and open a pull request with `airgate pr --title "\u2026"` (it waits for my approval).' : "\n\nWhen you are done: commit your work on a new branch here and say which branch.");
8729
- const r2 = await work(pod2, text3);
8730
- await finish(ctx, pod2, r2.done ? "sleep" : "running");
8731
- if (ctx.json) return ui2.json({ pod: { id: pod2.id, name: name2 }, repo: spec.repo, run: r2.run, ok: r2.ok, log: r2.log, pod_state: r2.done ? "sleeping" : "running", warnings });
8732
- ctx.io.stdout.write(r2.log.endsWith("\n") || !r2.log ? r2.log : r2.log + "\n");
8733
- if (!r2.done) ui2.err(ui2.ce("warn", `still working after ${minutes} min; it carries on. follow it: airgate log ${name2}`));
8734
- ui2.out(`${r2.ok ? ui2.c("ok", "done") : ui2.c("bad", "failed")} on ${name2}${r2.done ? " (asleep now, free)" : ""}. look: airgate attach ${name2}${bool(ctx.args, "pr") ? " \xB7 the pull request: airgate airlock" : ""}`);
8735
- return r2.ok ? 0 : 1;
8736
- }
8737
- const projectDir = resolve5(ctx.io.cwd, dirArg ?? ".");
8738
- if (!existsSync4(projectDir) || !statSync3(projectDir).isDirectory()) throw new CliError(`no directory ${projectDir}`);
8739
- const noApply = bool(ctx.args, "no-apply");
8740
- if (!noApply && !gitTopLevel(projectDir)) {
8741
- throw new CliError(`${projectDir} isn't a git repo, so the result can't come back as a patch`, "git init && git add -A && git commit -m base, or use --no-apply");
8742
- }
8743
- const sp = statePath(projectDir);
8744
- const before = existsSync4(sp) ? readFileSync6(sp, "utf8") : null;
8745
- const restoreState = () => {
8746
- try {
8747
- if (before !== null) writeFileSync4(sp, before);
8748
- else rmSync3(sp, { force: true });
8749
- } catch {
9165
+ const projectDir = resolve5(ctx.io.cwd, dirArg ?? ".");
9166
+ if (!existsSync5(projectDir) || !statSync3(projectDir).isDirectory()) throw new CliError(`no directory ${projectDir}`);
9167
+ const noApply = bool(ctx.args, "no-apply");
9168
+ if (!noApply && !gitTopLevel(projectDir)) {
9169
+ throw new CliError(`${projectDir} isn't a git repo, so the result can't come back as a patch`, "git init && git add -A && git commit -m base, or use --no-apply");
8750
9170
  }
8751
- };
8752
- const name = runName(podDirName(projectDir));
8753
- const upArgv = [projectDir, "--name", name, "--agent", agent, "--memory", size2, "--no-session", "--yes"];
8754
- const envFile = str(ctx.args, "env");
8755
- if (envFile) upArgv.push("--env", envFile);
8756
- const sent = await ui2.spin(`sending ${basename2(projectDir)} to a fresh pod`, runCommand(ctx, up, upArgv, ctx.io.cwd));
8757
- const pod = sent.pod;
8758
- const text2 = `Work in ~/${sent.dir} (a project sent from my laptop). ${task2}
9171
+ const sp = statePath(projectDir);
9172
+ const before = existsSync5(sp) ? readFileSync8(sp, "utf8") : null;
9173
+ const restoreState = () => {
9174
+ try {
9175
+ if (before !== null) writeFileSync5(sp, before);
9176
+ else rmSync4(sp, { force: true });
9177
+ } catch {
9178
+ }
9179
+ };
9180
+ const name = runName(podDirName(projectDir));
9181
+ const upArgv = [projectDir, "--name", name, "--agent", agent, "--memory", size2, "--no-session", "--yes"];
9182
+ const envFile = str(ctx.args, "env");
9183
+ if (envFile) upArgv.push("--env", envFile);
9184
+ const sent = await ui2.spin(`sending ${basename2(projectDir)} to a fresh pod`, runCommand(ctx, up, upArgv, ctx.io.cwd));
9185
+ const pod = sent.pod;
9186
+ const text2 = `Work in ~/${sent.dir} (a project sent from my laptop). ${task2}
8759
9187
 
8760
9188
  When you are done, leave your changes in the files there (no need to commit or push): they are brought back to my laptop as a patch.`;
8761
- let r;
8762
- try {
8763
- r = await work(pod, text2);
8764
- } catch (e) {
8765
- await finish(ctx, pod, "sleep");
8766
- throw e;
8767
- }
8768
- const keepNote = (why) => `${why}. the pod is kept (asleep): airgate attach ${name} \xB7 bring it back: airgate down --pod ${name} \xB7 delete it: airgate destroy ${name}`;
8769
- const out = (o) => ui2.json({ pod: { id: pod.id, name }, run: r.run, ok: r.ok, log: r.log, warnings, ...o });
8770
- if (!r.ok) {
8771
- await finish(ctx, pod, r.done ? "sleep" : "running");
8772
- if (ctx.json) return out({ applied: false, pod_state: r.done ? "sleeping" : "running" });
8773
- ctx.io.stdout.write(r.log.endsWith("\n") || !r.log ? r.log : r.log + "\n");
8774
- ui2.err(ui2.ce("bad", r.done ? keepNote(`the task failed (exit ${r.run.exitCode ?? "?"})`) : `still working after ${minutes} min; it carries on. follow it: airgate log ${name}, then: airgate down --pod ${name}`));
8775
- return 1;
8776
- }
8777
- let result;
8778
- try {
8779
- result = await ui2.spin(`bringing the changes back from ${name}`, runCommand(ctx, down2, ["--pod", name, "--no-session", ...noApply ? ["--dry-run"] : ["--yes"]], projectDir));
8780
- } catch (e) {
8781
- await finish(ctx, pod, "sleep");
8782
- const why = e instanceof Error ? e.message.split("\n")[0] : String(e);
8783
- if (ctx.json) return out({ applied: false, error: why, pod_state: "sleeping" });
9189
+ let r;
9190
+ try {
9191
+ r = await work(pod, text2);
9192
+ } catch (e) {
9193
+ await finish(ctx, pod, "sleep");
9194
+ throw e;
9195
+ }
9196
+ const keepNote = (why) => `${why}. the pod is kept (asleep): airgate attach ${name} \xB7 bring it back: airgate down --pod ${name} \xB7 delete it: airgate destroy ${name}`;
9197
+ const out = (o) => ui2.json({ pod: { id: pod.id, name }, run: r.run, ok: r.ok, log: r.log, warnings, ...o });
9198
+ if (!r.ok) {
9199
+ await finish(ctx, pod, r.done ? "sleep" : "running");
9200
+ if (ctx.json) return out({ applied: false, pod_state: r.done ? "sleeping" : "running" });
9201
+ ctx.io.stdout.write(r.log.endsWith("\n") || !r.log ? r.log : r.log + "\n");
9202
+ ui2.err(ui2.ce("bad", r.done ? keepNote(`the task failed (exit ${r.run.exitCode ?? "?"})`) : `still working after ${minutes} min; it carries on. follow it: airgate log ${name}, then: airgate down --pod ${name}`));
9203
+ return 1;
9204
+ }
9205
+ let result;
9206
+ try {
9207
+ result = await ui2.spin(`bringing the changes back from ${name}`, runCommand(ctx, down2, ["--pod", name, "--no-session", ...noApply ? ["--dry-run"] : ["--yes"]], projectDir));
9208
+ } catch (e) {
9209
+ await finish(ctx, pod, "sleep");
9210
+ const why = e instanceof Error ? e.message.split("\n")[0] : String(e);
9211
+ if (ctx.json) return out({ applied: false, error: why, pod_state: "sleeping" });
9212
+ ctx.io.stdout.write(r.log.endsWith("\n") || !r.log ? r.log : r.log + "\n");
9213
+ ui2.err(ui2.ce("bad", keepNote(`the task finished, but its changes didn't come back: ${why}`)));
9214
+ return 1;
9215
+ }
9216
+ const files = result.files ?? [];
9217
+ const keep = noApply || bool(ctx.args, "keep");
9218
+ await finish(ctx, pod, keep ? "sleep" : "destroy");
9219
+ if (!keep) restoreState();
9220
+ if (ctx.json) return out({ applied: !noApply && files.length > 0, files, ...noApply ? { patch: result.patch ?? "" } : {}, pod_state: keep ? "sleeping" : "deleted" });
8784
9221
  ctx.io.stdout.write(r.log.endsWith("\n") || !r.log ? r.log : r.log + "\n");
8785
- ui2.err(ui2.ce("bad", keepNote(`the task finished, but its changes didn't come back: ${why}`)));
8786
- return 1;
8787
- }
8788
- const files = result.files ?? [];
8789
- const keep = noApply || bool(ctx.args, "keep");
8790
- await finish(ctx, pod, keep ? "sleep" : "destroy");
8791
- if (!keep) restoreState();
8792
- if (ctx.json) return out({ applied: !noApply && files.length > 0, files, ...noApply ? { patch: result.patch ?? "" } : {}, pod_state: keep ? "sleeping" : "deleted" });
8793
- ctx.io.stdout.write(r.log.endsWith("\n") || !r.log ? r.log : r.log + "\n");
8794
- if (noApply) {
8795
- ctx.io.stdout.write(result.patch ?? "");
8796
- ui2.err(`${files.length} file(s) changed, not applied. apply: airgate down --pod ${name} \xB7 delete the pod: airgate destroy ${name}`);
9222
+ if (noApply) {
9223
+ ctx.io.stdout.write(result.patch ?? "");
9224
+ ui2.err(`${files.length} file(s) changed, not applied. apply: airgate down --pod ${name} \xB7 delete the pod: airgate destroy ${name}`);
9225
+ return 0;
9226
+ }
9227
+ const risky = files.filter((f) => f.risky).length;
9228
+ ui2.out(
9229
+ `${ui2.c("ok", "done")}: ${files.length ? `${files.length} file(s) changed here${risky ? `, ${risky} risky` : ""}. review: git status \xB7 git diff` : "no changes came back"}` + (keep ? ` \xB7 the pod is kept (asleep): ${name}` : " \xB7 the pod is deleted")
9230
+ );
8797
9231
  return 0;
8798
9232
  }
8799
- const risky = files.filter((f) => f.risky).length;
8800
- ui2.out(
8801
- `${ui2.c("ok", "done")}: ${files.length ? `${files.length} file(s) changed here${risky ? `, ${risky} risky` : ""}. review: git status \xB7 git diff` : "no changes came back"}` + (keep ? ` \xB7 the pod is kept (asleep): ${name}` : " \xB7 the pod is deleted")
8802
- );
8803
- return 0;
8804
9233
  }
8805
9234
  };
8806
9235
 
@@ -9176,6 +9605,10 @@ var mcp = {
9176
9605
  const rl = createInterface({ input: ctx.io.stdin, crlfDelay: Infinity, terminal: false });
9177
9606
  const pending = /* @__PURE__ */ new Set();
9178
9607
  if (!ctx.api.signedIn) ctx.io.stderr.write("airgate mcp: not signed in; tools will say so. run: airgate login\n");
9608
+ const relayer = relayIfSignedIn(ctx, (l) => ctx.io.stderr.write(`airgate mcp: ${l}
9609
+ `));
9610
+ if (relayer) ctx.io.stderr.write(`airgate mcp: signing pods' ${relayer.vendors.join(" / ")} calls from this laptop while this runs
9611
+ `);
9179
9612
  for await (const line2 of rl) {
9180
9613
  if (!line2.trim()) continue;
9181
9614
  let msg;
@@ -9193,13 +9626,14 @@ var mcp = {
9193
9626
  void job2.finally(() => pending.delete(job2));
9194
9627
  }
9195
9628
  await Promise.all(pending);
9629
+ relayer?.stop();
9196
9630
  }
9197
9631
  };
9198
9632
 
9199
9633
  // src/commands/misc.ts
9200
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync5 } from "node:fs";
9634
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync6 } from "node:fs";
9201
9635
  import { homedir as homedir3 } from "node:os";
9202
- import { join as join5 } from "node:path";
9636
+ import { join as join7 } from "node:path";
9203
9637
  var PAGES = ["pods", "airlock", "usage", "settings", "mask", "chat", "notes", "data", "status"];
9204
9638
  var open = {
9205
9639
  name: "open",
@@ -9275,12 +9709,12 @@ var skill = {
9275
9709
  if (!text2.startsWith("---")) throw new CliError(`${url} isn't a skill file`);
9276
9710
  if (action === "show") return void ctx.io.stdout.write(text2);
9277
9711
  if (action !== "install") throw new UsageError(`unknown action "${action}"`, "airgate skill install [--project] | airgate skill show");
9278
- const base = bool(ctx.args, "project") ? join5(ctx.io.cwd, ".claude") : join5(ctx.io.env.HOME ?? homedir3(), ".claude");
9279
- const dir = join5(base, "skills", "airgate");
9280
- mkdirSync4(dir, { recursive: true });
9281
- writeFileSync5(join5(dir, "SKILL.md"), text2);
9282
- if (ctx.json) return ctx.ui.json({ installed: join5(dir, "SKILL.md") });
9283
- ctx.ui.out(`${ctx.ui.c("ok", "installed")} the airgate skill: ${join5(dir, "SKILL.md")}`);
9712
+ const base = bool(ctx.args, "project") ? join7(ctx.io.cwd, ".claude") : join7(ctx.io.env.HOME ?? homedir3(), ".claude");
9713
+ const dir = join7(base, "skills", "airgate");
9714
+ mkdirSync5(dir, { recursive: true });
9715
+ writeFileSync6(join7(dir, "SKILL.md"), text2);
9716
+ if (ctx.json) return ctx.ui.json({ installed: join7(dir, "SKILL.md") });
9717
+ ctx.ui.out(`${ctx.ui.c("ok", "installed")} the airgate skill: ${join7(dir, "SKILL.md")}`);
9284
9718
  ctx.ui.out(ctx.ui.c("dim", 'claude code picks it up in its next session. ask it to "move this to an airgate pod".'));
9285
9719
  }
9286
9720
  };
@@ -10585,7 +11019,7 @@ function formModal(c, s2, f) {
10585
11019
  const t = TEMPLATES[formTemplate(f)];
10586
11020
  const builtin = formBuiltin(f);
10587
11021
  const plan2 = s2.me?.plan === "pro" ? "pro" : "payg";
10588
- const models = s2.catalog?.models ?? [];
11022
+ const models2 = s2.catalog?.models ?? [];
10589
11023
  fields.forEach((id, i) => {
10590
11024
  const y = b2.y + 1 + i;
10591
11025
  if (y >= b2.y + b2.h - 3) return;
@@ -10623,7 +11057,7 @@ function formModal(c, s2, f) {
10623
11057
  v = `[${f.builtin ? "x" : " "}] built-in model, no subscription`;
10624
11058
  break;
10625
11059
  case "model":
10626
- v = `\u2039 ${models[f.model]?.id ?? `default${s2.catalog?.defaultModel ? ` (${s2.catalog.defaultModel})` : ""}`} \u203A`;
11060
+ v = `\u2039 ${models2[f.model]?.id ?? `default${s2.catalog?.defaultModel ? ` (${s2.catalog.defaultModel})` : ""}`} \u203A`;
10627
11061
  break;
10628
11062
  case "schedule": {
10629
11063
  const p = SCHEDULE_LIST[f.schedule];
@@ -11057,6 +11491,8 @@ var COMMANDS = [
11057
11491
  up,
11058
11492
  down2,
11059
11493
  runTask,
11494
+ models,
11495
+ relay,
11060
11496
  usage,
11061
11497
  plan,
11062
11498
  upgrade,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "airgate",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Give your AI its own computer: the airgate command line.",
5
5
  "type": "module",
6
6
  "bin": {