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.
- package/README.md +5 -2
- package/dist/airgate.mjs +568 -132
- 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
|
|
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
|
|
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,
|
|
4378
|
-
if (
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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",
|
|
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.
|
|
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
|
|
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
|
|
7732
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
8648
|
-
|
|
8649
|
-
|
|
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
|
|
8655
|
-
if (!
|
|
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 (
|
|
8658
|
-
if (
|
|
8659
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
8707
|
-
|
|
8708
|
-
|
|
8709
|
-
|
|
8710
|
-
|
|
8711
|
-
|
|
8712
|
-
|
|
8713
|
-
|
|
8714
|
-
|
|
8715
|
-
|
|
8716
|
-
|
|
8717
|
-
}
|
|
8718
|
-
if (
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
|
|
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
|
|
8724
|
-
|
|
8725
|
-
const
|
|
8726
|
-
|
|
8727
|
-
|
|
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
|
-
|
|
8753
|
-
|
|
8754
|
-
|
|
8755
|
-
|
|
8756
|
-
|
|
8757
|
-
|
|
8758
|
-
|
|
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
|
-
|
|
8762
|
-
|
|
8763
|
-
|
|
8764
|
-
|
|
8765
|
-
|
|
8766
|
-
|
|
8767
|
-
|
|
8768
|
-
|
|
8769
|
-
|
|
8770
|
-
|
|
8771
|
-
|
|
8772
|
-
|
|
8773
|
-
|
|
8774
|
-
|
|
8775
|
-
|
|
8776
|
-
|
|
8777
|
-
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
|
|
8781
|
-
|
|
8782
|
-
|
|
8783
|
-
|
|
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
|
-
|
|
8786
|
-
|
|
8787
|
-
|
|
8788
|
-
|
|
8789
|
-
|
|
8790
|
-
|
|
8791
|
-
|
|
8792
|
-
|
|
8793
|
-
|
|
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
|
|
9634
|
+
import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync6 } from "node:fs";
|
|
9201
9635
|
import { homedir as homedir3 } from "node:os";
|
|
9202
|
-
import { join as
|
|
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") ?
|
|
9279
|
-
const dir =
|
|
9280
|
-
|
|
9281
|
-
|
|
9282
|
-
if (ctx.json) return ctx.ui.json({ installed:
|
|
9283
|
-
ctx.ui.out(`${ctx.ui.c("ok", "installed")} the airgate skill: ${
|
|
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
|
|
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 ${
|
|
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,
|