@tarfa/cli 0.1.5 → 0.1.6
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-UPCCR4ZA.js} +415 -57
- package/dist/cli.js +124 -67
- package/dist/serve.js +38 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,13 +17,14 @@ tarfa setup --machine-id <name> --project-roots ~/workspace --tailscale serve --
|
|
|
17
17
|
|
|
18
18
|
`tarfa setup` writes `~/.tarfa/agent.env`, the agent key, the service (a systemd user unit or a
|
|
19
19
|
launchd agent) and the Claude Code hooks, publishes the phone port with Tailscale, and prints a
|
|
20
|
-
pairing
|
|
20
|
+
pairing code and its QR code. In the installed app on the phone, Settings → Add machine: scan it,
|
|
21
|
+
or paste the code. `tarfa setup --help`
|
|
21
22
|
lists every flag.
|
|
22
23
|
|
|
23
24
|
## Day to day
|
|
24
25
|
|
|
25
26
|
```sh
|
|
26
|
-
tarfa pair # a one-time pairing
|
|
27
|
+
tarfa pair # a one-time pairing code for another phone (--link: a link, for an older app)
|
|
27
28
|
tarfa devices # paired phones; revoke or forget one
|
|
28
29
|
tarfa update # install the newest release and restart the agent; workspaces survive
|
|
29
30
|
tarfa ls # this machine's workspaces
|
|
@@ -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) {
|
|
@@ -5021,8 +5214,8 @@ function forgetDevice(roster, id2) {
|
|
|
5021
5214
|
}
|
|
5022
5215
|
|
|
5023
5216
|
// src/infrastructure/file-device-store.ts
|
|
5024
|
-
import { mkdir as
|
|
5025
|
-
import { dirname as
|
|
5217
|
+
import { mkdir as mkdir6, readFile as readFile5 } from "node:fs/promises";
|
|
5218
|
+
import { dirname as dirname7 } from "node:path";
|
|
5026
5219
|
import { z as z2 } from "zod";
|
|
5027
5220
|
var key = z2.string().regex(/^[A-Za-z0-9_-]{43}$/);
|
|
5028
5221
|
var device = z2.object({ id: z2.string(), x25519: key, ed25519: key, name: z2.string(), enrolledAt: z2.string() });
|
|
@@ -5069,7 +5262,7 @@ var FileDeviceStore = class {
|
|
|
5069
5262
|
}
|
|
5070
5263
|
if (parsed?.tokenEnrolment === false) return false;
|
|
5071
5264
|
}
|
|
5072
|
-
await
|
|
5265
|
+
await mkdir6(dirname7(this.filePath), { recursive: true, mode: 448 });
|
|
5073
5266
|
await this.update((roster) => roster);
|
|
5074
5267
|
return true;
|
|
5075
5268
|
}
|
|
@@ -5083,8 +5276,112 @@ var FileDeviceStore = class {
|
|
|
5083
5276
|
}
|
|
5084
5277
|
};
|
|
5085
5278
|
|
|
5279
|
+
// src/application/dictation-keys.ts
|
|
5280
|
+
var DICTATION_PROVIDERS = ["elevenlabs", "openrouter"];
|
|
5281
|
+
var MAX_KEY_LENGTH = 512;
|
|
5282
|
+
function providerOf(name) {
|
|
5283
|
+
return DICTATION_PROVIDERS.includes(name) ? name : null;
|
|
5284
|
+
}
|
|
5285
|
+
function checkKey(raw) {
|
|
5286
|
+
const key2 = raw.trim();
|
|
5287
|
+
if (key2 === "") return { ok: false, error: "no key given" };
|
|
5288
|
+
if (/\s/.test(key2)) return { ok: false, error: "a key holds no whitespace" };
|
|
5289
|
+
if (key2.length > MAX_KEY_LENGTH) return { ok: false, error: `a key is at most ${MAX_KEY_LENGTH} characters` };
|
|
5290
|
+
return { ok: true, key: key2 };
|
|
5291
|
+
}
|
|
5292
|
+
function parseDictationKeys(raw) {
|
|
5293
|
+
let value;
|
|
5294
|
+
try {
|
|
5295
|
+
value = JSON.parse(raw);
|
|
5296
|
+
} catch {
|
|
5297
|
+
return "malformed";
|
|
5298
|
+
}
|
|
5299
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return "malformed";
|
|
5300
|
+
const keys = {};
|
|
5301
|
+
for (const provider of DICTATION_PROVIDERS) {
|
|
5302
|
+
const candidate = value[provider];
|
|
5303
|
+
if (typeof candidate !== "string") continue;
|
|
5304
|
+
const checked = checkKey(candidate);
|
|
5305
|
+
if (checked.ok) keys[provider] = checked.key;
|
|
5306
|
+
}
|
|
5307
|
+
return keys;
|
|
5308
|
+
}
|
|
5309
|
+
function withKey(keys, provider, key2) {
|
|
5310
|
+
return { ...keys, [provider]: key2 };
|
|
5311
|
+
}
|
|
5312
|
+
function withoutKey(keys, provider) {
|
|
5313
|
+
const { [provider]: _removed, ...rest } = keys;
|
|
5314
|
+
return rest;
|
|
5315
|
+
}
|
|
5316
|
+
async function changeDictationKey(store, provider, raw) {
|
|
5317
|
+
let key2 = null;
|
|
5318
|
+
if (raw !== null) {
|
|
5319
|
+
const checked = checkKey(raw);
|
|
5320
|
+
if (!checked.ok) return checked;
|
|
5321
|
+
key2 = checked.key;
|
|
5322
|
+
}
|
|
5323
|
+
const result = await store.update((keys) => key2 === null ? withoutKey(keys, provider) : withKey(keys, provider, key2));
|
|
5324
|
+
return "problem" in result ? { ok: false, error: result.problem } : { ok: true };
|
|
5325
|
+
}
|
|
5326
|
+
|
|
5327
|
+
// src/infrastructure/file-dictation-keys-store.ts
|
|
5328
|
+
import { readFile as readFile6, stat as stat3 } from "node:fs/promises";
|
|
5329
|
+
|
|
5330
|
+
// src/application/private-files.ts
|
|
5331
|
+
function exposedFiles(files) {
|
|
5332
|
+
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}\``);
|
|
5333
|
+
}
|
|
5334
|
+
|
|
5335
|
+
// src/infrastructure/file-dictation-keys-store.ts
|
|
5336
|
+
var STALE_LOCK_MS = 1e4;
|
|
5337
|
+
var FileDictationKeysStore = class {
|
|
5338
|
+
constructor(path, report = () => {
|
|
5339
|
+
}) {
|
|
5340
|
+
this.path = path;
|
|
5341
|
+
this.report = report;
|
|
5342
|
+
}
|
|
5343
|
+
path;
|
|
5344
|
+
report;
|
|
5345
|
+
async load() {
|
|
5346
|
+
let mode;
|
|
5347
|
+
try {
|
|
5348
|
+
mode = (await stat3(this.path)).mode;
|
|
5349
|
+
} catch (err) {
|
|
5350
|
+
if (err.code === "ENOENT") return { keys: {} };
|
|
5351
|
+
return { problem: `${this.path} cannot be read (${err.code ?? "error"})` };
|
|
5352
|
+
}
|
|
5353
|
+
const exposed = exposedFiles([{ path: this.path, mode }]);
|
|
5354
|
+
if (exposed.length > 0) return { problem: exposed[0] };
|
|
5355
|
+
let raw;
|
|
5356
|
+
try {
|
|
5357
|
+
raw = await readFile6(this.path, "utf8");
|
|
5358
|
+
} catch (err) {
|
|
5359
|
+
return { problem: `${this.path} cannot be read (${err.code ?? "error"})` };
|
|
5360
|
+
}
|
|
5361
|
+
const keys = parseDictationKeys(raw);
|
|
5362
|
+
return keys === "malformed" ? { problem: `${this.path} is malformed` } : { keys };
|
|
5363
|
+
}
|
|
5364
|
+
async read() {
|
|
5365
|
+
const loaded = await this.load();
|
|
5366
|
+
if ("problem" in loaded) {
|
|
5367
|
+
this.report(loaded.problem);
|
|
5368
|
+
return {};
|
|
5369
|
+
}
|
|
5370
|
+
return loaded.keys;
|
|
5371
|
+
}
|
|
5372
|
+
async update(change) {
|
|
5373
|
+
return withFileLock(this.path, STALE_LOCK_MS, async () => {
|
|
5374
|
+
const loaded = await this.load();
|
|
5375
|
+
if ("problem" in loaded) return { problem: loaded.problem };
|
|
5376
|
+
await atomicWrite(this.path, `${JSON.stringify(change(loaded.keys), null, 2)}
|
|
5377
|
+
`, 384);
|
|
5378
|
+
return { ok: true };
|
|
5379
|
+
});
|
|
5380
|
+
}
|
|
5381
|
+
};
|
|
5382
|
+
|
|
5086
5383
|
// src/application/pairing.ts
|
|
5087
|
-
var PAIRING_TTL_MS =
|
|
5384
|
+
var PAIRING_TTL_MS = 20 * 6e4;
|
|
5088
5385
|
var PAIRING_RETAIN_MS = 24 * 60 * 6e4;
|
|
5089
5386
|
var retained = (list, now) => list.filter((p) => Date.parse(p.expiresAt) + PAIRING_RETAIN_MS > now.getTime());
|
|
5090
5387
|
function stateOf(p, now) {
|
|
@@ -5106,8 +5403,32 @@ function redeemPairing(list, secret, now, device2) {
|
|
|
5106
5403
|
return { list: kept.map((p) => p === match ? spent : p), outcome: "redeemed" };
|
|
5107
5404
|
}
|
|
5108
5405
|
|
|
5406
|
+
// src/application/pairing-link.ts
|
|
5407
|
+
var GENERIC_MACHINE_IDS = /* @__PURE__ */ new Set(["", "local", "localhost"]);
|
|
5408
|
+
function pairingName(machineId, hostname) {
|
|
5409
|
+
return GENERIC_MACHINE_IDS.has(machineId) ? hostname.split(".")[0] : machineId;
|
|
5410
|
+
}
|
|
5411
|
+
function announcedAddresses(phoneAddresses2) {
|
|
5412
|
+
const valid = phoneAddresses2.length > 0 && phoneAddresses2.length <= MAX_ADDRESSES && phoneAddresses2.every(isMachineUrl);
|
|
5413
|
+
return valid ? phoneAddresses2 : void 0;
|
|
5414
|
+
}
|
|
5415
|
+
async function mintPairing(deps, input) {
|
|
5416
|
+
if (deps.defaultPath !== void 0 && !isPairingPath(deps.defaultPath)) {
|
|
5417
|
+
throw new Error(`TARFA_DEFAULT_PATH must be an absolute path of at most 128 characters (got ${deps.defaultPath})`);
|
|
5418
|
+
}
|
|
5419
|
+
const secret = deps.newSecret();
|
|
5420
|
+
await deps.pairings.update((list) => addPairing(list, secret, deps.now(), deps.ttlMs));
|
|
5421
|
+
return {
|
|
5422
|
+
urls: input.phoneUrls,
|
|
5423
|
+
agentKey: deps.keys.x25519.publicKey,
|
|
5424
|
+
secret,
|
|
5425
|
+
name: deps.machineId,
|
|
5426
|
+
...deps.defaultPath !== void 0 ? { defaultPath: deps.defaultPath } : {}
|
|
5427
|
+
};
|
|
5428
|
+
}
|
|
5429
|
+
|
|
5109
5430
|
// src/infrastructure/file-pairing-store.ts
|
|
5110
|
-
import { readFile as
|
|
5431
|
+
import { readFile as readFile7 } from "node:fs/promises";
|
|
5111
5432
|
import { z as z3 } from "zod";
|
|
5112
5433
|
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
5434
|
var FilePairingStore = class {
|
|
@@ -5119,7 +5440,7 @@ var FilePairingStore = class {
|
|
|
5119
5440
|
staleLockMs;
|
|
5120
5441
|
async read() {
|
|
5121
5442
|
try {
|
|
5122
|
-
const parsed = schema.safeParse(JSON.parse(await
|
|
5443
|
+
const parsed = schema.safeParse(JSON.parse(await readFile7(this.filePath, "utf8")));
|
|
5123
5444
|
return parsed.success ? parsed.data : [];
|
|
5124
5445
|
} catch {
|
|
5125
5446
|
return [];
|
|
@@ -5139,8 +5460,8 @@ var FilePairingStore = class {
|
|
|
5139
5460
|
// src/infrastructure/agent-env-file.ts
|
|
5140
5461
|
import { execFile as execFile3 } from "node:child_process";
|
|
5141
5462
|
import { existsSync as existsSync3 } from "node:fs";
|
|
5142
|
-
import { chmod, copyFile, mkdir as
|
|
5143
|
-
import { dirname as
|
|
5463
|
+
import { chmod as chmod2, copyFile, mkdir as mkdir7, readFile as readFile8, writeFile as writeFile5 } from "node:fs/promises";
|
|
5464
|
+
import { dirname as dirname8, resolve as resolve5 } from "node:path";
|
|
5144
5465
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
5145
5466
|
import { promisify as promisify3 } from "node:util";
|
|
5146
5467
|
|
|
@@ -5160,10 +5481,10 @@ function withoutGitVars(env) {
|
|
|
5160
5481
|
}
|
|
5161
5482
|
async function mainCheckout(kind2 = installKind()) {
|
|
5162
5483
|
if (kind2 === "package") return null;
|
|
5163
|
-
const here =
|
|
5484
|
+
const here = dirname8(fileURLToPath2(import.meta.url));
|
|
5164
5485
|
try {
|
|
5165
5486
|
const { stdout } = await exec3("git", ["-C", here, "rev-parse", "--path-format=absolute", "--git-common-dir"], { env: withoutGitVars(process.env) });
|
|
5166
|
-
return
|
|
5487
|
+
return dirname8(stdout.trim());
|
|
5167
5488
|
} catch {
|
|
5168
5489
|
return resolve5(here, "..", "..", "..", "..");
|
|
5169
5490
|
}
|
|
@@ -5176,7 +5497,7 @@ function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
|
|
|
5176
5497
|
path,
|
|
5177
5498
|
read: async () => {
|
|
5178
5499
|
try {
|
|
5179
|
-
return await
|
|
5500
|
+
return await readFile8(path, "utf8");
|
|
5180
5501
|
} catch (err) {
|
|
5181
5502
|
if (err.code === "ENOENT") {
|
|
5182
5503
|
throw new Error(`no agent config at ${path}: set the agent up first (docs/DEPLOY.md), or set TARFA_AGENT_ENV`);
|
|
@@ -5188,14 +5509,14 @@ function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
|
|
|
5188
5509
|
let backup = `${path}.bak-${now().toISOString().replace(/[:.]/g, "-")}`;
|
|
5189
5510
|
try {
|
|
5190
5511
|
await copyFile(path, backup);
|
|
5191
|
-
await
|
|
5512
|
+
await chmod2(backup, 384);
|
|
5192
5513
|
} catch (err) {
|
|
5193
5514
|
if (err.code !== "ENOENT") throw err;
|
|
5194
5515
|
backup = null;
|
|
5195
5516
|
}
|
|
5196
|
-
await
|
|
5197
|
-
await
|
|
5198
|
-
await
|
|
5517
|
+
await mkdir7(dirname8(path), { recursive: true, mode: 448 });
|
|
5518
|
+
await writeFile5(path, text, { mode: 384 });
|
|
5519
|
+
await chmod2(path, 384);
|
|
5199
5520
|
return backup;
|
|
5200
5521
|
}
|
|
5201
5522
|
};
|
|
@@ -5203,7 +5524,7 @@ function agentEnvFile(path, now = () => /* @__PURE__ */ new Date()) {
|
|
|
5203
5524
|
|
|
5204
5525
|
// src/infrastructure/file-status-store.ts
|
|
5205
5526
|
import { readFileSync as readFileSync2, writeFileSync, mkdirSync } from "node:fs";
|
|
5206
|
-
import { dirname as
|
|
5527
|
+
import { dirname as dirname9 } from "node:path";
|
|
5207
5528
|
|
|
5208
5529
|
// ../contracts/src/domain/workspace-status.ts
|
|
5209
5530
|
var WORKSPACE_STATUSES = [
|
|
@@ -5388,6 +5709,7 @@ var machineStatsSchema = z6.object({
|
|
|
5388
5709
|
oomKills: z6.number().optional()
|
|
5389
5710
|
}).optional()
|
|
5390
5711
|
});
|
|
5712
|
+
var dictationProviderSchema = z6.enum(["elevenlabs", "openrouter"]);
|
|
5391
5713
|
var clientMessageSchema = z6.discriminatedUnion("type", [
|
|
5392
5714
|
// `token` carries a `tarfa pair` code for a new phone, or nothing (an enrolled key needs none).
|
|
5393
5715
|
z6.object({ type: z6.literal("auth"), token: z6.string() }),
|
|
@@ -5404,6 +5726,13 @@ var clientMessageSchema = z6.discriminatedUnion("type", [
|
|
|
5404
5726
|
z6.object({ type: z6.literal("listLaunchers") }),
|
|
5405
5727
|
z6.object({ type: z6.literal("forgetLauncher"), command: z6.string() }),
|
|
5406
5728
|
z6.object({ type: z6.literal("listKeyterms") }),
|
|
5729
|
+
// Asked only of an agent whose authResult lists AGENT_FEATURES.dictationKeys.
|
|
5730
|
+
z6.object({ type: z6.literal("getDictationKeys") }),
|
|
5731
|
+
// Store (`key`) or remove (`null`) one of the machine's dictation keys, as `tarfa dictation-key`
|
|
5732
|
+
// does. Sent only to an agent whose authResult lists AGENT_FEATURES.setDictationKey; the key is
|
|
5733
|
+
// checked by the agent, which answers `dictationKeySet` with the same `id`, so a late answer to
|
|
5734
|
+
// an earlier save is never taken for this one's.
|
|
5735
|
+
z6.object({ type: z6.literal("setDictationKey"), id: z6.string(), provider: dictationProviderSchema, key: z6.string().nullable() }),
|
|
5407
5736
|
z6.object({ type: z6.literal("readFile"), path: z6.string() }),
|
|
5408
5737
|
z6.object({ type: z6.literal("attach"), workspaceId: z6.string(), cols: dim, rows: dim }),
|
|
5409
5738
|
z6.object({ type: z6.literal("detach"), workspaceId: z6.string() }),
|
|
@@ -5440,7 +5769,12 @@ var clientMessageSchema = z6.discriminatedUnion("type", [
|
|
|
5440
5769
|
]);
|
|
5441
5770
|
var detachReasonSchema = z6.enum(["opened-elsewhere", "closed", "error"]);
|
|
5442
5771
|
var PROTOCOL_VERSION = 1;
|
|
5443
|
-
var AGENT_FEATURES = {
|
|
5772
|
+
var AGENT_FEATURES = {
|
|
5773
|
+
lastReply: "lastReply",
|
|
5774
|
+
dictationKeys: "dictationKeys",
|
|
5775
|
+
setDictationKey: "setDictationKey",
|
|
5776
|
+
addresses: "addresses"
|
|
5777
|
+
};
|
|
5444
5778
|
var agentMessageSchema = z6.discriminatedUnion("type", [
|
|
5445
5779
|
// `credential` is what authenticated the session (`token` only from an agent from before token
|
|
5446
5780
|
// enrolment was removed); `enrolled` says the device key is enrolled (written), so the phone
|
|
@@ -5455,7 +5789,14 @@ var agentMessageSchema = z6.discriminatedUnion("type", [
|
|
|
5455
5789
|
pushed: z6.literal(true).optional().catch(void 0),
|
|
5456
5790
|
// `features` names what this agent answers beyond the protocol, so the PWA offers a control
|
|
5457
5791
|
// only to an agent that supports it (a worktree agent shares a release's version number).
|
|
5458
|
-
|
|
5792
|
+
// `addresses`: where phones reach this agent now (its TARFA_PHONE_ADDRESSES), which a phone
|
|
5793
|
+
// adopts only once it has connected through each one.
|
|
5794
|
+
agent: z6.object({
|
|
5795
|
+
protocol: z6.number().int(),
|
|
5796
|
+
version: z6.string().optional(),
|
|
5797
|
+
features: z6.array(z6.string()).optional().catch(void 0),
|
|
5798
|
+
addresses: z6.array(z6.string()).optional().catch(void 0)
|
|
5799
|
+
}).optional().catch(void 0)
|
|
5459
5800
|
}),
|
|
5460
5801
|
// `claudeVoice`: whether this machine can play a Claude voice dictation (see voiceStart).
|
|
5461
5802
|
// An older agent omits it, and the PWA then treats Claude voice as unavailable there.
|
|
@@ -5474,6 +5815,16 @@ var agentMessageSchema = z6.discriminatedUnion("type", [
|
|
|
5474
5815
|
// keyterms so jargon transcribes correctly. Additive like `roots`: older agents omit it,
|
|
5475
5816
|
// so it defaults to [] instead of letting the strict client parser drop the whole message.
|
|
5476
5817
|
z6.object({ type: z6.literal("keyterms"), keyterms: z6.array(z6.string()).default([]) }),
|
|
5818
|
+
// The dictation provider keys the agent's operator stored (~/.tarfa/dictation-keys.json), sent
|
|
5819
|
+
// only in reply to getDictationKeys. A key the agent does not hold is absent.
|
|
5820
|
+
z6.object({
|
|
5821
|
+
type: z6.literal("dictationKeys"),
|
|
5822
|
+
elevenlabs: z6.string().optional().catch(void 0),
|
|
5823
|
+
openrouter: z6.string().optional().catch(void 0)
|
|
5824
|
+
}),
|
|
5825
|
+
// The answer to setDictationKey. `error` is the agent's fixed wording of a refusal and never
|
|
5826
|
+
// holds the key.
|
|
5827
|
+
z6.object({ type: z6.literal("dictationKeySet"), id: z6.string(), provider: dictationProviderSchema, ok: z6.boolean(), error: z6.string().optional() }),
|
|
5477
5828
|
// `files` is additive: older agents that predate it omit the field, so it defaults to []
|
|
5478
5829
|
// (a strictly-required field would make the strict client parser silently drop their
|
|
5479
5830
|
// dirEntries during a version-skewed rollout, hanging the browser on "Loading…").
|
|
@@ -5697,7 +6048,7 @@ var FileStatusStore = class {
|
|
|
5697
6048
|
}
|
|
5698
6049
|
}
|
|
5699
6050
|
persist() {
|
|
5700
|
-
mkdirSync(
|
|
6051
|
+
mkdirSync(dirname9(this.filePath), { recursive: true });
|
|
5701
6052
|
writeFileSync(this.filePath, JSON.stringify(Object.fromEntries(this.statuses), null, 2), "utf8");
|
|
5702
6053
|
}
|
|
5703
6054
|
};
|
|
@@ -5726,8 +6077,8 @@ function parseEntry(value) {
|
|
|
5726
6077
|
}
|
|
5727
6078
|
|
|
5728
6079
|
// src/infrastructure/file-parked-store.ts
|
|
5729
|
-
import { readFile as
|
|
5730
|
-
import { dirname as
|
|
6080
|
+
import { readFile as readFile9, writeFile as writeFile6, mkdir as mkdir8 } from "node:fs/promises";
|
|
6081
|
+
import { dirname as dirname10 } from "node:path";
|
|
5731
6082
|
var FileParkedStore = class {
|
|
5732
6083
|
constructor(filePath) {
|
|
5733
6084
|
this.filePath = filePath;
|
|
@@ -5736,7 +6087,7 @@ var FileParkedStore = class {
|
|
|
5736
6087
|
async list() {
|
|
5737
6088
|
let raw;
|
|
5738
6089
|
try {
|
|
5739
|
-
raw = await
|
|
6090
|
+
raw = await readFile9(this.filePath, "utf8");
|
|
5740
6091
|
} catch {
|
|
5741
6092
|
return [];
|
|
5742
6093
|
}
|
|
@@ -5761,8 +6112,8 @@ var FileParkedStore = class {
|
|
|
5761
6112
|
await this.persist(rest);
|
|
5762
6113
|
}
|
|
5763
6114
|
async persist(entries) {
|
|
5764
|
-
await
|
|
5765
|
-
await
|
|
6115
|
+
await mkdir8(dirname10(this.filePath), { recursive: true });
|
|
6116
|
+
await writeFile6(this.filePath, JSON.stringify(entries, null, 2), "utf8");
|
|
5766
6117
|
}
|
|
5767
6118
|
};
|
|
5768
6119
|
function parseEntry2(value) {
|
|
@@ -5796,10 +6147,10 @@ function parseEntry2(value) {
|
|
|
5796
6147
|
}
|
|
5797
6148
|
|
|
5798
6149
|
// src/infrastructure/fs-file-writer.ts
|
|
5799
|
-
import { mkdir as
|
|
6150
|
+
import { mkdir as mkdir9, writeFile as writeFile7, access } from "node:fs/promises";
|
|
5800
6151
|
var FsFileWriter = class {
|
|
5801
6152
|
async ensureDir(dir) {
|
|
5802
|
-
await
|
|
6153
|
+
await mkdir9(dir, { recursive: true });
|
|
5803
6154
|
}
|
|
5804
6155
|
async exists(path) {
|
|
5805
6156
|
try {
|
|
@@ -5810,7 +6161,7 @@ var FsFileWriter = class {
|
|
|
5810
6161
|
}
|
|
5811
6162
|
}
|
|
5812
6163
|
async writeFile(path, bytes) {
|
|
5813
|
-
await
|
|
6164
|
+
await writeFile7(path, bytes);
|
|
5814
6165
|
}
|
|
5815
6166
|
};
|
|
5816
6167
|
|
|
@@ -6111,11 +6462,10 @@ export {
|
|
|
6111
6462
|
parseDeviceHello,
|
|
6112
6463
|
deviceFingerprint,
|
|
6113
6464
|
MAX_ADDRESSES,
|
|
6114
|
-
isPairingPath,
|
|
6115
6465
|
generatePairingSecret,
|
|
6116
6466
|
pairingLink,
|
|
6117
|
-
parsePairingLink,
|
|
6118
6467
|
isMachineUrl,
|
|
6468
|
+
pairingCode,
|
|
6119
6469
|
REFUSAL,
|
|
6120
6470
|
IN_BAND_REFUSAL,
|
|
6121
6471
|
DEVICE_REFUSED,
|
|
@@ -6158,6 +6508,7 @@ export {
|
|
|
6158
6508
|
getRecentPaths,
|
|
6159
6509
|
pathWithinRoots,
|
|
6160
6510
|
browseDir,
|
|
6511
|
+
atomicWrite,
|
|
6161
6512
|
loadOrCreateAgentKeys,
|
|
6162
6513
|
enrolmentMode,
|
|
6163
6514
|
withEnrolmentMode,
|
|
@@ -6166,9 +6517,16 @@ export {
|
|
|
6166
6517
|
revokeDevice,
|
|
6167
6518
|
forgetDevice,
|
|
6168
6519
|
FileDeviceStore,
|
|
6520
|
+
DICTATION_PROVIDERS,
|
|
6521
|
+
providerOf,
|
|
6522
|
+
changeDictationKey,
|
|
6523
|
+
exposedFiles,
|
|
6524
|
+
FileDictationKeysStore,
|
|
6169
6525
|
PAIRING_TTL_MS,
|
|
6170
|
-
addPairing,
|
|
6171
6526
|
redeemPairing,
|
|
6527
|
+
pairingName,
|
|
6528
|
+
announcedAddresses,
|
|
6529
|
+
mintPairing,
|
|
6172
6530
|
FilePairingStore,
|
|
6173
6531
|
mainCheckout,
|
|
6174
6532
|
locateAgentEnv,
|
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
CodexAppServer,
|
|
4
|
+
DICTATION_PROVIDERS,
|
|
4
5
|
FileClaudeSessionsStore,
|
|
5
6
|
FileDeviceStore,
|
|
7
|
+
FileDictationKeysStore,
|
|
6
8
|
FileHiddenFoldersStore,
|
|
7
9
|
FilePairingStore,
|
|
8
10
|
FileParkedStore,
|
|
@@ -17,9 +19,10 @@ import {
|
|
|
17
19
|
STATE_DIR,
|
|
18
20
|
ShortWorkspaceIds,
|
|
19
21
|
TmuxAdapter,
|
|
20
|
-
addPairing,
|
|
21
22
|
agentEnvFile,
|
|
23
|
+
atomicWrite,
|
|
22
24
|
browseDir,
|
|
25
|
+
changeDictationKey,
|
|
23
26
|
childEnv,
|
|
24
27
|
cloneOfUnit,
|
|
25
28
|
codexVersions,
|
|
@@ -37,7 +40,6 @@ import {
|
|
|
37
40
|
isEntrypoint,
|
|
38
41
|
isHiddenPath,
|
|
39
42
|
isMachineUrl,
|
|
40
|
-
isPairingPath,
|
|
41
43
|
listWorkspaces,
|
|
42
44
|
loadConfig,
|
|
43
45
|
loadOrCreateAgentKeys,
|
|
@@ -46,14 +48,17 @@ import {
|
|
|
46
48
|
loginShellResolver,
|
|
47
49
|
mainCheckout,
|
|
48
50
|
migrateStateDir,
|
|
51
|
+
mintPairing,
|
|
49
52
|
packageRoot,
|
|
50
53
|
packageVersion,
|
|
54
|
+
pairingCode,
|
|
51
55
|
pairingLink,
|
|
56
|
+
pairingName,
|
|
52
57
|
parseEnvFile,
|
|
53
|
-
parsePairingLink,
|
|
54
58
|
parseSetupFlags,
|
|
55
59
|
planCli,
|
|
56
60
|
planSetup,
|
|
61
|
+
providerOf,
|
|
57
62
|
renderService,
|
|
58
63
|
resolveCodexHome,
|
|
59
64
|
resolveStateDir,
|
|
@@ -66,13 +71,13 @@ import {
|
|
|
66
71
|
unitRunsLauncher,
|
|
67
72
|
withEnrolmentMode,
|
|
68
73
|
withLegacyEnv
|
|
69
|
-
} from "./chunk-
|
|
74
|
+
} from "./chunk-UPCCR4ZA.js";
|
|
70
75
|
|
|
71
76
|
// src/cli.ts
|
|
72
77
|
import { randomBytes } from "node:crypto";
|
|
73
78
|
import { homedir, hostname, userInfo } from "node:os";
|
|
74
|
-
import { readFile as readFile6, mkdir as
|
|
75
|
-
import { dirname as
|
|
79
|
+
import { readFile as readFile6, mkdir as mkdir3, realpath as realpath2 } from "node:fs/promises";
|
|
80
|
+
import { dirname as dirname3, join as joinPath, resolve as resolvePath } from "node:path";
|
|
76
81
|
|
|
77
82
|
// src/application/format-workspace-table.ts
|
|
78
83
|
var TITLE_MAX = 40;
|
|
@@ -653,29 +658,13 @@ async function firstOnPath(name, pathVar) {
|
|
|
653
658
|
return null;
|
|
654
659
|
}
|
|
655
660
|
|
|
656
|
-
// src/infrastructure/atomic-write.ts
|
|
657
|
-
import { chmod as chmod3, mkdir as mkdir3, rename as rename2, rm as rm2, writeFile as writeFile2 } from "node:fs/promises";
|
|
658
|
-
import { dirname as dirname3 } from "node:path";
|
|
659
|
-
async function atomicWrite(path, text, mode) {
|
|
660
|
-
await mkdir3(dirname3(path), { recursive: true });
|
|
661
|
-
const temp = `${path}.tmp-${process.pid}`;
|
|
662
|
-
try {
|
|
663
|
-
await writeFile2(temp, text, { mode });
|
|
664
|
-
await chmod3(temp, mode);
|
|
665
|
-
await rename2(temp, path);
|
|
666
|
-
} catch (err) {
|
|
667
|
-
await rm2(temp, { force: true });
|
|
668
|
-
throw err;
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
661
|
// src/infrastructure/agent-log.ts
|
|
673
662
|
import { open, readFile as readFile4, stat as stat3 } from "node:fs/promises";
|
|
674
663
|
import { join as join5 } from "node:path";
|
|
675
664
|
|
|
676
665
|
// src/infrastructure/service-install.ts
|
|
677
666
|
import { execFile as execFile2 } from "node:child_process";
|
|
678
|
-
import { chmod as
|
|
667
|
+
import { chmod as chmod3, cp, copyFile as copyFile3, readFile as readFile3, rm as rm2, stat as stat2 } from "node:fs/promises";
|
|
679
668
|
import { join as join4 } from "node:path";
|
|
680
669
|
import { promisify as promisify2 } from "node:util";
|
|
681
670
|
var exec2 = promisify2(execFile2);
|
|
@@ -705,7 +694,7 @@ async function backupFile(path) {
|
|
|
705
694
|
if (!await exists(path)) return null;
|
|
706
695
|
const copy = `${path}.bak-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}`;
|
|
707
696
|
await copyFile3(path, copy);
|
|
708
|
-
await
|
|
697
|
+
await chmod3(copy, (await stat2(path)).mode & 511);
|
|
709
698
|
return copy;
|
|
710
699
|
}
|
|
711
700
|
var LABEL_GONE_MS = 1e4;
|
|
@@ -759,7 +748,7 @@ function serviceInstall(options) {
|
|
|
759
748
|
return [`launchctl bootout ${domain}/${LEGACY_LABEL}`];
|
|
760
749
|
},
|
|
761
750
|
async removeLegacy() {
|
|
762
|
-
await
|
|
751
|
+
await rm2(legacyPlist, { force: true });
|
|
763
752
|
return [`removed ${legacyPlist}`];
|
|
764
753
|
}
|
|
765
754
|
};
|
|
@@ -811,9 +800,9 @@ function serviceInstall(options) {
|
|
|
811
800
|
return ran.map(([command, args]) => `${command} ${args.join(" ")}`);
|
|
812
801
|
},
|
|
813
802
|
async removeLegacy() {
|
|
814
|
-
await
|
|
803
|
+
await rm2(legacyUnit, { force: true });
|
|
815
804
|
if (await exists(legacyDropIns) && !await exists(dropIns)) await cp(legacyDropIns, dropIns, { recursive: true });
|
|
816
|
-
await
|
|
805
|
+
await rm2(legacyDropIns, { recursive: true, force: true });
|
|
817
806
|
await run4("systemctl", ["--user", "daemon-reload"]);
|
|
818
807
|
return [`removed ${legacyUnit} and its drop-ins`, "systemctl --user daemon-reload"];
|
|
819
808
|
}
|
|
@@ -980,29 +969,50 @@ function attempt(port, ms) {
|
|
|
980
969
|
});
|
|
981
970
|
}
|
|
982
971
|
|
|
983
|
-
// src/
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
972
|
+
// src/infrastructure/read-secret.ts
|
|
973
|
+
async function readSecret(prompt) {
|
|
974
|
+
const stdin = process.stdin;
|
|
975
|
+
if (!stdin.isTTY) {
|
|
976
|
+
const chunks = [];
|
|
977
|
+
for await (const chunk of stdin) chunks.push(chunk);
|
|
978
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
979
|
+
}
|
|
980
|
+
process.stderr.write(prompt);
|
|
981
|
+
stdin.setRawMode(true);
|
|
982
|
+
stdin.resume();
|
|
983
|
+
stdin.setEncoding("utf8");
|
|
984
|
+
try {
|
|
985
|
+
return await new Promise((resolve2, reject) => {
|
|
986
|
+
let typed = "";
|
|
987
|
+
const onData = (data) => {
|
|
988
|
+
for (const ch of data) {
|
|
989
|
+
if (ch === "\r" || ch === "\n" || ch === "") {
|
|
990
|
+
stdin.off("data", onData);
|
|
991
|
+
resolve2(typed);
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (ch === "") {
|
|
995
|
+
stdin.off("data", onData);
|
|
996
|
+
reject(new Error("cancelled"));
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (ch === "\x7F" || ch === "\b") typed = typed.slice(0, -1);
|
|
1000
|
+
else typed += ch;
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
stdin.on("data", onData);
|
|
1004
|
+
stdin.once("end", () => reject(new Error("cancelled")));
|
|
1005
|
+
});
|
|
1006
|
+
} finally {
|
|
1007
|
+
stdin.setRawMode(false);
|
|
1008
|
+
stdin.pause();
|
|
1009
|
+
process.stderr.write("\n");
|
|
994
1010
|
}
|
|
995
|
-
const secret = deps.newSecret();
|
|
996
|
-
await deps.pairings.update((list) => addPairing(list, secret, deps.now(), deps.ttlMs));
|
|
997
|
-
return pairingLink(input.pwaOrigin, {
|
|
998
|
-
urls: input.phoneUrls,
|
|
999
|
-
agentKey: deps.keys.x25519.publicKey,
|
|
1000
|
-
secret,
|
|
1001
|
-
name: deps.machineId,
|
|
1002
|
-
...deps.defaultPath !== void 0 ? { defaultPath: deps.defaultPath } : {}
|
|
1003
|
-
});
|
|
1004
1011
|
}
|
|
1005
1012
|
|
|
1013
|
+
// src/cli.ts
|
|
1014
|
+
import encodeQR from "qr";
|
|
1015
|
+
|
|
1006
1016
|
// src/infrastructure/agent-service.ts
|
|
1007
1017
|
import { execFile as execFile5 } from "node:child_process";
|
|
1008
1018
|
import { promisify as promisify5 } from "node:util";
|
|
@@ -1252,6 +1262,8 @@ async function run3(argv, rawEnv, home, io = {}) {
|
|
|
1252
1262
|
}
|
|
1253
1263
|
case "devices":
|
|
1254
1264
|
return devices(new FileDeviceStore(config.devicesPath), rest);
|
|
1265
|
+
case "dictation-key":
|
|
1266
|
+
return dictationKey(new FileDictationKeysStore(config.dictationKeysPath), rest, io.secret ?? readSecret);
|
|
1255
1267
|
default:
|
|
1256
1268
|
throw new UsageError(`unknown command: ${command} (run \`tarfa --help\`)`);
|
|
1257
1269
|
}
|
|
@@ -1260,8 +1272,9 @@ var HELP = `usage: tarfa <command> [options] (\`tarfa setup --help\`, \`tarfa
|
|
|
1260
1272
|
|
|
1261
1273
|
Set up this machine:
|
|
1262
1274
|
setup write agent.env, the agent key, the service and the hooks, then check the phone port
|
|
1263
|
-
pair print a one-time pairing
|
|
1275
|
+
pair print a one-time pairing code and QR code for a phone (--link: a link, for an older app)
|
|
1264
1276
|
devices list, revoke or forget paired phones; open or close enrolment
|
|
1277
|
+
dictation-key store the ElevenLabs or OpenRouter key every paired phone dictates with
|
|
1265
1278
|
update install the newest @tarfa/cli and apply it (package installs)
|
|
1266
1279
|
apply rewrite the launcher and service if they changed, reinstall the hooks, restart
|
|
1267
1280
|
install-hooks install the Claude Code and Codex hooks that report each workspace's status
|
|
@@ -1287,6 +1300,7 @@ var COMMANDS = /* @__PURE__ */ new Set([
|
|
|
1287
1300
|
"connect",
|
|
1288
1301
|
"relay-key",
|
|
1289
1302
|
"devices",
|
|
1303
|
+
"dictation-key",
|
|
1290
1304
|
"install-hooks",
|
|
1291
1305
|
"ls",
|
|
1292
1306
|
"spawn",
|
|
@@ -1587,7 +1601,8 @@ async function readStdin(ttyError) {
|
|
|
1587
1601
|
return Buffer.concat(chunks).toString("utf8");
|
|
1588
1602
|
}
|
|
1589
1603
|
var RELAYS_GONE = "relays are gone: tarfa setup sets a machine up for phones, and tarfa pair pairs one";
|
|
1590
|
-
var
|
|
1604
|
+
var MAX_PAIRING_NAME = 64;
|
|
1605
|
+
var PAIR_USAGE = `usage: tarfa pair [--pwa <PWA origin>] [--ttl <minutes, 1-${PAIRING_TTL_MS / 6e4}, default ${PAIRING_TTL_MS / 6e4}>] [--link]`;
|
|
1591
1606
|
function takeOption(args, name, usage, Failure = Error) {
|
|
1592
1607
|
const at = args.indexOf(name);
|
|
1593
1608
|
if (at === -1) return void 0;
|
|
@@ -1629,6 +1644,8 @@ async function pair(argv, env, home) {
|
|
|
1629
1644
|
const config = loadConfig({ ...env, ...await agentEnvValues(env, home) }, home);
|
|
1630
1645
|
let origin = pwaOrigin(takeOption(args, "--pwa", PAIR_USAGE));
|
|
1631
1646
|
const ttl = takeOption(args, "--ttl", PAIR_USAGE) ?? String(PAIRING_TTL_MS / 6e4);
|
|
1647
|
+
const linkAt = args.indexOf("--link");
|
|
1648
|
+
if (linkAt !== -1) args.splice(linkAt, 1);
|
|
1632
1649
|
const minutes = Number(ttl);
|
|
1633
1650
|
if (!/^\d+$/.test(ttl) || minutes < 1 || minutes * 6e4 > PAIRING_TTL_MS) throw new Error(`--ttl takes whole minutes from 1 to ${PAIRING_TTL_MS / 6e4} (got ${ttl})`);
|
|
1634
1651
|
if (args.length > 0) throw new Error(PAIR_USAGE);
|
|
@@ -1643,19 +1660,21 @@ async function pair(argv, env, home) {
|
|
|
1643
1660
|
const bad = config.phoneAddresses.find((url) => !isMachineUrl(url));
|
|
1644
1661
|
if (bad) throw new Error(`TARFA_PHONE_ADDRESSES holds ${bad}, which a phone refuses: wss://<host>[:port][/<slug>] (run tarfa setup)`);
|
|
1645
1662
|
if (config.phoneAddresses.length > MAX_ADDRESSES) throw new Error(`TARFA_PHONE_ADDRESSES holds more than ${MAX_ADDRESSES} addresses`);
|
|
1646
|
-
const
|
|
1663
|
+
const machineId = pairingName(config.machineId, hostname());
|
|
1664
|
+
if (machineId.trim().length > MAX_PAIRING_NAME) throw new Error(`TARFA_MACHINE_ID must be at most ${MAX_PAIRING_NAME} characters, the name phones show (got ${machineId.length})`);
|
|
1665
|
+
const pairing = await mintPairing(
|
|
1647
1666
|
{
|
|
1648
1667
|
keys: await loadOrCreateAgentKeys(config.agentKeyPath),
|
|
1649
|
-
machineId
|
|
1668
|
+
machineId,
|
|
1650
1669
|
...config.defaultPath ? { defaultPath: config.defaultPath } : {},
|
|
1651
1670
|
pairings: new FilePairingStore(config.pairingPath),
|
|
1652
1671
|
newSecret: generatePairingSecret,
|
|
1653
1672
|
now: () => /* @__PURE__ */ new Date(),
|
|
1654
1673
|
ttlMs: minutes * 6e4
|
|
1655
1674
|
},
|
|
1656
|
-
{ phoneUrls: config.phoneAddresses
|
|
1675
|
+
{ phoneUrls: config.phoneAddresses }
|
|
1657
1676
|
);
|
|
1658
|
-
return pairingInstructions(
|
|
1677
|
+
return pairingInstructions(pairing, { origin, minutes, link: linkAt !== -1 });
|
|
1659
1678
|
}
|
|
1660
1679
|
var WaitTimeout = class extends Error {
|
|
1661
1680
|
exitCode = 124;
|
|
@@ -1675,11 +1694,16 @@ replace anything it did not make (an agent key, another tailscale serve entry).
|
|
|
1675
1694
|
|
|
1676
1695
|
--machine-id <id> the name phones show for this machine
|
|
1677
1696
|
--project-roots <dir,dir> absolute folders new workspaces may be made in
|
|
1678
|
-
--default-path <dir> where a phone resolves relative paths (
|
|
1697
|
+
--default-path <dir> where a phone resolves relative paths (sent when pairing)
|
|
1679
1698
|
--phone-port <n> loopback port phones reach (default 8797); every Linux user on one
|
|
1680
1699
|
machine needs their own, e.g. 8797 and 8798
|
|
1681
|
-
--tailscale serve|
|
|
1682
|
-
|
|
1700
|
+
--tailscale funnel|serve|none how phones reach this machine: with or without Tailscale (funnel,
|
|
1701
|
+
public through Funnel), only with Tailscale on (serve), or through an
|
|
1702
|
+
address published some other way (none, with --address). Ask the
|
|
1703
|
+
operator. Without it, setup keeps the TARFA_PHONE_ADDRESSES or the
|
|
1704
|
+
serve handler for the phone port the machine already has, picks
|
|
1705
|
+
funnel for a machine phones reached through a relay, and otherwise
|
|
1706
|
+
refuses. Every run prints a reachability: line saying which
|
|
1683
1707
|
--tailscale-port <n> the HTTPS port Tailscale serves it on (default 443 for serve, 10000
|
|
1684
1708
|
for funnel; Funnel allows only 443, 8443 and 10000)
|
|
1685
1709
|
--slug <name> serve under /<name>, so several agents share one port
|
|
@@ -1687,7 +1711,7 @@ replace anything it did not make (an agent key, another tailscale serve entry).
|
|
|
1687
1711
|
4 in all), e.g. a reverse proxy
|
|
1688
1712
|
--pwa <origin> where phones open the app (default https://tarfa.app; an existing
|
|
1689
1713
|
TARFA_PWA_ORIGIN is kept unless this is given)
|
|
1690
|
-
--pair print a pairing
|
|
1714
|
+
--pair print a pairing code and QR code at the end
|
|
1691
1715
|
--adopt a machine whose service runs a clone's deploy/run-agent.sh: move it to
|
|
1692
1716
|
the installed package (copies deploy/agent.env to ~/.tarfa/agent.env,
|
|
1693
1717
|
keeps the original, repoints the service, replaces the ~/.local/bin links
|
|
@@ -1795,9 +1819,10 @@ ${SETUP_USAGE.split("\n")[0]}
|
|
|
1795
1819
|
stateDir: stateDirLayout(home),
|
|
1796
1820
|
phonePortOwner: await owner(flags.phonePort),
|
|
1797
1821
|
hooksPortOwner: await owner(config.port),
|
|
1798
|
-
tailscale:
|
|
1799
|
-
//
|
|
1800
|
-
|
|
1822
|
+
// Read whatever --tailscale says: they settle how the port is published when no flag does, and word
|
|
1823
|
+
// whether phones need Tailscale. Both answer null when Tailscale is absent or will not tell.
|
|
1824
|
+
tailscale: await tailscale.status(),
|
|
1825
|
+
serve: await tailscale.serveStatus(),
|
|
1801
1826
|
cli: kind === "package" ? await cliState(home, env) : null,
|
|
1802
1827
|
adoption: cloneRepo === null ? null : { repo: cloneRepo, copyEnv, agentLive: { path: agentLivePath, kind: await linkKind(agentLivePath) } }
|
|
1803
1828
|
};
|
|
@@ -1813,7 +1838,7 @@ ${SETUP_USAGE.split("\n")[0]}
|
|
|
1813
1838
|
await loadOrCreateAgentKeys(config.agentKeyPath);
|
|
1814
1839
|
},
|
|
1815
1840
|
writeRoster: async () => {
|
|
1816
|
-
await
|
|
1841
|
+
await mkdir3(dirname3(config.devicesPath), { recursive: true, mode: 448 });
|
|
1817
1842
|
await devices2.update((roster) => roster);
|
|
1818
1843
|
},
|
|
1819
1844
|
writeLauncher: (path, text) => atomicWrite(path, text, 493),
|
|
@@ -1946,20 +1971,52 @@ async function apply2(argv, env, home, io) {
|
|
|
1946
1971
|
if (!result.ok) throw new Error(result.lines.join("\n"));
|
|
1947
1972
|
return result.lines.join("\n");
|
|
1948
1973
|
}
|
|
1949
|
-
function pairingInstructions(
|
|
1950
|
-
|
|
1974
|
+
function pairingInstructions(pairing, options) {
|
|
1975
|
+
const { origin, minutes } = options;
|
|
1976
|
+
const text = options.link ? pairingLink(origin, pairing) : pairingCode(pairing);
|
|
1977
|
+
const how = options.link ? [
|
|
1951
1978
|
"On the phone, in Tarfa: Settings \u2192 Scan QR code, then scan this. Or open the link below on the",
|
|
1952
|
-
"phone (Android opens the installed app; on an iPhone copy it and use Settings \u2192 Paste pairing link)."
|
|
1979
|
+
"phone (Android opens the installed app; on an iPhone copy it and use Settings \u2192 Paste pairing link)."
|
|
1980
|
+
] : [
|
|
1981
|
+
`On the phone, open the Tarfa app (install it from ${origin} first), then Settings \u2192 Add machine:`,
|
|
1982
|
+
"Scan QR code and scan this, or copy the code below to the phone and tap Paste pairing code."
|
|
1983
|
+
];
|
|
1984
|
+
return [
|
|
1985
|
+
...how,
|
|
1953
1986
|
"",
|
|
1954
1987
|
// Half-height blocks: about 60 columns, where `term` needs twice that.
|
|
1955
|
-
encodeQR(
|
|
1956
|
-
|
|
1988
|
+
encodeQR(text, "ascii", { ecc: "low" }),
|
|
1989
|
+
text,
|
|
1957
1990
|
"",
|
|
1958
1991
|
// The phone asks before pairing and shows this fingerprint: they must match.
|
|
1959
|
-
`The phone will show machine key ${deviceFingerprint(
|
|
1992
|
+
`The phone will show machine key ${deviceFingerprint(pairing.agentKey)}; check it matches.`,
|
|
1960
1993
|
`It works once, for ${minutes} minute${minutes === 1 ? "" : "s"}. \`tarfa pair\` makes another.`
|
|
1961
1994
|
].join("\n");
|
|
1962
1995
|
}
|
|
1996
|
+
var DICTATION_KEY_USAGE = [
|
|
1997
|
+
`usage: tarfa dictation-key [status | set ${DICTATION_PROVIDERS.join("|")} | remove ${DICTATION_PROVIDERS.join("|")}]`,
|
|
1998
|
+
" set reads the key on stdin, or asks for it with echo off; never as an argument",
|
|
1999
|
+
" every phone paired with this machine dictates into its workspaces with it, at its next connect"
|
|
2000
|
+
].join("\n");
|
|
2001
|
+
async function dictationKey(store, [action = "status", name, ...extra], secret) {
|
|
2002
|
+
if (action === "status") {
|
|
2003
|
+
if (name !== void 0) throw new UsageError(DICTATION_KEY_USAGE);
|
|
2004
|
+
const loaded2 = await store.load();
|
|
2005
|
+
if ("problem" in loaded2) throw new Error(loaded2.problem);
|
|
2006
|
+
return DICTATION_PROVIDERS.map((p) => `${p} ${loaded2.keys[p] ? "set" : "not set"}`).join("\n");
|
|
2007
|
+
}
|
|
2008
|
+
if (action !== "set" && action !== "remove") throw new UsageError(DICTATION_KEY_USAGE);
|
|
2009
|
+
if (action === "set" && extra.length > 0) {
|
|
2010
|
+
throw new UsageError("pass the key on stdin or type it at the prompt, never as an argument: any local user can read a process's arguments");
|
|
2011
|
+
}
|
|
2012
|
+
const provider = name ? providerOf(name) : null;
|
|
2013
|
+
if (!provider || extra.length > 0) throw new UsageError(DICTATION_KEY_USAGE);
|
|
2014
|
+
const loaded = await store.load();
|
|
2015
|
+
if ("problem" in loaded) throw new Error(loaded.problem);
|
|
2016
|
+
const result = await changeDictationKey(store, provider, action === "remove" ? null : await secret(`${provider} key: `));
|
|
2017
|
+
if (!result.ok) throw new Error(result.error);
|
|
2018
|
+
return action === "remove" ? `${provider}: removed; phones stop getting it at their next connect. Rotate it at the provider if a phone that had it is lost` : `${provider}: stored; every phone paired with this machine gets it at its next connect`;
|
|
2019
|
+
}
|
|
1963
2020
|
var DEVICES_USAGE = [
|
|
1964
2021
|
"usage: tarfa devices [list | revoke <id|key> | forget <id|key> | enrolment open|closed]",
|
|
1965
2022
|
" enrolment open a pairing code from `tarfa pair` enrols a new phone",
|
package/dist/serve.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
DEVICE_REFUSED,
|
|
7
7
|
FileClaudeSessionsStore,
|
|
8
8
|
FileDeviceStore,
|
|
9
|
+
FileDictationKeysStore,
|
|
9
10
|
FileHiddenFoldersStore,
|
|
10
11
|
FilePairingStore,
|
|
11
12
|
FileParkedStore,
|
|
@@ -25,9 +26,11 @@ import {
|
|
|
25
26
|
ShortWorkspaceIds,
|
|
26
27
|
TmuxAdapter,
|
|
27
28
|
WrongStaticKeyError,
|
|
29
|
+
announcedAddresses,
|
|
28
30
|
audioMediaType,
|
|
29
31
|
b64url,
|
|
30
32
|
browseDir,
|
|
33
|
+
changeDictationKey,
|
|
31
34
|
childEnv,
|
|
32
35
|
cliOfCommand,
|
|
33
36
|
configWarnings,
|
|
@@ -39,6 +42,7 @@ import {
|
|
|
39
42
|
enrolDevice,
|
|
40
43
|
enrolmentMode,
|
|
41
44
|
exactTarget,
|
|
45
|
+
exposedFiles,
|
|
42
46
|
formatAgentMessage,
|
|
43
47
|
fsIsDirectory,
|
|
44
48
|
getRecentPaths,
|
|
@@ -74,7 +78,7 @@ import {
|
|
|
74
78
|
systemPathResolver,
|
|
75
79
|
withLegacyEnv,
|
|
76
80
|
writeFileAtomic
|
|
77
|
-
} from "./chunk-
|
|
81
|
+
} from "./chunk-UPCCR4ZA.js";
|
|
78
82
|
|
|
79
83
|
// src/serve.ts
|
|
80
84
|
import { homedir as homedir2 } from "node:os";
|
|
@@ -1284,6 +1288,7 @@ var PRE_AUTH_MAX_MESSAGE_BYTES = 8 * 1024;
|
|
|
1284
1288
|
var MAX_AUTH_ATTEMPTS = 3;
|
|
1285
1289
|
var MAX_HELD_MESSAGES = 16;
|
|
1286
1290
|
var PAIRING_SECRET = /^[A-Za-z0-9_-]{43}$/;
|
|
1291
|
+
var FEATURES = [AGENT_FEATURES.lastReply, AGENT_FEATURES.dictationKeys, AGENT_FEATURES.setDictationKey];
|
|
1287
1292
|
var NoiseClientConnection = class {
|
|
1288
1293
|
constructor(socket, staticPrivate, options) {
|
|
1289
1294
|
this.socket = socket;
|
|
@@ -1332,7 +1337,15 @@ var NoiseClientConnection = class {
|
|
|
1332
1337
|
return;
|
|
1333
1338
|
}
|
|
1334
1339
|
if (message.type === "authResult" && message.ok) {
|
|
1335
|
-
const
|
|
1340
|
+
const { agentVersion: agentVersion2, phoneAddresses } = this.options;
|
|
1341
|
+
const features = [...this.options.features ?? FEATURES];
|
|
1342
|
+
const agent = {
|
|
1343
|
+
protocol: PROTOCOL_VERSION,
|
|
1344
|
+
...agentVersion2 ? { version: agentVersion2 } : {},
|
|
1345
|
+
features: phoneAddresses ? [...features, AGENT_FEATURES.addresses] : features,
|
|
1346
|
+
...phoneAddresses ? { addresses: phoneAddresses } : {}
|
|
1347
|
+
};
|
|
1348
|
+
const accepted = { ...message, agent };
|
|
1336
1349
|
message = accepted;
|
|
1337
1350
|
if (!this.awaitingPhone) this.cancelDeadline();
|
|
1338
1351
|
this.session.raiseCap(MAX_MESSAGE_BYTES);
|
|
@@ -2386,6 +2399,12 @@ var ConnectionHandler = class {
|
|
|
2386
2399
|
this.conn.send({ type: "keyterms", keyterms: await this.deps.keyterms.list() });
|
|
2387
2400
|
return;
|
|
2388
2401
|
}
|
|
2402
|
+
case "getDictationKeys":
|
|
2403
|
+
this.conn.send({ type: "dictationKeys", ...await this.deps.dictationKeys?.read() });
|
|
2404
|
+
return;
|
|
2405
|
+
case "setDictationKey":
|
|
2406
|
+
this.conn.send({ type: "dictationKeySet", id: message.id, provider: message.provider, ...await this.setDictationKey(message.provider, message.key) });
|
|
2407
|
+
return;
|
|
2389
2408
|
case "readFile": {
|
|
2390
2409
|
const { binary, data, truncated } = await readFile10({ reader }, message.path, await this.allowedRoots());
|
|
2391
2410
|
this.conn.send({ type: "fileContents", path: message.path, data, truncated, binary });
|
|
@@ -2679,6 +2698,17 @@ var ConnectionHandler = class {
|
|
|
2679
2698
|
});
|
|
2680
2699
|
this.conn.send({ type: "attached", workspaceId });
|
|
2681
2700
|
}
|
|
2701
|
+
// Any enrolled phone of this machine may change its key: it can already run anything in a
|
|
2702
|
+
// terminal here. A thrown error may name the file, never the key, but is worded here anyway.
|
|
2703
|
+
async setDictationKey(provider, key) {
|
|
2704
|
+
const store = this.deps.dictationKeys;
|
|
2705
|
+
if (!store) return { ok: false, error: "this agent keeps no dictation keys" };
|
|
2706
|
+
try {
|
|
2707
|
+
return await changeDictationKey(store, provider, key);
|
|
2708
|
+
} catch {
|
|
2709
|
+
return { ok: false, error: "the keys file could not be written" };
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2682
2712
|
// A dictation lives only as long as its attachment: without one the held spaces go nowhere,
|
|
2683
2713
|
// and the machine's one dictation slot would stay taken.
|
|
2684
2714
|
cancelVoice(reason) {
|
|
@@ -3876,7 +3906,7 @@ function startDeviceGate(deps) {
|
|
|
3876
3906
|
return {
|
|
3877
3907
|
staticPrivate: b64url.decode(keys.x25519.privateKey),
|
|
3878
3908
|
devices,
|
|
3879
|
-
options: (extra) => ({ devices, log, refusalLog, pushBudget, agentVersion: deps.agentVersion, ...extra }),
|
|
3909
|
+
options: (extra) => ({ devices, log, refusalLog, pushBudget, agentVersion: deps.agentVersion, phoneAddresses: deps.phoneAddresses, ...extra }),
|
|
3880
3910
|
roster: () => last,
|
|
3881
3911
|
pollRoster: () => monitor.poll(),
|
|
3882
3912
|
stop: () => clearInterval(poll)
|
|
@@ -4127,13 +4157,6 @@ import { join as join6 } from "node:path";
|
|
|
4127
4157
|
|
|
4128
4158
|
// src/infrastructure/private-dir.ts
|
|
4129
4159
|
import { chmod, mkdir as mkdir7, stat as stat4 } from "node:fs/promises";
|
|
4130
|
-
|
|
4131
|
-
// src/application/private-files.ts
|
|
4132
|
-
function exposedFiles(files) {
|
|
4133
|
-
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}\``);
|
|
4134
|
-
}
|
|
4135
|
-
|
|
4136
|
-
// src/infrastructure/private-dir.ts
|
|
4137
4160
|
async function securePrivateDir(dir) {
|
|
4138
4161
|
await mkdir7(dir, { recursive: true, mode: 448 });
|
|
4139
4162
|
await chmod(dir, 448);
|
|
@@ -4196,6 +4219,7 @@ async function main() {
|
|
|
4196
4219
|
config.devicesPath,
|
|
4197
4220
|
config.pairingPath,
|
|
4198
4221
|
join7(config.stateDir, "token"),
|
|
4222
|
+
config.dictationKeysPath,
|
|
4199
4223
|
// The launcher sources it: whoever can write it runs code as this user.
|
|
4200
4224
|
await locateAgentEnv(env, homedir2())
|
|
4201
4225
|
]);
|
|
@@ -4238,6 +4262,8 @@ async function main() {
|
|
|
4238
4262
|
launchers: new FileLaunchersStore(config.launchersPath),
|
|
4239
4263
|
detectInstalled: (names) => detectInstalled(names, loginShellResolver()),
|
|
4240
4264
|
keyterms: new FileKeytermsStore(config.keytermsPath),
|
|
4265
|
+
dictationKeys: new FileDictationKeysStore(config.dictationKeysPath, (problem) => process.stderr.write(`tarfa agent: no dictation keys sent: ${problem}
|
|
4266
|
+
`)),
|
|
4241
4267
|
clientLog: new FileClientLogStore(config.clientLogPath),
|
|
4242
4268
|
hookEventLog: new FileHookEventLog(config.hookEventLogPath),
|
|
4243
4269
|
messaging: {
|
|
@@ -4277,7 +4303,8 @@ async function main() {
|
|
|
4277
4303
|
pairings: new FilePairingStore(config.pairingPath),
|
|
4278
4304
|
log,
|
|
4279
4305
|
rosterPollMs: ROSTER_POLL_MS,
|
|
4280
|
-
agentVersion
|
|
4306
|
+
agentVersion,
|
|
4307
|
+
phoneAddresses: announcedAddresses(config.phoneAddresses)
|
|
4281
4308
|
});
|
|
4282
4309
|
try {
|
|
4283
4310
|
const phones = await startPhoneListener({
|