@tarfa/cli 0.1.5 → 0.1.7
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 +3 -2
- package/dist/{chunk-KTUXNG4Q.js → chunk-4M2ZRGQR.js} +739 -338
- package/dist/cli.js +143 -69
- package/dist/serve.js +38 -11
- package/package.json +1 -1
|
@@ -2463,8 +2463,6 @@ function deviceFingerprint(x25519PublicKey) {
|
|
|
2463
2463
|
|
|
2464
2464
|
// ../secure-channel/src/pairing-link.ts
|
|
2465
2465
|
var PREFIX = "#pair?";
|
|
2466
|
-
var KEY = /^[A-Za-z0-9_-]{43}$/;
|
|
2467
|
-
var MAX_NAME = 64;
|
|
2468
2466
|
var MAX_ADDRESSES = 4;
|
|
2469
2467
|
var MAX_PATH = 128;
|
|
2470
2468
|
function isPairingPath(path) {
|
|
@@ -2485,19 +2483,6 @@ function pairingLink(pwaOrigin, pairing) {
|
|
|
2485
2483
|
const query = fields.map(([name, value]) => `${name}=${encode(value)}`).join("&");
|
|
2486
2484
|
return `${new URL(pwaOrigin).origin}/${PREFIX}${query}`;
|
|
2487
2485
|
}
|
|
2488
|
-
function parsePairingLink(text) {
|
|
2489
|
-
const trimmed = text.trim();
|
|
2490
|
-
const hash = trimmed.startsWith("#") ? trimmed : trimmed.slice(Math.max(0, trimmed.indexOf("#")));
|
|
2491
|
-
if (!trimmed.includes("#") || !hash.startsWith(PREFIX)) return null;
|
|
2492
|
-
const query = new URLSearchParams(hash.slice(PREFIX.length));
|
|
2493
|
-
const urls = query.getAll("u");
|
|
2494
|
-
const [agentKey, secret] = ["k", "s"].map((name2) => query.get(name2) ?? "");
|
|
2495
|
-
const name = (query.get("n") ?? "").trim() || "machine";
|
|
2496
|
-
if (!KEY.test(agentKey) || !KEY.test(secret) || name.length > MAX_NAME) return null;
|
|
2497
|
-
if (urls.length === 0 || urls.length > MAX_ADDRESSES || !urls.every(isMachineUrl)) return null;
|
|
2498
|
-
const defaultPath = query.get("p") ?? "";
|
|
2499
|
-
return { urls, agentKey, secret, name, ...isPairingPath(defaultPath) ? { defaultPath } : {} };
|
|
2500
|
-
}
|
|
2501
2486
|
var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
2502
2487
|
function isMachineUrl(text) {
|
|
2503
2488
|
try {
|
|
@@ -2509,6 +2494,131 @@ function isMachineUrl(text) {
|
|
|
2509
2494
|
}
|
|
2510
2495
|
}
|
|
2511
2496
|
|
|
2497
|
+
// ../secure-channel/src/pairing-code.ts
|
|
2498
|
+
var PREFIX2 = "TARFA";
|
|
2499
|
+
var VERSION = 1;
|
|
2500
|
+
var MAX_PLAUSIBLE_VERSION = 15;
|
|
2501
|
+
var ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
2502
|
+
var KEY_BYTES = 32;
|
|
2503
|
+
var CHECKSUM_BYTES = 4;
|
|
2504
|
+
var MAX_NAME = 64;
|
|
2505
|
+
var SCHEMES = ["wss://", "ws://"];
|
|
2506
|
+
function pairingCode(pairing) {
|
|
2507
|
+
const out = [VERSION, ...rawKey(pairing.agentKey), ...rawKey(pairing.secret)];
|
|
2508
|
+
const text = (value) => {
|
|
2509
|
+
const bytes = new TextEncoder().encode(value);
|
|
2510
|
+
if (bytes.length > 65535) throw new Error("pairing code field too long");
|
|
2511
|
+
out.push(bytes.length >> 8, bytes.length & 255, ...bytes);
|
|
2512
|
+
};
|
|
2513
|
+
text(pairing.name);
|
|
2514
|
+
text(pairing.defaultPath ?? "");
|
|
2515
|
+
out.push(pairing.urls.length);
|
|
2516
|
+
for (const url of pairing.urls) {
|
|
2517
|
+
const scheme = SCHEMES.findIndex((s) => url.toLowerCase().startsWith(s));
|
|
2518
|
+
if (scheme < 0) throw new Error(`not a machine address: ${url}`);
|
|
2519
|
+
out.push(scheme);
|
|
2520
|
+
text(url.slice(SCHEMES[scheme].length));
|
|
2521
|
+
}
|
|
2522
|
+
const body = Uint8Array.from(out);
|
|
2523
|
+
const code = PREFIX2 + encode32(Uint8Array.from([...body, ...sha256(body).slice(0, CHECKSUM_BYTES)]));
|
|
2524
|
+
if (!parsePairingCode(code).ok) throw new Error("these pairing details do not make a valid pairing code");
|
|
2525
|
+
return code;
|
|
2526
|
+
}
|
|
2527
|
+
function rawKey(key2) {
|
|
2528
|
+
const bytes = /^[A-Za-z0-9_-]{43}$/.test(key2) ? b64url.decode(key2) : new Uint8Array();
|
|
2529
|
+
if (bytes.length !== KEY_BYTES || b64url.encode(bytes) !== key2) throw new Error("a pairing key must be 32 bytes, canonically encoded");
|
|
2530
|
+
return bytes;
|
|
2531
|
+
}
|
|
2532
|
+
function parsePairingCode(text) {
|
|
2533
|
+
const invalid = { ok: false, reason: "invalid" };
|
|
2534
|
+
const compact = text.replace(/\s+/g, "").toUpperCase();
|
|
2535
|
+
if (!compact.startsWith(PREFIX2)) return invalid;
|
|
2536
|
+
const bytes = decode32(compact.slice(PREFIX2.length));
|
|
2537
|
+
if (!bytes || bytes.length < 1 + CHECKSUM_BYTES) return invalid;
|
|
2538
|
+
if (bytes[0] > VERSION && bytes[0] <= MAX_PLAUSIBLE_VERSION) return { ok: false, reason: "newer" };
|
|
2539
|
+
const body = bytes.slice(0, -CHECKSUM_BYTES);
|
|
2540
|
+
const sum = sha256(body);
|
|
2541
|
+
if (!bytes.slice(-CHECKSUM_BYTES).every((b, i) => b === sum[i])) return invalid;
|
|
2542
|
+
if (body[0] !== VERSION) return invalid;
|
|
2543
|
+
const pairing = readBody(body);
|
|
2544
|
+
return pairing ? { ok: true, pairing } : invalid;
|
|
2545
|
+
}
|
|
2546
|
+
function readBody(body) {
|
|
2547
|
+
let at = 1;
|
|
2548
|
+
const take = (n) => {
|
|
2549
|
+
if (at + n > body.length) return null;
|
|
2550
|
+
at += n;
|
|
2551
|
+
return body.slice(at - n, at);
|
|
2552
|
+
};
|
|
2553
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
2554
|
+
const text = () => {
|
|
2555
|
+
const length = take(2);
|
|
2556
|
+
const bytes = length && take(length[0] << 8 | length[1]);
|
|
2557
|
+
if (!bytes) return null;
|
|
2558
|
+
try {
|
|
2559
|
+
return decoder.decode(bytes);
|
|
2560
|
+
} catch {
|
|
2561
|
+
return null;
|
|
2562
|
+
}
|
|
2563
|
+
};
|
|
2564
|
+
const agentKey = take(KEY_BYTES);
|
|
2565
|
+
const secret = take(KEY_BYTES);
|
|
2566
|
+
const rawName = text();
|
|
2567
|
+
const path = text();
|
|
2568
|
+
const count = take(1)?.[0];
|
|
2569
|
+
if (!agentKey || !secret || rawName === null || path === null || count === void 0) return null;
|
|
2570
|
+
if (count === 0 || count > MAX_ADDRESSES) return null;
|
|
2571
|
+
const urls = [];
|
|
2572
|
+
for (let i = 0; i < count; i++) {
|
|
2573
|
+
const scheme = SCHEMES[take(1)?.[0] ?? -1];
|
|
2574
|
+
const rest = text();
|
|
2575
|
+
if (!scheme || rest === null || !isMachineUrl(scheme + rest)) return null;
|
|
2576
|
+
urls.push(scheme + rest);
|
|
2577
|
+
}
|
|
2578
|
+
if (at !== body.length) return null;
|
|
2579
|
+
const name = rawName.trim() || "machine";
|
|
2580
|
+
if (name.length > MAX_NAME) return null;
|
|
2581
|
+
return {
|
|
2582
|
+
urls,
|
|
2583
|
+
agentKey: b64url.encode(agentKey),
|
|
2584
|
+
secret: b64url.encode(secret),
|
|
2585
|
+
name,
|
|
2586
|
+
// A path the phone cannot use costs the path, not the pairing.
|
|
2587
|
+
...isPairingPath(path) ? { defaultPath: path } : {}
|
|
2588
|
+
};
|
|
2589
|
+
}
|
|
2590
|
+
function encode32(bytes) {
|
|
2591
|
+
let out = "";
|
|
2592
|
+
let value = 0;
|
|
2593
|
+
let bits = 0;
|
|
2594
|
+
for (const byte of bytes) {
|
|
2595
|
+
value = (value << 8 | byte) & 4095;
|
|
2596
|
+
bits += 8;
|
|
2597
|
+
while (bits >= 5) {
|
|
2598
|
+
out += ALPHABET[value >>> bits - 5 & 31];
|
|
2599
|
+
bits -= 5;
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
return bits > 0 ? out + ALPHABET[value << 5 - bits & 31] : out;
|
|
2603
|
+
}
|
|
2604
|
+
function decode32(text) {
|
|
2605
|
+
const out = [];
|
|
2606
|
+
let value = 0;
|
|
2607
|
+
let bits = 0;
|
|
2608
|
+
for (const char of text) {
|
|
2609
|
+
const digit = ALPHABET.indexOf(char);
|
|
2610
|
+
if (digit < 0) return null;
|
|
2611
|
+
value = (value << 5 | digit) & 4095;
|
|
2612
|
+
bits += 5;
|
|
2613
|
+
if (bits >= 8) {
|
|
2614
|
+
out.push(value >>> bits - 8 & 255);
|
|
2615
|
+
bits -= 8;
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
if (bits >= 5 || (value & (1 << bits) - 1) !== 0) return null;
|
|
2619
|
+
return Uint8Array.from(out);
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2512
2622
|
// ../../node_modules/.pnpm/@noble+ciphers@2.4.0/node_modules/@noble/ciphers/utils.js
|
|
2513
2623
|
function isBytes3(a) {
|
|
2514
2624
|
return a instanceof Uint8Array || ArrayBuffer.isView(a) && a.constructor.name === "Uint8Array" && "BYTES_PER_ELEMENT" in a && a.BYTES_PER_ELEMENT === 1;
|
|
@@ -3740,10 +3850,10 @@ var SHELL_SAFE = /^[\w./@+-]+$/;
|
|
|
3740
3850
|
var ADDRESS = /^wss:\/\/[A-Za-z0-9.-]+(:\d{1,5})?(\/[\w-]{0,64})?$/;
|
|
3741
3851
|
var PWA_ORIGIN = /^https?:\/\/[A-Za-z0-9.-]+(:\d{1,5})?\/?$/;
|
|
3742
3852
|
var FUNNEL_PORTS_CAP = "https://tailscale.com/cap/funnel-ports?ports=";
|
|
3743
|
-
function planSetup(
|
|
3853
|
+
function planSetup(given, state) {
|
|
3744
3854
|
if (state.phonePortOwner === "other") {
|
|
3745
3855
|
return {
|
|
3746
|
-
error: `127.0.0.1:${
|
|
3856
|
+
error: `127.0.0.1:${given.phonePort} is taken by another process (another user's agent?): pick a free one with --phone-port, e.g. ${given.phonePort + 1}`
|
|
3747
3857
|
};
|
|
3748
3858
|
}
|
|
3749
3859
|
if (state.hooksPortOwner === "other") {
|
|
@@ -3757,21 +3867,27 @@ function planSetup(flags, state) {
|
|
|
3757
3867
|
if (state.adoption?.agentLive.kind === "other") {
|
|
3758
3868
|
return { error: `${state.adoption.agentLive.path} is not a link, and setup only ever removes a link there: move it away, then run tarfa setup again` };
|
|
3759
3869
|
}
|
|
3870
|
+
const reach = resolveReachability(given, state);
|
|
3871
|
+
if ("error" in reach) return reach;
|
|
3872
|
+
const flags = reach.flags;
|
|
3873
|
+
const relayHint = (error) => reach.relayDefault ? { error: `${error.error} (this machine was reached through a public relay, so setup defaults to Funnel; pass --tailscale serve for tailnet only)` } : error;
|
|
3760
3874
|
const target = `http://127.0.0.1:${flags.phonePort}`;
|
|
3761
3875
|
const path = flags.slug ? `/${flags.slug}` : "/";
|
|
3762
3876
|
const steps = [];
|
|
3763
|
-
const checks = [];
|
|
3877
|
+
const checks = [...reach.notes];
|
|
3764
3878
|
let serveStep;
|
|
3765
3879
|
let operatorStep;
|
|
3766
3880
|
let derived;
|
|
3881
|
+
let publicDerived = false;
|
|
3767
3882
|
if (flags.tailscale !== "none") {
|
|
3768
3883
|
const port = flags.tailscalePort ?? (flags.tailscale === "funnel" ? 1e4 : 443);
|
|
3769
3884
|
const checked = checkTailscale(flags.tailscale, port, state.tailscale);
|
|
3770
|
-
if ("error" in checked) return checked;
|
|
3885
|
+
if ("error" in checked) return relayHint(checked);
|
|
3771
3886
|
checks.push(...checked.checks);
|
|
3772
3887
|
if (state.serve === null) return { error: "could not read `tailscale serve status --json`: setup will not add a handler it cannot check against the existing ones" };
|
|
3773
3888
|
const served = serveNeeds(state.serve, `${checked.host}:${port}`, port, path, target, flags.tailscale === "funnel");
|
|
3774
|
-
if ("error" in served) return served;
|
|
3889
|
+
if ("error" in served) return relayHint(served);
|
|
3890
|
+
publicDerived = flags.tailscale === "funnel" || served.public;
|
|
3775
3891
|
if (served.public) checks.push(`tailscale: port ${port} is on Funnel, so this address is reachable from the internet`);
|
|
3776
3892
|
if (served.needed) serveStep = { kind: "tailscale-serve", port, path, target, funnel: flags.tailscale === "funnel" || served.public };
|
|
3777
3893
|
derived = `wss://${checked.host}:${port}${path === "/" ? "" : path}`;
|
|
@@ -3782,6 +3898,7 @@ function planSetup(flags, state) {
|
|
|
3782
3898
|
}
|
|
3783
3899
|
const addresses = [...derived ? [derived] : [], ...flags.addresses];
|
|
3784
3900
|
if (addresses.length > MAX_ADDRESSES) return { error: `a machine takes at most ${MAX_ADDRESSES} addresses (got ${addresses.length})` };
|
|
3901
|
+
checks.push(describeReachability(addresses.length > 0 ? addresses : reach.kept, state, derived !== void 0 ? { address: derived, public: publicDerived } : null));
|
|
3785
3902
|
if (state.legacyService) steps.push({ kind: "stop-legacy-service" });
|
|
3786
3903
|
if (state.stateDir === "legacy") steps.push({ kind: "move-state" });
|
|
3787
3904
|
const adoption = state.adoption;
|
|
@@ -3820,6 +3937,65 @@ function planSetup(flags, state) {
|
|
|
3820
3937
|
if (flags.pair) steps.push({ kind: "pair" });
|
|
3821
3938
|
return { steps, checks };
|
|
3822
3939
|
}
|
|
3940
|
+
var CHOOSE = "say whether phones must reach this machine without Tailscale: --tailscale funnel (with or without Tailscale, through Funnel) or --tailscale serve (only with Tailscale on); --tailscale none --address <wss-url> if you publish the phone port yourself";
|
|
3941
|
+
function resolveReachability(flags, state) {
|
|
3942
|
+
const env = withLegacyEnv(parseEnvFile(state.envText ?? ""));
|
|
3943
|
+
const relay = Boolean(env.TARFA_RELAY_URLS?.trim() || env.TARFA_RELAY_URL?.trim()) || state.legacyService;
|
|
3944
|
+
const kept = (env.TARFA_PHONE_ADDRESSES ?? "").split(",").map((a) => a.trim()).filter(Boolean);
|
|
3945
|
+
const settled = (tailscale, over = {}, notes = [], relayDefault = false) => ({
|
|
3946
|
+
flags: { ...flags, ...over, tailscale },
|
|
3947
|
+
kept,
|
|
3948
|
+
notes,
|
|
3949
|
+
relayDefault
|
|
3950
|
+
});
|
|
3951
|
+
if (flags.tailscale !== void 0) {
|
|
3952
|
+
const lost = relay && flags.tailscale === "serve" ? ["this machine was reached through a public relay, so phones reached it without Tailscale; with --tailscale serve they reach it only with Tailscale on (--tailscale funnel keeps the old reach)"] : [];
|
|
3953
|
+
return settled(flags.tailscale, {}, lost);
|
|
3954
|
+
}
|
|
3955
|
+
if (flags.addresses.length > 0 || kept.length > 0) return settled("none");
|
|
3956
|
+
const handler = phonePortHandler(state.serve, `http://127.0.0.1:${flags.phonePort}`, flags.slug, flags.tailscalePort);
|
|
3957
|
+
if (handler && relay && !handler.funnel) {
|
|
3958
|
+
return {
|
|
3959
|
+
error: `this machine was reached through a public relay, but its phone port is served on the tailnet only (port ${handler.port}): pass --tailscale funnel to keep it reachable without Tailscale, or --tailscale serve to make it tailnet-only`
|
|
3960
|
+
};
|
|
3961
|
+
}
|
|
3962
|
+
if (handler) {
|
|
3963
|
+
return settled(handler.funnel ? "funnel" : "serve", { tailscalePort: handler.port, ...handler.slug ? { slug: handler.slug } : {} });
|
|
3964
|
+
}
|
|
3965
|
+
if (relay) {
|
|
3966
|
+
return settled("funnel", {}, ["this machine was reached through a public relay, so setup keeps it reachable without Tailscale: --tailscale funnel (pass --tailscale serve for tailnet only)"], true);
|
|
3967
|
+
}
|
|
3968
|
+
return { error: CHOOSE };
|
|
3969
|
+
}
|
|
3970
|
+
function phonePortHandler(serve, target, slug, port) {
|
|
3971
|
+
const found = [];
|
|
3972
|
+
for (const [hostPort, web] of Object.entries(serve?.Web ?? {})) {
|
|
3973
|
+
for (const [path, handler] of Object.entries(web.Handlers ?? {})) {
|
|
3974
|
+
if (handler.Proxy?.replace(/\/$/, "") !== target) continue;
|
|
3975
|
+
if (slug !== void 0 && path !== `/${slug}`) continue;
|
|
3976
|
+
const at = Number(hostPort.slice(hostPort.lastIndexOf(":") + 1));
|
|
3977
|
+
if (port !== void 0 && at !== port) continue;
|
|
3978
|
+
found.push({ port: at, ...path === "/" ? {} : { slug: path.slice(1) }, funnel: serve?.AllowFunnel?.[hostPort] === true });
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3981
|
+
return found.find((h) => h.funnel) ?? found[0] ?? null;
|
|
3982
|
+
}
|
|
3983
|
+
function describeReachability(addresses, state, derived) {
|
|
3984
|
+
if (addresses.length === 0) {
|
|
3985
|
+
return "reachability: phones cannot reach this machine yet: it has no phone address (--tailscale funnel or serve, or --address)";
|
|
3986
|
+
}
|
|
3987
|
+
const self = (state.tailscale?.Self?.DNSName ?? "").replace(/\.$/, "");
|
|
3988
|
+
const kinds = addresses.map((address) => {
|
|
3989
|
+
if (derived?.address === address) return derived.public ? "public" : "tailnet";
|
|
3990
|
+
const url = new URL(address);
|
|
3991
|
+
if (state.serve === null || !self || url.hostname !== self) return "unknown";
|
|
3992
|
+
return state.serve.AllowFunnel?.[`${url.hostname}:${url.port || 443}`] === true ? "public" : "tailnet";
|
|
3993
|
+
});
|
|
3994
|
+
const list = addresses.join(", ");
|
|
3995
|
+
if (kinds.includes("public")) return `reachability: phones reach this machine with or without Tailscale (${list})`;
|
|
3996
|
+
if (kinds.every((k) => k === "tailnet")) return `reachability: phones reach this machine only with Tailscale on (${list})`;
|
|
3997
|
+
return `reachability: phones reach this machine at ${list}; setup cannot tell whether that needs Tailscale`;
|
|
3998
|
+
}
|
|
3823
3999
|
function planEnv(flags, state, addresses) {
|
|
3824
4000
|
const legacy = renameLegacyEnvLines(state.envText ?? "");
|
|
3825
4001
|
const before = legacy.text;
|
|
@@ -3971,8 +4147,8 @@ function parseSetupFlags(argv) {
|
|
|
3971
4147
|
if (phonePort === null) return { error: "--phone-port must be a port number (1-65535)" };
|
|
3972
4148
|
const slug = single.get("--slug");
|
|
3973
4149
|
if (slug !== void 0 && !SLUG.test(slug)) return { error: "--slug takes letters, digits, _ and - (at most 64)" };
|
|
3974
|
-
const tailscale = single.get("--tailscale")
|
|
3975
|
-
if (tailscale !== "serve" && tailscale !== "funnel" && tailscale !== "none") return { error: "--tailscale takes serve, funnel or none" };
|
|
4150
|
+
const tailscale = single.get("--tailscale");
|
|
4151
|
+
if (tailscale !== void 0 && tailscale !== "serve" && tailscale !== "funnel" && tailscale !== "none") return { error: "--tailscale takes serve, funnel or none" };
|
|
3976
4152
|
const tailscalePort = single.has("--tailscale-port") ? portNumber(single.get("--tailscale-port")) : void 0;
|
|
3977
4153
|
if (tailscalePort === null) return { error: "--tailscale-port must be a port number" };
|
|
3978
4154
|
const bad = addresses.find((a) => !isMachineUrl(a) || !ADDRESS.test(a));
|
|
@@ -3993,7 +4169,7 @@ function parseSetupFlags(argv) {
|
|
|
3993
4169
|
...defaultPath !== void 0 ? { defaultPath } : {},
|
|
3994
4170
|
phonePort,
|
|
3995
4171
|
...slug !== void 0 ? { slug } : {},
|
|
3996
|
-
tailscale,
|
|
4172
|
+
...tailscale !== void 0 ? { tailscale } : {},
|
|
3997
4173
|
...tailscalePort !== void 0 ? { tailscalePort } : {},
|
|
3998
4174
|
addresses,
|
|
3999
4175
|
...pwa !== void 0 ? { pwa } : {},
|
|
@@ -4207,6 +4383,7 @@ function loadConfig(rawEnv, home) {
|
|
|
4207
4383
|
// Usually a symlink to a vocabulary kept with the user's own dotfiles, so one list can
|
|
4208
4384
|
// serve every machine; TARFA_KEYTERMS_PATH points at it directly instead.
|
|
4209
4385
|
keytermsPath: env.TARFA_KEYTERMS_PATH ?? join3(stateDir, "keyterms.txt"),
|
|
4386
|
+
dictationKeysPath: join3(stateDir, "dictation-keys.json"),
|
|
4210
4387
|
clientLogPath: join3(stateDir, "client-log.jsonl"),
|
|
4211
4388
|
hookEventLogPath: join3(stateDir, "hook-events.jsonl"),
|
|
4212
4389
|
messageLogPath: join3(stateDir, "messages.jsonl"),
|
|
@@ -4938,9 +5115,25 @@ async function browseDir(deps, path, allowedRoots) {
|
|
|
4938
5115
|
};
|
|
4939
5116
|
}
|
|
4940
5117
|
|
|
4941
|
-
// src/infrastructure/
|
|
4942
|
-
import { mkdir as mkdir4,
|
|
5118
|
+
// src/infrastructure/atomic-write.ts
|
|
5119
|
+
import { chmod, mkdir as mkdir4, rename as rename3, rm, writeFile as writeFile3 } from "node:fs/promises";
|
|
4943
5120
|
import { dirname as dirname5 } from "node:path";
|
|
5121
|
+
async function atomicWrite(path, text, mode) {
|
|
5122
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
5123
|
+
const temp = `${path}.tmp-${process.pid}`;
|
|
5124
|
+
try {
|
|
5125
|
+
await writeFile3(temp, text, { mode });
|
|
5126
|
+
await chmod(temp, mode);
|
|
5127
|
+
await rename3(temp, path);
|
|
5128
|
+
} catch (err) {
|
|
5129
|
+
await rm(temp, { force: true });
|
|
5130
|
+
throw err;
|
|
5131
|
+
}
|
|
5132
|
+
}
|
|
5133
|
+
|
|
5134
|
+
// src/infrastructure/agent-key-file.ts
|
|
5135
|
+
import { mkdir as mkdir5, readFile as readFile4, writeFile as writeFile4 } from "node:fs/promises";
|
|
5136
|
+
import { dirname as dirname6 } from "node:path";
|
|
4944
5137
|
async function loadOrCreateAgentKeys(path) {
|
|
4945
5138
|
try {
|
|
4946
5139
|
return parseKeys(await readFile4(path, "utf8"), path);
|
|
@@ -4948,9 +5141,9 @@ async function loadOrCreateAgentKeys(path) {
|
|
|
4948
5141
|
if (err.code !== "ENOENT") throw err;
|
|
4949
5142
|
}
|
|
4950
5143
|
const keys = generateAgentKeys();
|
|
4951
|
-
await
|
|
5144
|
+
await mkdir5(dirname6(path), { recursive: true, mode: 448 });
|
|
4952
5145
|
try {
|
|
4953
|
-
await
|
|
5146
|
+
await writeFile4(path, `${JSON.stringify(keys, null, 2)}
|
|
4954
5147
|
`, { mode: 384, flag: "wx" });
|
|
4955
5148
|
return keys;
|
|
4956
5149
|
} catch (err) {
|
|
@@ -4958,6 +5151,16 @@ async function loadOrCreateAgentKeys(path) {
|
|
|
4958
5151
|
return parseKeys(await readFile4(path, "utf8"), path);
|
|
4959
5152
|
}
|
|
4960
5153
|
}
|
|
5154
|
+
async function readAgentPublicKey(path) {
|
|
5155
|
+
let text;
|
|
5156
|
+
try {
|
|
5157
|
+
text = await readFile4(path, "utf8");
|
|
5158
|
+
} catch (err) {
|
|
5159
|
+
if (err.code === "ENOENT") throw new Error(`no agent key at ${path}: run tarfa setup first`);
|
|
5160
|
+
throw err;
|
|
5161
|
+
}
|
|
5162
|
+
return parseKeys(text, path).x25519.publicKey;
|
|
5163
|
+
}
|
|
4961
5164
|
function parseKeys(text, path) {
|
|
4962
5165
|
const keyPattern = /^[A-Za-z0-9_-]{43}$/;
|
|
4963
5166
|
const parsed = JSON.parse(text);
|
|
@@ -5021,8 +5224,8 @@ function forgetDevice(roster, id2) {
|
|
|
5021
5224
|
}
|
|
5022
5225
|
|
|
5023
5226
|
// src/infrastructure/file-device-store.ts
|
|
5024
|
-
import { mkdir as
|
|
5025
|
-
import { dirname as
|
|
5227
|
+
import { mkdir as mkdir6, readFile as readFile5 } from "node:fs/promises";
|
|
5228
|
+
import { dirname as dirname7 } from "node:path";
|
|
5026
5229
|
import { z as z2 } from "zod";
|
|
5027
5230
|
var key = z2.string().regex(/^[A-Za-z0-9_-]{43}$/);
|
|
5028
5231
|
var device = z2.object({ id: z2.string(), x25519: key, ed25519: key, name: z2.string(), enrolledAt: z2.string() });
|
|
@@ -5069,7 +5272,7 @@ var FileDeviceStore = class {
|
|
|
5069
5272
|
}
|
|
5070
5273
|
if (parsed?.tokenEnrolment === false) return false;
|
|
5071
5274
|
}
|
|
5072
|
-
await
|
|
5275
|
+
await mkdir6(dirname7(this.filePath), { recursive: true, mode: 448 });
|
|
5073
5276
|
await this.update((roster) => roster);
|
|
5074
5277
|
return true;
|
|
5075
5278
|
}
|
|
@@ -5083,128 +5286,16 @@ var FileDeviceStore = class {
|
|
|
5083
5286
|
}
|
|
5084
5287
|
};
|
|
5085
5288
|
|
|
5086
|
-
// src/
|
|
5087
|
-
var
|
|
5088
|
-
var
|
|
5089
|
-
var
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
}
|
|
5094
|
-
function addPairing(list, secret, now, ttlMs = PAIRING_TTL_MS) {
|
|
5095
|
-
return [...retained(list, now), { secret, expiresAt: new Date(now.getTime() + ttlMs).toISOString() }];
|
|
5096
|
-
}
|
|
5097
|
-
function redeemPairing(list, secret, now, device2) {
|
|
5098
|
-
const kept = retained(list, now);
|
|
5099
|
-
const match = secret ? kept.find((p) => constantTimeEqual(p.secret, secret)) : void 0;
|
|
5100
|
-
if (!match) return { list: kept, outcome: "unknown" };
|
|
5101
|
-
if (match.usedAt && match.usedBy === device2) return { list: kept, outcome: "mine" };
|
|
5102
|
-
const state = stateOf(match, now);
|
|
5103
|
-
if (state !== "live") return { list: kept, outcome: state };
|
|
5104
|
-
const at = now.toISOString();
|
|
5105
|
-
const spent = { secret: match.secret, expiresAt: at, usedAt: at, usedBy: device2 };
|
|
5106
|
-
return { list: kept.map((p) => p === match ? spent : p), outcome: "redeemed" };
|
|
5107
|
-
}
|
|
5108
|
-
|
|
5109
|
-
// src/infrastructure/file-pairing-store.ts
|
|
5110
|
-
import { readFile as readFile6 } from "node:fs/promises";
|
|
5111
|
-
import { z as z3 } from "zod";
|
|
5112
|
-
var schema = z3.array(z3.object({ secret: z3.string().regex(/^[A-Za-z0-9_-]{43}$/), expiresAt: z3.string(), usedAt: z3.string().optional(), usedBy: z3.string().optional() }));
|
|
5113
|
-
var FilePairingStore = class {
|
|
5114
|
-
constructor(filePath, staleLockMs = 1e4) {
|
|
5115
|
-
this.filePath = filePath;
|
|
5116
|
-
this.staleLockMs = staleLockMs;
|
|
5117
|
-
}
|
|
5118
|
-
filePath;
|
|
5119
|
-
staleLockMs;
|
|
5120
|
-
async read() {
|
|
5121
|
-
try {
|
|
5122
|
-
const parsed = schema.safeParse(JSON.parse(await readFile6(this.filePath, "utf8")));
|
|
5123
|
-
return parsed.success ? parsed.data : [];
|
|
5124
|
-
} catch {
|
|
5125
|
-
return [];
|
|
5126
|
-
}
|
|
5127
|
-
}
|
|
5128
|
-
async update(change) {
|
|
5129
|
-
return withFileLock(this.filePath, this.staleLockMs, async () => {
|
|
5130
|
-
const current = await this.read();
|
|
5131
|
-
const next = change(current);
|
|
5132
|
-
if (JSON.stringify(next) !== JSON.stringify(current)) await writeFileAtomic(this.filePath, `${JSON.stringify(next, null, 2)}
|
|
5133
|
-
`);
|
|
5134
|
-
return next;
|
|
5135
|
-
});
|
|
5136
|
-
}
|
|
5137
|
-
};
|
|
5138
|
-
|
|
5139
|
-
// src/infrastructure/agent-env-file.ts
|
|
5140
|
-
import { execFile as execFile3 } from "node:child_process";
|
|
5141
|
-
import { existsSync as existsSync3 } from "node:fs";
|
|
5142
|
-
import { chmod, copyFile, mkdir as mkdir6, readFile as readFile7, writeFile as writeFile4 } from "node:fs/promises";
|
|
5143
|
-
import { dirname as dirname7, resolve as resolve5 } from "node:path";
|
|
5144
|
-
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
5145
|
-
import { promisify as promisify3 } from "node:util";
|
|
5146
|
-
|
|
5147
|
-
// src/application/agent-env-location.ts
|
|
5148
|
-
import { join as join6 } from "node:path";
|
|
5149
|
-
function resolveAgentEnvPath(input) {
|
|
5150
|
-
if (input.env.TARFA_AGENT_ENV) return input.env.TARFA_AGENT_ENV;
|
|
5151
|
-
const stateFile = join6(input.home, ".tarfa", "agent.env");
|
|
5152
|
-
if (input.exists(stateFile) || input.checkout === null) return stateFile;
|
|
5153
|
-
return join6(input.checkout, "deploy", "agent.env");
|
|
5154
|
-
}
|
|
5155
|
-
|
|
5156
|
-
// src/infrastructure/agent-env-file.ts
|
|
5157
|
-
var exec3 = promisify3(execFile3);
|
|
5158
|
-
function withoutGitVars(env) {
|
|
5159
|
-
return Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith("GIT_")));
|
|
5160
|
-
}
|
|
5161
|
-
async function mainCheckout(kind2 = installKind()) {
|
|
5162
|
-
if (kind2 === "package") return null;
|
|
5163
|
-
const here = dirname7(fileURLToPath2(import.meta.url));
|
|
5164
|
-
try {
|
|
5165
|
-
const { stdout } = await exec3("git", ["-C", here, "rev-parse", "--path-format=absolute", "--git-common-dir"], { env: withoutGitVars(process.env) });
|
|
5166
|
-
return dirname7(stdout.trim());
|
|
5167
|
-
} catch {
|
|
5168
|
-
return resolve5(here, "..", "..", "..", "..");
|
|
5169
|
-
}
|
|
5170
|
-
}
|
|
5171
|
-
async function locateAgentEnv(env, home, kind2 = installKind()) {
|
|
5172
|
-
return resolveAgentEnvPath({ env, home, checkout: await mainCheckout(kind2), exists: existsSync3 });
|
|
5173
|
-
}
|
|
5174
|
-
function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
|
|
5175
|
-
return {
|
|
5176
|
-
path,
|
|
5177
|
-
read: async () => {
|
|
5178
|
-
try {
|
|
5179
|
-
return await readFile7(path, "utf8");
|
|
5180
|
-
} catch (err) {
|
|
5181
|
-
if (err.code === "ENOENT") {
|
|
5182
|
-
throw new Error(`no agent config at ${path}: set the agent up first (docs/DEPLOY.md), or set TARFA_AGENT_ENV`);
|
|
5183
|
-
}
|
|
5184
|
-
throw err;
|
|
5185
|
-
}
|
|
5186
|
-
},
|
|
5187
|
-
write: async (text) => {
|
|
5188
|
-
let backup = `${path}.bak-${now().toISOString().replace(/[:.]/g, "-")}`;
|
|
5189
|
-
try {
|
|
5190
|
-
await copyFile(path, backup);
|
|
5191
|
-
await chmod(backup, 384);
|
|
5192
|
-
} catch (err) {
|
|
5193
|
-
if (err.code !== "ENOENT") throw err;
|
|
5194
|
-
backup = null;
|
|
5195
|
-
}
|
|
5196
|
-
await mkdir6(dirname7(path), { recursive: true, mode: 448 });
|
|
5197
|
-
await writeFile4(path, text, { mode: 384 });
|
|
5198
|
-
await chmod(path, 384);
|
|
5199
|
-
return backup;
|
|
5200
|
-
}
|
|
5201
|
-
};
|
|
5289
|
+
// ../contracts/src/domain/workspace-link.ts
|
|
5290
|
+
var WORKSPACE_LINK_PREFIX = "#open?";
|
|
5291
|
+
var KEY = /^[A-Za-z0-9_-]{43}$/;
|
|
5292
|
+
var ID = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
|
|
5293
|
+
var valid = (link) => KEY.test(link.agentKey) && ID.test(link.workspaceId);
|
|
5294
|
+
function workspaceLink(pwaOrigin, link) {
|
|
5295
|
+
if (!valid(link)) throw new Error(`not a linkable workspace: ${link.workspaceId}`);
|
|
5296
|
+
return `${new URL(pwaOrigin).origin}/${WORKSPACE_LINK_PREFIX}k=${link.agentKey}&w=${link.workspaceId}`;
|
|
5202
5297
|
}
|
|
5203
5298
|
|
|
5204
|
-
// src/infrastructure/file-status-store.ts
|
|
5205
|
-
import { readFileSync as readFileSync2, writeFileSync, mkdirSync } from "node:fs";
|
|
5206
|
-
import { dirname as dirname8 } from "node:path";
|
|
5207
|
-
|
|
5208
5299
|
// ../contracts/src/domain/workspace-status.ts
|
|
5209
5300
|
var WORKSPACE_STATUSES = [
|
|
5210
5301
|
"needs-feedback",
|
|
@@ -5228,6 +5319,17 @@ function runsClaude(command) {
|
|
|
5228
5319
|
return /(^|[\s/])claude(\s|$)/.test(command);
|
|
5229
5320
|
}
|
|
5230
5321
|
|
|
5322
|
+
// ../contracts/src/domain/dictation-key-format.ts
|
|
5323
|
+
var SHAPES = {
|
|
5324
|
+
elevenlabs: [/^sk_[A-Za-z0-9]{32,}$/, /^[0-9a-f]{32}$/],
|
|
5325
|
+
openrouter: [/^sk-or-v\d+-[A-Za-z0-9]{32,}$/]
|
|
5326
|
+
};
|
|
5327
|
+
function dictationKeyProblem(provider, key2) {
|
|
5328
|
+
if (SHAPES[provider].some((shape) => shape.test(key2))) return null;
|
|
5329
|
+
const other = provider === "elevenlabs" ? "openrouter" : "elevenlabs";
|
|
5330
|
+
return SHAPES[other].some((shape) => shape.test(key2)) ? "other-provider" : "format";
|
|
5331
|
+
}
|
|
5332
|
+
|
|
5231
5333
|
// ../contracts/src/domain/image-media-type.ts
|
|
5232
5334
|
var BY_EXT = {
|
|
5233
5335
|
png: "image/png",
|
|
@@ -5273,244 +5375,274 @@ function audioMediaType(path) {
|
|
|
5273
5375
|
}
|
|
5274
5376
|
|
|
5275
5377
|
// ../contracts/src/protocol/messages.ts
|
|
5276
|
-
import { z as
|
|
5378
|
+
import { z as z5 } from "zod";
|
|
5277
5379
|
|
|
5278
5380
|
// ../contracts/src/protocol/session-channels.ts
|
|
5279
|
-
import { z as
|
|
5381
|
+
import { z as z4 } from "zod";
|
|
5280
5382
|
|
|
5281
5383
|
// ../contracts/src/protocol/client-log.ts
|
|
5282
|
-
import { z as
|
|
5283
|
-
var clientLogEventSchema =
|
|
5284
|
-
sess:
|
|
5384
|
+
import { z as z3 } from "zod";
|
|
5385
|
+
var clientLogEventSchema = z3.object({
|
|
5386
|
+
sess: z3.string(),
|
|
5285
5387
|
// per-app-launch id
|
|
5286
|
-
seq:
|
|
5388
|
+
seq: z3.number().int().nonnegative(),
|
|
5287
5389
|
// monotonic within sess
|
|
5288
|
-
t:
|
|
5390
|
+
t: z3.number(),
|
|
5289
5391
|
// epoch ms at emit
|
|
5290
|
-
machineId:
|
|
5392
|
+
machineId: z3.string().optional(),
|
|
5291
5393
|
// absent for app-global events (visibility/online)
|
|
5292
|
-
kind:
|
|
5293
|
-
code:
|
|
5394
|
+
kind: z3.string(),
|
|
5395
|
+
code: z3.number().int().optional(),
|
|
5294
5396
|
// WS close code, when reported
|
|
5295
|
-
reason:
|
|
5397
|
+
reason: z3.string().optional(),
|
|
5296
5398
|
// classified diagnostic message / close reason
|
|
5297
|
-
online:
|
|
5298
|
-
vis:
|
|
5399
|
+
online: z3.boolean(),
|
|
5400
|
+
vis: z3.string()
|
|
5299
5401
|
});
|
|
5300
|
-
var clientLogBatchSchema =
|
|
5301
|
-
ua:
|
|
5302
|
-
shell:
|
|
5303
|
-
events:
|
|
5402
|
+
var clientLogBatchSchema = z3.object({
|
|
5403
|
+
ua: z3.string().optional(),
|
|
5404
|
+
shell: z3.string().optional(),
|
|
5405
|
+
events: z3.array(clientLogEventSchema).min(1).max(500)
|
|
5304
5406
|
});
|
|
5305
5407
|
|
|
5306
5408
|
// ../contracts/src/protocol/session-channels.ts
|
|
5307
|
-
var id =
|
|
5308
|
-
var mediaResultSchema =
|
|
5409
|
+
var id = z4.string().min(1).max(64);
|
|
5410
|
+
var mediaResultSchema = z4.enum(["ok", "forbidden", "not-found", "too-large", "error"]);
|
|
5309
5411
|
var sessionClientMessageSchemas = [
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5412
|
+
z4.object({ type: z4.literal("readMedia"), id, path: z4.string() }),
|
|
5413
|
+
z4.object({ type: z4.literal("mediaAck"), id, seq: z4.number().int().nonnegative() }),
|
|
5414
|
+
z4.object({ type: z4.literal("mediaCancel"), id }),
|
|
5313
5415
|
// A browser view: `data` is one message of the browser stream protocol (browser-messages.ts)
|
|
5314
5416
|
// as JSON text. Each `browser` the agent sends
|
|
5315
5417
|
// is acknowledged with a `browserAck`, which is what lets the agent drop stale frames for a
|
|
5316
5418
|
// slow phone (see BrowserStreamHandler).
|
|
5317
|
-
|
|
5318
|
-
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5419
|
+
z4.object({ type: z4.literal("browserOpen"), stream: id, session: z4.string() }),
|
|
5420
|
+
z4.object({ type: z4.literal("browser"), stream: id, data: z4.string() }),
|
|
5421
|
+
z4.object({ type: z4.literal("browserAck"), stream: id }),
|
|
5422
|
+
z4.object({ type: z4.literal("browserClose"), stream: id }),
|
|
5423
|
+
z4.object({ type: z4.literal("clientLog"), id, batch: clientLogBatchSchema })
|
|
5322
5424
|
];
|
|
5323
5425
|
var sessionAgentMessageSchemas = [
|
|
5324
5426
|
// The answer to `readMedia`. `size` and `mediaType` come with `ok`; `size` bytes then follow
|
|
5325
5427
|
// as chunks numbered from 0.
|
|
5326
|
-
|
|
5327
|
-
type:
|
|
5428
|
+
z4.object({
|
|
5429
|
+
type: z4.literal("media"),
|
|
5328
5430
|
id,
|
|
5329
5431
|
result: mediaResultSchema,
|
|
5330
|
-
mediaType:
|
|
5331
|
-
size:
|
|
5432
|
+
mediaType: z4.string().optional(),
|
|
5433
|
+
size: z4.number().int().nonnegative().optional()
|
|
5332
5434
|
}),
|
|
5333
|
-
|
|
5334
|
-
|
|
5435
|
+
z4.object({ type: z4.literal("mediaChunk"), id, seq: z4.number().int().nonnegative(), data: z4.string() }),
|
|
5436
|
+
z4.object({ type: z4.literal("browser"), stream: id, data: z4.string() }),
|
|
5335
5437
|
// The agent ended the stream (the session closed, was taken over, or never existed); the
|
|
5336
5438
|
// reason, when there is one, came just before as a browser-protocol `ended`.
|
|
5337
|
-
|
|
5338
|
-
|
|
5439
|
+
z4.object({ type: z4.literal("browserClosed"), stream: id }),
|
|
5440
|
+
z4.object({ type: z4.literal("clientLogAck"), id, ok: z4.boolean() })
|
|
5339
5441
|
];
|
|
5340
5442
|
|
|
5341
5443
|
// ../contracts/src/protocol/messages.ts
|
|
5342
|
-
var workspaceStatusSchema =
|
|
5444
|
+
var workspaceStatusSchema = z5.enum(
|
|
5343
5445
|
WORKSPACE_STATUSES
|
|
5344
5446
|
);
|
|
5345
|
-
var workspaceSchema =
|
|
5346
|
-
machineId:
|
|
5347
|
-
id:
|
|
5348
|
-
name:
|
|
5349
|
-
projectPath:
|
|
5350
|
-
command:
|
|
5351
|
-
createdAt:
|
|
5352
|
-
lastActivityAt:
|
|
5447
|
+
var workspaceSchema = z5.object({
|
|
5448
|
+
machineId: z5.string(),
|
|
5449
|
+
id: z5.string(),
|
|
5450
|
+
name: z5.string(),
|
|
5451
|
+
projectPath: z5.string(),
|
|
5452
|
+
command: z5.string(),
|
|
5453
|
+
createdAt: z5.number(),
|
|
5454
|
+
lastActivityAt: z5.number(),
|
|
5353
5455
|
status: workspaceStatusSchema,
|
|
5354
5456
|
// Optional GitHub owner/repo (parsed from the origin remote by the agent). `.optional()`
|
|
5355
5457
|
// keeps a version-skewed rollout safe: an old agent omits it, a new one includes it.
|
|
5356
|
-
github:
|
|
5458
|
+
github: z5.object({ owner: z5.string(), repo: z5.string(), ownerType: z5.enum(["user", "org"]).optional() }).optional(),
|
|
5357
5459
|
// Manual "pin to top of its status group" flag. `.optional()` keeps a version-skewed
|
|
5358
5460
|
// rollout safe: an old agent omits it and the PWA reads it as not-urgent.
|
|
5359
|
-
urgent:
|
|
5461
|
+
urgent: z5.boolean().optional(),
|
|
5360
5462
|
// The name Claude Code's session registry gives the session running in this workspace.
|
|
5361
5463
|
// `.optional()` keeps a version-skewed rollout safe: an old agent omits it and the PWA
|
|
5362
5464
|
// simply shows no badge. It must be declared here or zod strips it off the wire.
|
|
5363
|
-
agentAddress:
|
|
5364
|
-
parentId:
|
|
5365
|
-
waitingOn:
|
|
5366
|
-
asking:
|
|
5465
|
+
agentAddress: z5.string().optional(),
|
|
5466
|
+
parentId: z5.string().optional(),
|
|
5467
|
+
waitingOn: z5.literal("parent").optional(),
|
|
5468
|
+
asking: z5.boolean().optional()
|
|
5367
5469
|
});
|
|
5368
|
-
var dim =
|
|
5369
|
-
var browserSessionSchema =
|
|
5370
|
-
var machineStatsSchema =
|
|
5371
|
-
cpuPercent:
|
|
5372
|
-
memory:
|
|
5373
|
-
swapUsedBytes:
|
|
5374
|
-
limit:
|
|
5375
|
-
usedBytes:
|
|
5376
|
-
totalBytes:
|
|
5377
|
-
stallPercent:
|
|
5470
|
+
var dim = z5.number().int().positive();
|
|
5471
|
+
var browserSessionSchema = z5.object({ name: z5.string() });
|
|
5472
|
+
var machineStatsSchema = z5.object({
|
|
5473
|
+
cpuPercent: z5.number(),
|
|
5474
|
+
memory: z5.object({ usedBytes: z5.number(), totalBytes: z5.number() }),
|
|
5475
|
+
swapUsedBytes: z5.number(),
|
|
5476
|
+
limit: z5.object({
|
|
5477
|
+
usedBytes: z5.number(),
|
|
5478
|
+
totalBytes: z5.number(),
|
|
5479
|
+
stallPercent: z5.number().optional(),
|
|
5378
5480
|
// The kill line. `totalBytes` is min(high, max) — the throttle line the machine is
|
|
5379
5481
|
// normally pinned against — so headroom, which measures the distance to an OOM kill,
|
|
5380
5482
|
// needs `memory.max` separately. Absent when the cgroup sets no hard limit.
|
|
5381
|
-
maxBytes:
|
|
5483
|
+
maxBytes: z5.number().optional(),
|
|
5382
5484
|
// Page cache plus reclaimable slab, less mapped file pages: memory the kernel can
|
|
5383
5485
|
// hand back without killing anything. This is what makes `usedBytes` sitting at the
|
|
5384
5486
|
// ceiling harmless, and what headroom adds to the distance-to-max.
|
|
5385
|
-
reclaimableBytes:
|
|
5487
|
+
reclaimableBytes: z5.number().optional(),
|
|
5386
5488
|
// Cumulative `memory.events` oom_kill for the cgroup. The client keeps the first
|
|
5387
5489
|
// value it sees per connection and colours on the delta, so the agent stays dumb.
|
|
5388
|
-
oomKills:
|
|
5490
|
+
oomKills: z5.number().optional()
|
|
5389
5491
|
}).optional()
|
|
5390
5492
|
});
|
|
5391
|
-
var
|
|
5493
|
+
var dictationProviderSchema = z5.enum(["elevenlabs", "openrouter"]);
|
|
5494
|
+
var clientMessageSchema = z5.discriminatedUnion("type", [
|
|
5392
5495
|
// `token` carries a `tarfa pair` code for a new phone, or nothing (an enrolled key needs none).
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5496
|
+
z5.object({ type: z5.literal("auth"), token: z5.string() }),
|
|
5497
|
+
z5.object({ type: z5.literal("list") }),
|
|
5498
|
+
z5.object({ type: z5.literal("getRecentPaths") }),
|
|
5499
|
+
z5.object({ type: z5.literal("browseDir"), path: z5.string() }),
|
|
5397
5500
|
// Create a new directory named `name` inside the already-browsed `parent`. The agent
|
|
5398
5501
|
// authorizes it against the same allowed-roots set as browseDir, then replies with a
|
|
5399
5502
|
// `dirEntries` for the new (empty) folder so the picker browses into it.
|
|
5400
|
-
|
|
5401
|
-
|
|
5503
|
+
z5.object({ type: z5.literal("makeDir"), parent: z5.string(), name: z5.string() }),
|
|
5504
|
+
z5.object({ type: z5.literal("listRoots") }),
|
|
5402
5505
|
// No longer answered; kept so an agent does not refuse it from an older PWA.
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5506
|
+
z5.object({ type: z5.literal("listCommands") }),
|
|
5507
|
+
z5.object({ type: z5.literal("listLaunchers") }),
|
|
5508
|
+
z5.object({ type: z5.literal("forgetLauncher"), command: z5.string() }),
|
|
5509
|
+
z5.object({ type: z5.literal("listKeyterms") }),
|
|
5510
|
+
// Asked only of an agent whose authResult lists AGENT_FEATURES.dictationKeys.
|
|
5511
|
+
z5.object({ type: z5.literal("getDictationKeys") }),
|
|
5512
|
+
// Store (`key`) or remove (`null`) one of the machine's dictation keys, as `tarfa dictation-key`
|
|
5513
|
+
// does. Sent only to an agent whose authResult lists AGENT_FEATURES.setDictationKey; the key is
|
|
5514
|
+
// checked by the agent, which answers `dictationKeySet` with the same `id`, so a late answer to
|
|
5515
|
+
// an earlier save is never taken for this one's.
|
|
5516
|
+
z5.object({ type: z5.literal("setDictationKey"), id: z5.string(), provider: dictationProviderSchema, key: z5.string().nullable() }),
|
|
5517
|
+
z5.object({ type: z5.literal("readFile"), path: z5.string() }),
|
|
5518
|
+
z5.object({ type: z5.literal("attach"), workspaceId: z5.string(), cols: dim, rows: dim }),
|
|
5519
|
+
z5.object({ type: z5.literal("detach"), workspaceId: z5.string() }),
|
|
5520
|
+
z5.object({ type: z5.literal("input"), workspaceId: z5.string(), data: z5.string() }),
|
|
5521
|
+
z5.object({ type: z5.literal("putFile"), workspaceId: z5.string(), name: z5.string(), data: z5.string() }),
|
|
5412
5522
|
// A dictation streamed as it is spoken: 16 kHz mono s16le PCM (base64) the agent plays into
|
|
5413
5523
|
// the machine's virtual mic while holding space in the workspace, so Claude Code's /voice
|
|
5414
5524
|
// transcribes it. `id` names the dictation, so a late reply to an older one is never taken for
|
|
5415
5525
|
// the current one's. Answered with one `voiceDone`; an older agent answers `bad_message`.
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
5526
|
+
z5.object({ type: z5.literal("voiceStart"), workspaceId: z5.string(), id: z5.string() }),
|
|
5527
|
+
z5.object({ type: z5.literal("voiceChunk"), id: z5.string(), data: z5.string() }),
|
|
5528
|
+
z5.object({ type: z5.literal("voiceEnd"), id: z5.string() }),
|
|
5419
5529
|
// The text of Claude's last reply in a workspace, read aloud on the phone. Sent only to an agent
|
|
5420
5530
|
// whose authResult names the `lastReply` feature.
|
|
5421
|
-
|
|
5422
|
-
|
|
5531
|
+
z5.object({ type: z5.literal("lastReply"), workspaceId: z5.string() }),
|
|
5532
|
+
z5.object({ type: z5.literal("resize"), workspaceId: z5.string(), cols: dim, rows: dim }),
|
|
5423
5533
|
// `remember` is set only when the command came from the Custom field: the agent then adds it
|
|
5424
5534
|
// to the machine's launcher list. Additive, so an older agent just ignores it.
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5535
|
+
z5.object({ type: z5.literal("create"), projectPath: z5.string(), command: z5.string(), remember: z5.boolean().optional() }),
|
|
5536
|
+
z5.object({ type: z5.literal("close"), workspaceId: z5.string() }),
|
|
5537
|
+
z5.object({ type: z5.literal("setStatus"), workspaceId: z5.string(), status: workspaceStatusSchema }),
|
|
5428
5538
|
// Manually pin/unpin a workspace to the top of its status group. Orthogonal to setStatus;
|
|
5429
5539
|
// the agent's onChange broadcasts the refreshed workspace to every device.
|
|
5430
|
-
|
|
5540
|
+
z5.object({ type: z5.literal("setUrgent"), workspaceId: z5.string(), urgent: z5.boolean() }),
|
|
5431
5541
|
// Start an agent-browser session named after the workspace. Old agents answer with their
|
|
5432
5542
|
// generic unknown-message error; the PWA only offers this once the agent has proven
|
|
5433
5543
|
// support by sending `browserSessions`.
|
|
5434
|
-
|
|
5544
|
+
z5.object({ type: z5.literal("startBrowser"), workspaceId: z5.string() }),
|
|
5435
5545
|
// Liveness probe: the PWA pings periodically so it can detect a half-open socket that
|
|
5436
5546
|
// never fires a `close` event (common on mobile) and force a reconnect. The agent answers
|
|
5437
5547
|
// with `pong`.
|
|
5438
|
-
|
|
5548
|
+
z5.object({ type: z5.literal("ping") }),
|
|
5439
5549
|
...sessionClientMessageSchemas
|
|
5440
5550
|
]);
|
|
5441
|
-
var detachReasonSchema =
|
|
5551
|
+
var detachReasonSchema = z5.enum(["opened-elsewhere", "closed", "error"]);
|
|
5442
5552
|
var PROTOCOL_VERSION = 1;
|
|
5443
|
-
var AGENT_FEATURES = {
|
|
5444
|
-
|
|
5553
|
+
var AGENT_FEATURES = {
|
|
5554
|
+
lastReply: "lastReply",
|
|
5555
|
+
dictationKeys: "dictationKeys",
|
|
5556
|
+
setDictationKey: "setDictationKey",
|
|
5557
|
+
addresses: "addresses"
|
|
5558
|
+
};
|
|
5559
|
+
var agentMessageSchema = z5.discriminatedUnion("type", [
|
|
5445
5560
|
// `credential` is what authenticated the session (`token` only from an agent from before token
|
|
5446
5561
|
// enrolment was removed); `enrolled` says the device key is enrolled (written), so the phone
|
|
5447
5562
|
// forgets its pairing secret. Both optional: an older agent sends neither.
|
|
5448
5563
|
// `pushed`: the agent served the phone at once (its hello asked) and has sent the opening replies
|
|
5449
5564
|
// (workspaces, keyterms) behind it, so the phone does not ask for them.
|
|
5450
|
-
|
|
5451
|
-
type:
|
|
5452
|
-
ok:
|
|
5453
|
-
credential:
|
|
5454
|
-
enrolled:
|
|
5455
|
-
pushed:
|
|
5565
|
+
z5.object({
|
|
5566
|
+
type: z5.literal("authResult"),
|
|
5567
|
+
ok: z5.boolean(),
|
|
5568
|
+
credential: z5.enum(["device", "pairing", "token"]).optional().catch(void 0),
|
|
5569
|
+
enrolled: z5.boolean().optional().catch(void 0),
|
|
5570
|
+
pushed: z5.literal(true).optional().catch(void 0),
|
|
5456
5571
|
// `features` names what this agent answers beyond the protocol, so the PWA offers a control
|
|
5457
5572
|
// only to an agent that supports it (a worktree agent shares a release's version number).
|
|
5458
|
-
|
|
5573
|
+
// `addresses`: where phones reach this agent now (its TARFA_PHONE_ADDRESSES), which a phone
|
|
5574
|
+
// adopts only once it has connected through each one.
|
|
5575
|
+
agent: z5.object({
|
|
5576
|
+
protocol: z5.number().int(),
|
|
5577
|
+
version: z5.string().optional(),
|
|
5578
|
+
features: z5.array(z5.string()).optional().catch(void 0),
|
|
5579
|
+
addresses: z5.array(z5.string()).optional().catch(void 0)
|
|
5580
|
+
}).optional().catch(void 0)
|
|
5459
5581
|
}),
|
|
5460
5582
|
// `claudeVoice`: whether this machine can play a Claude voice dictation (see voiceStart).
|
|
5461
5583
|
// An older agent omits it, and the PWA then treats Claude voice as unavailable there.
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5584
|
+
z5.object({ type: z5.literal("workspaces"), workspaces: z5.array(workspaceSchema), claudeVoice: z5.boolean().optional() }),
|
|
5585
|
+
z5.object({ type: z5.literal("workspaceUpdated"), workspace: workspaceSchema }),
|
|
5586
|
+
z5.object({ type: z5.literal("recentPaths"), paths: z5.array(z5.string()) }),
|
|
5465
5587
|
// The agent's configured project roots — the folder picker's top-level browse points.
|
|
5466
5588
|
// `roots` is additive: older agents omit it, so it defaults to [] rather than letting the
|
|
5467
5589
|
// strict client parser drop the whole message during a version-skewed rollout.
|
|
5468
|
-
|
|
5590
|
+
z5.object({ type: z5.literal("roots"), roots: z5.array(z5.string()).default([]) }),
|
|
5469
5591
|
// What the Create Workspace command list offers on this machine: the known CLIs found
|
|
5470
5592
|
// installed (`claude`, `codex`) and the custom commands launched here before. Additive like
|
|
5471
5593
|
// `roots`: an older agent never sends it and the PWA keeps its fixed list.
|
|
5472
|
-
|
|
5594
|
+
z5.object({ type: z5.literal("launchers"), detected: z5.array(z5.string()).default([]), custom: z5.array(z5.string()).default([]) }),
|
|
5473
5595
|
// The agent machine's dictation vocabulary (~/.tarfa/keyterms.txt), passed to ElevenLabs as
|
|
5474
5596
|
// keyterms so jargon transcribes correctly. Additive like `roots`: older agents omit it,
|
|
5475
5597
|
// so it defaults to [] instead of letting the strict client parser drop the whole message.
|
|
5476
|
-
|
|
5598
|
+
z5.object({ type: z5.literal("keyterms"), keyterms: z5.array(z5.string()).default([]) }),
|
|
5599
|
+
// The dictation provider keys the agent's operator stored (~/.tarfa/dictation-keys.json), sent
|
|
5600
|
+
// only in reply to getDictationKeys. A key the agent does not hold is absent.
|
|
5601
|
+
z5.object({
|
|
5602
|
+
type: z5.literal("dictationKeys"),
|
|
5603
|
+
elevenlabs: z5.string().optional().catch(void 0),
|
|
5604
|
+
openrouter: z5.string().optional().catch(void 0)
|
|
5605
|
+
}),
|
|
5606
|
+
// The answer to setDictationKey. `error` is the agent's fixed wording of a refusal and never
|
|
5607
|
+
// holds the key.
|
|
5608
|
+
z5.object({ type: z5.literal("dictationKeySet"), id: z5.string(), provider: dictationProviderSchema, ok: z5.boolean(), error: z5.string().optional() }),
|
|
5477
5609
|
// `files` is additive: older agents that predate it omit the field, so it defaults to []
|
|
5478
5610
|
// (a strictly-required field would make the strict client parser silently drop their
|
|
5479
5611
|
// dirEntries during a version-skewed rollout, hanging the browser on "Loading…").
|
|
5480
|
-
|
|
5612
|
+
z5.object({ type: z5.literal("dirEntries"), path: z5.string(), subdirs: z5.array(z5.string()), files: z5.array(z5.string()).default([]) }),
|
|
5481
5613
|
// `truncated`/`binary` are additive: older agents omit them, so they default to false (a
|
|
5482
5614
|
// strictly-required field would make the strict client parser drop the whole message
|
|
5483
5615
|
// during a version-skewed rollout).
|
|
5484
|
-
|
|
5485
|
-
type:
|
|
5486
|
-
path:
|
|
5487
|
-
data:
|
|
5488
|
-
truncated:
|
|
5489
|
-
binary:
|
|
5616
|
+
z5.object({
|
|
5617
|
+
type: z5.literal("fileContents"),
|
|
5618
|
+
path: z5.string(),
|
|
5619
|
+
data: z5.string(),
|
|
5620
|
+
truncated: z5.boolean().default(false),
|
|
5621
|
+
binary: z5.boolean().default(false)
|
|
5490
5622
|
}),
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5623
|
+
z5.object({ type: z5.literal("attached"), workspaceId: z5.string() }),
|
|
5624
|
+
z5.object({ type: z5.literal("output"), workspaceId: z5.string(), data: z5.string() }),
|
|
5625
|
+
z5.object({ type: z5.literal("fileStored"), workspaceId: z5.string(), path: z5.string() }),
|
|
5494
5626
|
// `text` is null when the agent holds no reply for that workspace (none since it started, or not Claude).
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5627
|
+
z5.object({ type: z5.literal("lastReply"), workspaceId: z5.string(), text: z5.string().nullable() }),
|
|
5628
|
+
z5.object({ type: z5.literal("voiceDone"), id: z5.string(), ok: z5.boolean(), error: z5.string().optional() }),
|
|
5629
|
+
z5.object({ type: z5.literal("detached"), workspaceId: z5.string(), reason: detachReasonSchema }),
|
|
5630
|
+
z5.object({ type: z5.literal("closed"), workspaceId: z5.string() }),
|
|
5499
5631
|
// A phone was just enrolled on this machine (a pairing code), told to every other
|
|
5500
5632
|
// phone connected to it: a device nobody meant to add must not go unnoticed. An older PWA drops
|
|
5501
5633
|
// the unknown type.
|
|
5502
|
-
|
|
5503
|
-
|
|
5634
|
+
z5.object({ type: z5.literal("deviceEnrolled"), id: z5.string(), name: z5.string(), at: z5.string() }),
|
|
5635
|
+
z5.object({ type: z5.literal("error"), code: z5.string(), message: z5.string() }),
|
|
5504
5636
|
// Live agent-browser sessions, sent after every `workspaces` reply. `sessions` defaults
|
|
5505
5637
|
// to [] so a skewed sender omitting it can't make the strict client parser drop the
|
|
5506
5638
|
// whole message.
|
|
5507
|
-
|
|
5639
|
+
z5.object({ type: z5.literal("browserSessions"), sessions: z5.array(browserSessionSchema).default([]) }),
|
|
5508
5640
|
// The agent machine's current CPU/memory load (see machineStatsSchema). Pushed on a
|
|
5509
5641
|
// timer while at least one client is connected; an agent that predates it simply never
|
|
5510
5642
|
// sends one and the PWA shows no load strip for that machine.
|
|
5511
|
-
|
|
5643
|
+
z5.object({ type: z5.literal("stats"), stats: machineStatsSchema }),
|
|
5512
5644
|
// Reply to the client's liveness `ping` (see clientMessageSchema).
|
|
5513
|
-
|
|
5645
|
+
z5.object({ type: z5.literal("pong") }),
|
|
5514
5646
|
...sessionAgentMessageSchemas
|
|
5515
5647
|
]);
|
|
5516
5648
|
function parseClientMessage(raw) {
|
|
@@ -5518,64 +5650,64 @@ function parseClientMessage(raw) {
|
|
|
5518
5650
|
}
|
|
5519
5651
|
|
|
5520
5652
|
// ../contracts/src/protocol/browser-messages.ts
|
|
5521
|
-
import { z as
|
|
5522
|
-
var modifiers =
|
|
5523
|
-
var browserClientMessageSchema =
|
|
5524
|
-
|
|
5653
|
+
import { z as z6 } from "zod";
|
|
5654
|
+
var modifiers = z6.number().int().optional();
|
|
5655
|
+
var browserClientMessageSchema = z6.discriminatedUnion("type", [
|
|
5656
|
+
z6.object({ type: z6.literal("auth"), token: z6.string() }),
|
|
5525
5657
|
// Liveness probe, same pattern as the main terminal socket.
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
type:
|
|
5529
|
-
eventType:
|
|
5530
|
-
x:
|
|
5531
|
-
y:
|
|
5532
|
-
button:
|
|
5533
|
-
clickCount:
|
|
5534
|
-
deltaX:
|
|
5535
|
-
deltaY:
|
|
5658
|
+
z6.object({ type: z6.literal("ping") }),
|
|
5659
|
+
z6.object({
|
|
5660
|
+
type: z6.literal("input_mouse"),
|
|
5661
|
+
eventType: z6.string(),
|
|
5662
|
+
x: z6.number(),
|
|
5663
|
+
y: z6.number(),
|
|
5664
|
+
button: z6.string().optional(),
|
|
5665
|
+
clickCount: z6.number().int().optional(),
|
|
5666
|
+
deltaX: z6.number().optional(),
|
|
5667
|
+
deltaY: z6.number().optional(),
|
|
5536
5668
|
modifiers
|
|
5537
5669
|
}),
|
|
5538
|
-
|
|
5539
|
-
type:
|
|
5540
|
-
eventType:
|
|
5541
|
-
key:
|
|
5542
|
-
code:
|
|
5543
|
-
text:
|
|
5544
|
-
windowsVirtualKeyCode:
|
|
5670
|
+
z6.object({
|
|
5671
|
+
type: z6.literal("input_keyboard"),
|
|
5672
|
+
eventType: z6.string(),
|
|
5673
|
+
key: z6.string().optional(),
|
|
5674
|
+
code: z6.string().optional(),
|
|
5675
|
+
text: z6.string().optional(),
|
|
5676
|
+
windowsVirtualKeyCode: z6.number().int().optional(),
|
|
5545
5677
|
modifiers
|
|
5546
5678
|
}),
|
|
5547
|
-
|
|
5548
|
-
type:
|
|
5549
|
-
eventType:
|
|
5550
|
-
touchPoints:
|
|
5679
|
+
z6.object({
|
|
5680
|
+
type: z6.literal("input_touch"),
|
|
5681
|
+
eventType: z6.string(),
|
|
5682
|
+
touchPoints: z6.array(z6.object({ x: z6.number(), y: z6.number() }).passthrough()),
|
|
5551
5683
|
modifiers
|
|
5552
5684
|
}),
|
|
5553
5685
|
// Handled by the agent (CLI navigation/session control), never forwarded upstream.
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5686
|
+
z6.object({ type: z6.literal("navigate"), url: z6.string() }),
|
|
5687
|
+
z6.object({ type: z6.literal("back") }),
|
|
5688
|
+
z6.object({ type: z6.literal("forward") }),
|
|
5689
|
+
z6.object({ type: z6.literal("closeSession") }),
|
|
5558
5690
|
// Resize the remote browser viewport to the attached viewer's canvas (CSS pixels).
|
|
5559
|
-
|
|
5560
|
-
type:
|
|
5561
|
-
width:
|
|
5562
|
-
height:
|
|
5691
|
+
z6.object({
|
|
5692
|
+
type: z6.literal("setViewport"),
|
|
5693
|
+
width: z6.number().int().positive(),
|
|
5694
|
+
height: z6.number().int().positive()
|
|
5563
5695
|
})
|
|
5564
5696
|
]);
|
|
5565
|
-
var browserEndedReasonSchema =
|
|
5566
|
-
var browserAgentMessageSchema =
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
type:
|
|
5572
|
-
data:
|
|
5697
|
+
var browserEndedReasonSchema = z6.enum(["closed", "not-found", "error", "opened-elsewhere"]);
|
|
5698
|
+
var browserAgentMessageSchema = z6.discriminatedUnion("type", [
|
|
5699
|
+
z6.object({ type: z6.literal("authResult"), ok: z6.boolean() }),
|
|
5700
|
+
z6.object({ type: z6.literal("pong") }),
|
|
5701
|
+
z6.object({ type: z6.literal("ended"), reason: browserEndedReasonSchema }),
|
|
5702
|
+
z6.object({
|
|
5703
|
+
type: z6.literal("frame"),
|
|
5704
|
+
data: z6.string(),
|
|
5573
5705
|
// base64 JPEG
|
|
5574
|
-
metadata:
|
|
5706
|
+
metadata: z6.object({ deviceWidth: z6.number(), deviceHeight: z6.number() }).passthrough()
|
|
5575
5707
|
}).passthrough(),
|
|
5576
|
-
|
|
5577
|
-
|
|
5578
|
-
|
|
5708
|
+
z6.object({ type: z6.literal("tabs") }).passthrough(),
|
|
5709
|
+
z6.object({ type: z6.literal("url"), url: z6.string() }).passthrough(),
|
|
5710
|
+
z6.object({ type: z6.literal("status") }).passthrough()
|
|
5579
5711
|
]);
|
|
5580
5712
|
function parseBrowserClientMessage(raw) {
|
|
5581
5713
|
return browserClientMessageSchema.parse(raw);
|
|
@@ -5584,7 +5716,267 @@ function parseBrowserAgentMessage(raw) {
|
|
|
5584
5716
|
return browserAgentMessageSchema.parse(raw);
|
|
5585
5717
|
}
|
|
5586
5718
|
|
|
5719
|
+
// src/application/dictation-keys.ts
|
|
5720
|
+
var DICTATION_PROVIDERS = ["elevenlabs", "openrouter"];
|
|
5721
|
+
var MAX_KEY_LENGTH = 512;
|
|
5722
|
+
function providerOf(name) {
|
|
5723
|
+
return DICTATION_PROVIDERS.includes(name) ? name : null;
|
|
5724
|
+
}
|
|
5725
|
+
function checkKey(raw) {
|
|
5726
|
+
const key2 = raw.trim();
|
|
5727
|
+
if (key2 === "") return { ok: false, error: "no key given" };
|
|
5728
|
+
if (/\s/.test(key2)) return { ok: false, error: "a key holds no whitespace" };
|
|
5729
|
+
if (key2.length > MAX_KEY_LENGTH) return { ok: false, error: `a key is at most ${MAX_KEY_LENGTH} characters` };
|
|
5730
|
+
return { ok: true, key: key2 };
|
|
5731
|
+
}
|
|
5732
|
+
var SHAPE = {
|
|
5733
|
+
elevenlabs: "an ElevenLabs key starts with sk_ followed by at least 32 letters and digits",
|
|
5734
|
+
openrouter: "an OpenRouter key starts with sk-or-v1- followed by at least 32 letters and digits"
|
|
5735
|
+
};
|
|
5736
|
+
var OTHER = { elevenlabs: "OpenRouter", openrouter: "ElevenLabs" };
|
|
5737
|
+
function keyFormatError(provider, key2) {
|
|
5738
|
+
const problem = dictationKeyProblem(provider, key2);
|
|
5739
|
+
if (problem === null) return null;
|
|
5740
|
+
return problem === "other-provider" ? `this looks like an ${OTHER[provider]} key; ${SHAPE[provider]}` : SHAPE[provider];
|
|
5741
|
+
}
|
|
5742
|
+
function parseDictationKeys(raw) {
|
|
5743
|
+
let value;
|
|
5744
|
+
try {
|
|
5745
|
+
value = JSON.parse(raw);
|
|
5746
|
+
} catch {
|
|
5747
|
+
return "malformed";
|
|
5748
|
+
}
|
|
5749
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return "malformed";
|
|
5750
|
+
const keys = {};
|
|
5751
|
+
for (const provider of DICTATION_PROVIDERS) {
|
|
5752
|
+
const candidate = value[provider];
|
|
5753
|
+
if (typeof candidate !== "string") continue;
|
|
5754
|
+
const checked = checkKey(candidate);
|
|
5755
|
+
if (checked.ok) keys[provider] = checked.key;
|
|
5756
|
+
}
|
|
5757
|
+
return keys;
|
|
5758
|
+
}
|
|
5759
|
+
function withKey(keys, provider, key2) {
|
|
5760
|
+
return { ...keys, [provider]: key2 };
|
|
5761
|
+
}
|
|
5762
|
+
function withoutKey(keys, provider) {
|
|
5763
|
+
const { [provider]: _removed, ...rest } = keys;
|
|
5764
|
+
return rest;
|
|
5765
|
+
}
|
|
5766
|
+
async function changeDictationKey(store, provider, raw) {
|
|
5767
|
+
let key2 = null;
|
|
5768
|
+
if (raw !== null) {
|
|
5769
|
+
const checked = checkKey(raw);
|
|
5770
|
+
if (!checked.ok) return checked;
|
|
5771
|
+
const formatError = keyFormatError(provider, checked.key);
|
|
5772
|
+
if (formatError) return { ok: false, error: formatError };
|
|
5773
|
+
key2 = checked.key;
|
|
5774
|
+
}
|
|
5775
|
+
const result = await store.update((keys) => key2 === null ? withoutKey(keys, provider) : withKey(keys, provider, key2));
|
|
5776
|
+
return "problem" in result ? { ok: false, error: result.problem } : { ok: true };
|
|
5777
|
+
}
|
|
5778
|
+
|
|
5779
|
+
// src/infrastructure/file-dictation-keys-store.ts
|
|
5780
|
+
import { readFile as readFile6, stat as stat3 } from "node:fs/promises";
|
|
5781
|
+
|
|
5782
|
+
// src/application/private-files.ts
|
|
5783
|
+
function exposedFiles(files) {
|
|
5784
|
+
return files.filter(({ mode }) => (mode & 63) !== 0).map(({ path, mode }) => `${path} is readable or writable by other users (mode ${(mode & 511).toString(8)}): run \`chmod 600 ${path}\``);
|
|
5785
|
+
}
|
|
5786
|
+
|
|
5787
|
+
// src/infrastructure/file-dictation-keys-store.ts
|
|
5788
|
+
var STALE_LOCK_MS = 1e4;
|
|
5789
|
+
var FileDictationKeysStore = class {
|
|
5790
|
+
constructor(path, report = () => {
|
|
5791
|
+
}) {
|
|
5792
|
+
this.path = path;
|
|
5793
|
+
this.report = report;
|
|
5794
|
+
}
|
|
5795
|
+
path;
|
|
5796
|
+
report;
|
|
5797
|
+
async load() {
|
|
5798
|
+
let mode;
|
|
5799
|
+
try {
|
|
5800
|
+
mode = (await stat3(this.path)).mode;
|
|
5801
|
+
} catch (err) {
|
|
5802
|
+
if (err.code === "ENOENT") return { keys: {} };
|
|
5803
|
+
return { problem: `${this.path} cannot be read (${err.code ?? "error"})` };
|
|
5804
|
+
}
|
|
5805
|
+
const exposed = exposedFiles([{ path: this.path, mode }]);
|
|
5806
|
+
if (exposed.length > 0) return { problem: exposed[0] };
|
|
5807
|
+
let raw;
|
|
5808
|
+
try {
|
|
5809
|
+
raw = await readFile6(this.path, "utf8");
|
|
5810
|
+
} catch (err) {
|
|
5811
|
+
return { problem: `${this.path} cannot be read (${err.code ?? "error"})` };
|
|
5812
|
+
}
|
|
5813
|
+
const keys = parseDictationKeys(raw);
|
|
5814
|
+
return keys === "malformed" ? { problem: `${this.path} is malformed` } : { keys };
|
|
5815
|
+
}
|
|
5816
|
+
async read() {
|
|
5817
|
+
const loaded = await this.load();
|
|
5818
|
+
if ("problem" in loaded) {
|
|
5819
|
+
this.report(loaded.problem);
|
|
5820
|
+
return {};
|
|
5821
|
+
}
|
|
5822
|
+
return loaded.keys;
|
|
5823
|
+
}
|
|
5824
|
+
async update(change) {
|
|
5825
|
+
return withFileLock(this.path, STALE_LOCK_MS, async () => {
|
|
5826
|
+
const loaded = await this.load();
|
|
5827
|
+
if ("problem" in loaded) return { problem: loaded.problem };
|
|
5828
|
+
await atomicWrite(this.path, `${JSON.stringify(change(loaded.keys), null, 2)}
|
|
5829
|
+
`, 384);
|
|
5830
|
+
return { ok: true };
|
|
5831
|
+
});
|
|
5832
|
+
}
|
|
5833
|
+
};
|
|
5834
|
+
|
|
5835
|
+
// src/application/pairing.ts
|
|
5836
|
+
var PAIRING_TTL_MS = 20 * 6e4;
|
|
5837
|
+
var PAIRING_RETAIN_MS = 24 * 60 * 6e4;
|
|
5838
|
+
var retained = (list, now) => list.filter((p) => Date.parse(p.expiresAt) + PAIRING_RETAIN_MS > now.getTime());
|
|
5839
|
+
function stateOf(p, now) {
|
|
5840
|
+
if (p.usedAt) return "used";
|
|
5841
|
+
return now.getTime() < Date.parse(p.expiresAt) ? "live" : "expired";
|
|
5842
|
+
}
|
|
5843
|
+
function addPairing(list, secret, now, ttlMs = PAIRING_TTL_MS) {
|
|
5844
|
+
return [...retained(list, now), { secret, expiresAt: new Date(now.getTime() + ttlMs).toISOString() }];
|
|
5845
|
+
}
|
|
5846
|
+
function redeemPairing(list, secret, now, device2) {
|
|
5847
|
+
const kept = retained(list, now);
|
|
5848
|
+
const match = secret ? kept.find((p) => constantTimeEqual(p.secret, secret)) : void 0;
|
|
5849
|
+
if (!match) return { list: kept, outcome: "unknown" };
|
|
5850
|
+
if (match.usedAt && match.usedBy === device2) return { list: kept, outcome: "mine" };
|
|
5851
|
+
const state = stateOf(match, now);
|
|
5852
|
+
if (state !== "live") return { list: kept, outcome: state };
|
|
5853
|
+
const at = now.toISOString();
|
|
5854
|
+
const spent = { secret: match.secret, expiresAt: at, usedAt: at, usedBy: device2 };
|
|
5855
|
+
return { list: kept.map((p) => p === match ? spent : p), outcome: "redeemed" };
|
|
5856
|
+
}
|
|
5857
|
+
|
|
5858
|
+
// src/application/pairing-link.ts
|
|
5859
|
+
var GENERIC_MACHINE_IDS = /* @__PURE__ */ new Set(["", "local", "localhost"]);
|
|
5860
|
+
function pairingName(machineId, hostname) {
|
|
5861
|
+
return GENERIC_MACHINE_IDS.has(machineId) ? hostname.split(".")[0] : machineId;
|
|
5862
|
+
}
|
|
5863
|
+
function announcedAddresses(phoneAddresses2) {
|
|
5864
|
+
const valid2 = phoneAddresses2.length > 0 && phoneAddresses2.length <= MAX_ADDRESSES && phoneAddresses2.every(isMachineUrl);
|
|
5865
|
+
return valid2 ? phoneAddresses2 : void 0;
|
|
5866
|
+
}
|
|
5867
|
+
async function mintPairing(deps, input) {
|
|
5868
|
+
if (deps.defaultPath !== void 0 && !isPairingPath(deps.defaultPath)) {
|
|
5869
|
+
throw new Error(`TARFA_DEFAULT_PATH must be an absolute path of at most 128 characters (got ${deps.defaultPath})`);
|
|
5870
|
+
}
|
|
5871
|
+
const secret = deps.newSecret();
|
|
5872
|
+
await deps.pairings.update((list) => addPairing(list, secret, deps.now(), deps.ttlMs));
|
|
5873
|
+
return {
|
|
5874
|
+
urls: input.phoneUrls,
|
|
5875
|
+
agentKey: deps.keys.x25519.publicKey,
|
|
5876
|
+
secret,
|
|
5877
|
+
name: deps.machineId,
|
|
5878
|
+
...deps.defaultPath !== void 0 ? { defaultPath: deps.defaultPath } : {}
|
|
5879
|
+
};
|
|
5880
|
+
}
|
|
5881
|
+
|
|
5882
|
+
// src/infrastructure/file-pairing-store.ts
|
|
5883
|
+
import { readFile as readFile7 } from "node:fs/promises";
|
|
5884
|
+
import { z as z7 } from "zod";
|
|
5885
|
+
var schema = z7.array(z7.object({ secret: z7.string().regex(/^[A-Za-z0-9_-]{43}$/), expiresAt: z7.string(), usedAt: z7.string().optional(), usedBy: z7.string().optional() }));
|
|
5886
|
+
var FilePairingStore = class {
|
|
5887
|
+
constructor(filePath, staleLockMs = 1e4) {
|
|
5888
|
+
this.filePath = filePath;
|
|
5889
|
+
this.staleLockMs = staleLockMs;
|
|
5890
|
+
}
|
|
5891
|
+
filePath;
|
|
5892
|
+
staleLockMs;
|
|
5893
|
+
async read() {
|
|
5894
|
+
try {
|
|
5895
|
+
const parsed = schema.safeParse(JSON.parse(await readFile7(this.filePath, "utf8")));
|
|
5896
|
+
return parsed.success ? parsed.data : [];
|
|
5897
|
+
} catch {
|
|
5898
|
+
return [];
|
|
5899
|
+
}
|
|
5900
|
+
}
|
|
5901
|
+
async update(change) {
|
|
5902
|
+
return withFileLock(this.filePath, this.staleLockMs, async () => {
|
|
5903
|
+
const current = await this.read();
|
|
5904
|
+
const next = change(current);
|
|
5905
|
+
if (JSON.stringify(next) !== JSON.stringify(current)) await writeFileAtomic(this.filePath, `${JSON.stringify(next, null, 2)}
|
|
5906
|
+
`);
|
|
5907
|
+
return next;
|
|
5908
|
+
});
|
|
5909
|
+
}
|
|
5910
|
+
};
|
|
5911
|
+
|
|
5912
|
+
// src/infrastructure/agent-env-file.ts
|
|
5913
|
+
import { execFile as execFile3 } from "node:child_process";
|
|
5914
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
5915
|
+
import { chmod as chmod2, copyFile, mkdir as mkdir7, readFile as readFile8, writeFile as writeFile5 } from "node:fs/promises";
|
|
5916
|
+
import { dirname as dirname8, resolve as resolve5 } from "node:path";
|
|
5917
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
5918
|
+
import { promisify as promisify3 } from "node:util";
|
|
5919
|
+
|
|
5920
|
+
// src/application/agent-env-location.ts
|
|
5921
|
+
import { join as join6 } from "node:path";
|
|
5922
|
+
function resolveAgentEnvPath(input) {
|
|
5923
|
+
if (input.env.TARFA_AGENT_ENV) return input.env.TARFA_AGENT_ENV;
|
|
5924
|
+
const stateFile = join6(input.home, ".tarfa", "agent.env");
|
|
5925
|
+
if (input.exists(stateFile) || input.checkout === null) return stateFile;
|
|
5926
|
+
return join6(input.checkout, "deploy", "agent.env");
|
|
5927
|
+
}
|
|
5928
|
+
|
|
5929
|
+
// src/infrastructure/agent-env-file.ts
|
|
5930
|
+
var exec3 = promisify3(execFile3);
|
|
5931
|
+
function withoutGitVars(env) {
|
|
5932
|
+
return Object.fromEntries(Object.entries(env).filter(([name]) => !name.startsWith("GIT_")));
|
|
5933
|
+
}
|
|
5934
|
+
async function mainCheckout(kind2 = installKind()) {
|
|
5935
|
+
if (kind2 === "package") return null;
|
|
5936
|
+
const here = dirname8(fileURLToPath2(import.meta.url));
|
|
5937
|
+
try {
|
|
5938
|
+
const { stdout } = await exec3("git", ["-C", here, "rev-parse", "--path-format=absolute", "--git-common-dir"], { env: withoutGitVars(process.env) });
|
|
5939
|
+
return dirname8(stdout.trim());
|
|
5940
|
+
} catch {
|
|
5941
|
+
return resolve5(here, "..", "..", "..", "..");
|
|
5942
|
+
}
|
|
5943
|
+
}
|
|
5944
|
+
async function locateAgentEnv(env, home, kind2 = installKind()) {
|
|
5945
|
+
return resolveAgentEnvPath({ env, home, checkout: await mainCheckout(kind2), exists: existsSync3 });
|
|
5946
|
+
}
|
|
5947
|
+
function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
|
|
5948
|
+
return {
|
|
5949
|
+
path,
|
|
5950
|
+
read: async () => {
|
|
5951
|
+
try {
|
|
5952
|
+
return await readFile8(path, "utf8");
|
|
5953
|
+
} catch (err) {
|
|
5954
|
+
if (err.code === "ENOENT") {
|
|
5955
|
+
throw new Error(`no agent config at ${path}: set the agent up first (docs/DEPLOY.md), or set TARFA_AGENT_ENV`);
|
|
5956
|
+
}
|
|
5957
|
+
throw err;
|
|
5958
|
+
}
|
|
5959
|
+
},
|
|
5960
|
+
write: async (text) => {
|
|
5961
|
+
let backup = `${path}.bak-${now().toISOString().replace(/[:.]/g, "-")}`;
|
|
5962
|
+
try {
|
|
5963
|
+
await copyFile(path, backup);
|
|
5964
|
+
await chmod2(backup, 384);
|
|
5965
|
+
} catch (err) {
|
|
5966
|
+
if (err.code !== "ENOENT") throw err;
|
|
5967
|
+
backup = null;
|
|
5968
|
+
}
|
|
5969
|
+
await mkdir7(dirname8(path), { recursive: true, mode: 448 });
|
|
5970
|
+
await writeFile5(path, text, { mode: 384 });
|
|
5971
|
+
await chmod2(path, 384);
|
|
5972
|
+
return backup;
|
|
5973
|
+
}
|
|
5974
|
+
};
|
|
5975
|
+
}
|
|
5976
|
+
|
|
5587
5977
|
// src/infrastructure/file-status-store.ts
|
|
5978
|
+
import { readFileSync as readFileSync2, writeFileSync, mkdirSync } from "node:fs";
|
|
5979
|
+
import { dirname as dirname9 } from "node:path";
|
|
5588
5980
|
var FileStatusStore = class {
|
|
5589
5981
|
constructor(filePath, now = Date.now) {
|
|
5590
5982
|
this.filePath = filePath;
|
|
@@ -5697,7 +6089,7 @@ var FileStatusStore = class {
|
|
|
5697
6089
|
}
|
|
5698
6090
|
}
|
|
5699
6091
|
persist() {
|
|
5700
|
-
mkdirSync(
|
|
6092
|
+
mkdirSync(dirname9(this.filePath), { recursive: true });
|
|
5701
6093
|
writeFileSync(this.filePath, JSON.stringify(Object.fromEntries(this.statuses), null, 2), "utf8");
|
|
5702
6094
|
}
|
|
5703
6095
|
};
|
|
@@ -5726,8 +6118,8 @@ function parseEntry(value) {
|
|
|
5726
6118
|
}
|
|
5727
6119
|
|
|
5728
6120
|
// src/infrastructure/file-parked-store.ts
|
|
5729
|
-
import { readFile as
|
|
5730
|
-
import { dirname as
|
|
6121
|
+
import { readFile as readFile9, writeFile as writeFile6, mkdir as mkdir8 } from "node:fs/promises";
|
|
6122
|
+
import { dirname as dirname10 } from "node:path";
|
|
5731
6123
|
var FileParkedStore = class {
|
|
5732
6124
|
constructor(filePath) {
|
|
5733
6125
|
this.filePath = filePath;
|
|
@@ -5736,7 +6128,7 @@ var FileParkedStore = class {
|
|
|
5736
6128
|
async list() {
|
|
5737
6129
|
let raw;
|
|
5738
6130
|
try {
|
|
5739
|
-
raw = await
|
|
6131
|
+
raw = await readFile9(this.filePath, "utf8");
|
|
5740
6132
|
} catch {
|
|
5741
6133
|
return [];
|
|
5742
6134
|
}
|
|
@@ -5761,8 +6153,8 @@ var FileParkedStore = class {
|
|
|
5761
6153
|
await this.persist(rest);
|
|
5762
6154
|
}
|
|
5763
6155
|
async persist(entries) {
|
|
5764
|
-
await
|
|
5765
|
-
await
|
|
6156
|
+
await mkdir8(dirname10(this.filePath), { recursive: true });
|
|
6157
|
+
await writeFile6(this.filePath, JSON.stringify(entries, null, 2), "utf8");
|
|
5766
6158
|
}
|
|
5767
6159
|
};
|
|
5768
6160
|
function parseEntry2(value) {
|
|
@@ -5796,10 +6188,10 @@ function parseEntry2(value) {
|
|
|
5796
6188
|
}
|
|
5797
6189
|
|
|
5798
6190
|
// src/infrastructure/fs-file-writer.ts
|
|
5799
|
-
import { mkdir as
|
|
6191
|
+
import { mkdir as mkdir9, writeFile as writeFile7, access } from "node:fs/promises";
|
|
5800
6192
|
var FsFileWriter = class {
|
|
5801
6193
|
async ensureDir(dir) {
|
|
5802
|
-
await
|
|
6194
|
+
await mkdir9(dir, { recursive: true });
|
|
5803
6195
|
}
|
|
5804
6196
|
async exists(path) {
|
|
5805
6197
|
try {
|
|
@@ -5810,7 +6202,7 @@ var FsFileWriter = class {
|
|
|
5810
6202
|
}
|
|
5811
6203
|
}
|
|
5812
6204
|
async writeFile(path, bytes) {
|
|
5813
|
-
await
|
|
6205
|
+
await writeFile7(path, bytes);
|
|
5814
6206
|
}
|
|
5815
6207
|
};
|
|
5816
6208
|
|
|
@@ -6111,11 +6503,10 @@ export {
|
|
|
6111
6503
|
parseDeviceHello,
|
|
6112
6504
|
deviceFingerprint,
|
|
6113
6505
|
MAX_ADDRESSES,
|
|
6114
|
-
isPairingPath,
|
|
6115
6506
|
generatePairingSecret,
|
|
6116
6507
|
pairingLink,
|
|
6117
|
-
parsePairingLink,
|
|
6118
6508
|
isMachineUrl,
|
|
6509
|
+
pairingCode,
|
|
6119
6510
|
REFUSAL,
|
|
6120
6511
|
IN_BAND_REFUSAL,
|
|
6121
6512
|
DEVICE_REFUSED,
|
|
@@ -6158,7 +6549,9 @@ export {
|
|
|
6158
6549
|
getRecentPaths,
|
|
6159
6550
|
pathWithinRoots,
|
|
6160
6551
|
browseDir,
|
|
6552
|
+
atomicWrite,
|
|
6161
6553
|
loadOrCreateAgentKeys,
|
|
6554
|
+
readAgentPublicKey,
|
|
6162
6555
|
enrolmentMode,
|
|
6163
6556
|
withEnrolmentMode,
|
|
6164
6557
|
standingOf,
|
|
@@ -6166,13 +6559,6 @@ export {
|
|
|
6166
6559
|
revokeDevice,
|
|
6167
6560
|
forgetDevice,
|
|
6168
6561
|
FileDeviceStore,
|
|
6169
|
-
PAIRING_TTL_MS,
|
|
6170
|
-
addPairing,
|
|
6171
|
-
redeemPairing,
|
|
6172
|
-
FilePairingStore,
|
|
6173
|
-
mainCheckout,
|
|
6174
|
-
locateAgentEnv,
|
|
6175
|
-
agentEnvFile,
|
|
6176
6562
|
isWorkspaceStatus,
|
|
6177
6563
|
runsClaude,
|
|
6178
6564
|
imageMediaType,
|
|
@@ -6183,6 +6569,21 @@ export {
|
|
|
6183
6569
|
parseClientMessage,
|
|
6184
6570
|
parseBrowserClientMessage,
|
|
6185
6571
|
parseBrowserAgentMessage,
|
|
6572
|
+
workspaceLink,
|
|
6573
|
+
DICTATION_PROVIDERS,
|
|
6574
|
+
providerOf,
|
|
6575
|
+
changeDictationKey,
|
|
6576
|
+
exposedFiles,
|
|
6577
|
+
FileDictationKeysStore,
|
|
6578
|
+
PAIRING_TTL_MS,
|
|
6579
|
+
redeemPairing,
|
|
6580
|
+
pairingName,
|
|
6581
|
+
announcedAddresses,
|
|
6582
|
+
mintPairing,
|
|
6583
|
+
FilePairingStore,
|
|
6584
|
+
mainCheckout,
|
|
6585
|
+
locateAgentEnv,
|
|
6586
|
+
agentEnvFile,
|
|
6186
6587
|
FileStatusStore,
|
|
6187
6588
|
FileParkedStore,
|
|
6188
6589
|
FsFileWriter,
|