@termfleet/core 0.1.0
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/dist/agent-launch.d.ts +78 -0
- package/dist/agent-launch.js +247 -0
- package/dist/agent-session-id.d.ts +10 -0
- package/dist/agent-session-id.js +36 -0
- package/dist/agent-session-index-client.d.ts +7 -0
- package/dist/agent-session-index-client.js +86 -0
- package/dist/agent-session-index-worker.d.ts +1 -0
- package/dist/agent-session-index-worker.js +20 -0
- package/dist/agent-session-index.d.ts +34 -0
- package/dist/agent-session-index.js +527 -0
- package/dist/agent-session-tail.d.ts +33 -0
- package/dist/agent-session-tail.js +184 -0
- package/dist/agent-session-watcher.d.ts +36 -0
- package/dist/agent-session-watcher.js +194 -0
- package/dist/agent-session.d.ts +380 -0
- package/dist/agent-session.js +1688 -0
- package/dist/background-runner.d.ts +3 -0
- package/dist/background-runner.js +55 -0
- package/dist/boot-queue.d.ts +35 -0
- package/dist/boot-queue.js +66 -0
- package/dist/build-info.d.ts +5 -0
- package/dist/build-info.js +38 -0
- package/dist/collab/canvas-doc.d.ts +47 -0
- package/dist/collab/canvas-doc.js +83 -0
- package/dist/contracts/auth.d.ts +77 -0
- package/dist/contracts/auth.js +1 -0
- package/dist/contracts/canvas.d.ts +34 -0
- package/dist/contracts/canvas.js +76 -0
- package/dist/contracts/console-layout.d.ts +39 -0
- package/dist/contracts/console-layout.js +135 -0
- package/dist/contracts/files.d.ts +38 -0
- package/dist/contracts/files.js +37 -0
- package/dist/contracts/provider-url.d.ts +3 -0
- package/dist/contracts/provider-url.js +49 -0
- package/dist/contracts/registry.d.ts +58 -0
- package/dist/contracts/registry.js +285 -0
- package/dist/launch-trace.d.ts +6 -0
- package/dist/launch-trace.js +33 -0
- package/dist/lib/errors.d.ts +1 -0
- package/dist/lib/errors.js +5 -0
- package/dist/lib/exec.d.ts +13 -0
- package/dist/lib/exec.js +134 -0
- package/dist/local-providers.d.ts +32 -0
- package/dist/local-providers.js +184 -0
- package/dist/local-tunnel.d.ts +6 -0
- package/dist/local-tunnel.js +258 -0
- package/dist/provider-access-token.d.ts +11 -0
- package/dist/provider-access-token.js +77 -0
- package/dist/provider-client.d.ts +152 -0
- package/dist/provider-client.js +666 -0
- package/dist/provider-url-resolver.d.ts +16 -0
- package/dist/provider-url-resolver.js +37 -0
- package/dist/registry-client.d.ts +93 -0
- package/dist/registry-client.js +170 -0
- package/dist/registry.d.ts +56 -0
- package/dist/registry.js +406 -0
- package/dist/session-attention.d.ts +24 -0
- package/dist/session-attention.js +54 -0
- package/dist/session-lifecycle.d.ts +83 -0
- package/dist/session-lifecycle.js +658 -0
- package/dist/session-window.d.ts +3 -0
- package/dist/session-window.js +20 -0
- package/dist/terminal-client.d.ts +49 -0
- package/dist/terminal-client.js +89 -0
- package/dist/types.d.ts +155 -0
- package/dist/types.js +21 -0
- package/package.json +26 -0
package/dist/lib/exec.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
export function commandExists(command) {
|
|
3
|
+
const result = spawnSync("command", ["-v", command], {
|
|
4
|
+
encoding: "utf8",
|
|
5
|
+
shell: true
|
|
6
|
+
});
|
|
7
|
+
return result.status === 0;
|
|
8
|
+
}
|
|
9
|
+
export function requireCommand(command, installHint) {
|
|
10
|
+
if (!commandExists(command)) {
|
|
11
|
+
const hint = installHint ? ` ${installHint}` : "";
|
|
12
|
+
throw new Error(`${command} is required but was not found on PATH.${hint}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function run(command, args, options = {}) {
|
|
16
|
+
const result = spawnSync(command, args, {
|
|
17
|
+
cwd: options.cwd,
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
20
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
21
|
+
timeout: options.timeoutMs
|
|
22
|
+
});
|
|
23
|
+
if (result.error) {
|
|
24
|
+
const message = result.error.name === "Error" && "code" in result.error && result.error.code === "ETIMEDOUT"
|
|
25
|
+
? `${command} ${args.join(" ")} timed out after ${options.timeoutMs}ms.`
|
|
26
|
+
: result.error.message;
|
|
27
|
+
const detail = runOutputDetail(result.stdout, result.stderr);
|
|
28
|
+
if (detail) {
|
|
29
|
+
throw new Error(`${message}\n${detail}`);
|
|
30
|
+
}
|
|
31
|
+
throw result.error;
|
|
32
|
+
}
|
|
33
|
+
if (result.status !== 0) {
|
|
34
|
+
const output = runOutputDetail(result.stdout, result.stderr);
|
|
35
|
+
const detail = output ? `\n${output}` : "";
|
|
36
|
+
throw new Error(`${command} ${args.join(" ")} failed with exit ${result.status}.${detail}`);
|
|
37
|
+
}
|
|
38
|
+
return result.stdout;
|
|
39
|
+
}
|
|
40
|
+
function runOutputDetail(stdout, stderr) {
|
|
41
|
+
const stderrText = outputText(stderr);
|
|
42
|
+
const stdoutText = outputText(stdout);
|
|
43
|
+
if (stderrText && stdoutText) {
|
|
44
|
+
return `stderr:\n${stderrText}\nstdout:\n${stdoutText}`;
|
|
45
|
+
}
|
|
46
|
+
if (stderrText) {
|
|
47
|
+
return `stderr:\n${stderrText}`;
|
|
48
|
+
}
|
|
49
|
+
if (stdoutText) {
|
|
50
|
+
return `stdout:\n${stdoutText}`;
|
|
51
|
+
}
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
function outputText(value) {
|
|
55
|
+
if (Buffer.isBuffer(value)) {
|
|
56
|
+
return value.toString("utf8").trim();
|
|
57
|
+
}
|
|
58
|
+
return String(value ?? "").trim();
|
|
59
|
+
}
|
|
60
|
+
export function runBuffer(command, args, options = {}) {
|
|
61
|
+
const result = spawnSync(command, args, {
|
|
62
|
+
cwd: options.cwd,
|
|
63
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
64
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
65
|
+
});
|
|
66
|
+
if (result.error) {
|
|
67
|
+
throw result.error;
|
|
68
|
+
}
|
|
69
|
+
if (result.status !== 0) {
|
|
70
|
+
const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8").trim() : String(result.stderr ?? "").trim();
|
|
71
|
+
const detail = stderr ? `\n${stderr}` : "";
|
|
72
|
+
throw new Error(`${command} ${args.join(" ")} failed with exit ${result.status}.${detail}`);
|
|
73
|
+
}
|
|
74
|
+
return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? "");
|
|
75
|
+
}
|
|
76
|
+
export function runWithInput(command, args, input, options = {}) {
|
|
77
|
+
const result = spawnSync(command, args, {
|
|
78
|
+
cwd: options.cwd,
|
|
79
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
80
|
+
input,
|
|
81
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
82
|
+
});
|
|
83
|
+
if (result.error) {
|
|
84
|
+
throw result.error;
|
|
85
|
+
}
|
|
86
|
+
if (result.status !== 0) {
|
|
87
|
+
const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString("utf8").trim() : String(result.stderr ?? "").trim();
|
|
88
|
+
const detail = stderr ? `\n${stderr}` : "";
|
|
89
|
+
throw new Error(`${command} ${args.join(" ")} failed with exit ${result.status}.${detail}`);
|
|
90
|
+
}
|
|
91
|
+
return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? "");
|
|
92
|
+
}
|
|
93
|
+
export async function runAsync(command, args, options = {}) {
|
|
94
|
+
return await spawnAsync(command, args, options);
|
|
95
|
+
}
|
|
96
|
+
export async function runWithInputAsync(command, args, input, options = {}) {
|
|
97
|
+
return await spawnAsync(command, args, options, input);
|
|
98
|
+
}
|
|
99
|
+
async function spawnAsync(command, args, options, input) {
|
|
100
|
+
return await new Promise((resolve, reject) => {
|
|
101
|
+
const child = spawn(command, args, {
|
|
102
|
+
cwd: options.cwd,
|
|
103
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
104
|
+
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
|
|
105
|
+
});
|
|
106
|
+
const stdout = [];
|
|
107
|
+
const stderr = [];
|
|
108
|
+
child.stdout?.on("data", (chunk) => stdout.push(chunk));
|
|
109
|
+
child.stderr?.on("data", (chunk) => stderr.push(chunk));
|
|
110
|
+
child.on("error", reject);
|
|
111
|
+
child.on("close", (code) => {
|
|
112
|
+
if (code !== 0) {
|
|
113
|
+
const detail = Buffer.concat(stderr).toString("utf8").trim();
|
|
114
|
+
reject(new Error(`${command} ${args.join(" ")} failed with exit ${code}.${detail ? `\n${detail}` : ""}`));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
resolve(Buffer.concat(stdout).toString("utf8"));
|
|
118
|
+
});
|
|
119
|
+
if (input !== undefined && child.stdin) {
|
|
120
|
+
child.stdin.on("error", reject);
|
|
121
|
+
child.stdin.end(input);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
export function spawnInherited(command, args) {
|
|
126
|
+
const child = spawn(command, args, { stdio: "inherit" });
|
|
127
|
+
child.on("exit", (code, signal) => {
|
|
128
|
+
if (signal) {
|
|
129
|
+
process.kill(process.pid, signal);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
process.exit(code ?? 1);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface AdvertisedProvider {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
instanceId?: string;
|
|
4
|
+
kind?: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
pid?: number;
|
|
7
|
+
advertisedAt?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ResolvedProvider {
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
source: "flag:--url" | "env:TERMFLEET_PROVIDER_URL" | "current-context" | "auto-local";
|
|
12
|
+
label?: string;
|
|
13
|
+
}
|
|
14
|
+
export type ProviderResolutionCode = "no_provider" | "ambiguous_provider";
|
|
15
|
+
export declare class ProviderResolutionError extends Error {
|
|
16
|
+
readonly code: ProviderResolutionCode;
|
|
17
|
+
readonly remedy: string;
|
|
18
|
+
readonly candidates?: AdvertisedProvider[];
|
|
19
|
+
constructor(code: ProviderResolutionCode, message: string, remedy: string, candidates?: AdvertisedProvider[]);
|
|
20
|
+
}
|
|
21
|
+
export declare function advertiseLocalProvider(record: AdvertisedProvider): void;
|
|
22
|
+
export declare function withdrawLocalProvider(baseUrl: string): void;
|
|
23
|
+
export declare function readAdvertisedLocalProviders(): AdvertisedProvider[];
|
|
24
|
+
export declare function liveLocalProviders(timeoutMs?: number): Promise<AdvertisedProvider[]>;
|
|
25
|
+
export declare function readCurrentProvider(): string | undefined;
|
|
26
|
+
export declare function writeCurrentProvider(baseUrl: string): string;
|
|
27
|
+
export declare function clearCurrentProvider(): void;
|
|
28
|
+
export declare function resolveDefaultProvider(opts: {
|
|
29
|
+
url?: string;
|
|
30
|
+
env?: string;
|
|
31
|
+
probeTimeoutMs?: number;
|
|
32
|
+
}): Promise<ResolvedProvider>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Machine-global discovery for LOCAL providers, plus the default-provider
|
|
2
|
+
// resolution chain shared by every CLI command.
|
|
3
|
+
//
|
|
4
|
+
// The problem this solves: termfleet commands used to HARD-REQUIRE
|
|
5
|
+
// `--url`/`--provider`, and the only discovery path was the cloud registry —
|
|
6
|
+
// whose session expires (401) and which, for local providers, was stored
|
|
7
|
+
// CWD-RELATIVE (`${cwd}/.termfleet-registry.json`), so a perfectly good local
|
|
8
|
+
// provider was invisible from any other directory. The result: callers (humans
|
|
9
|
+
// and agents alike) constantly hit "no provider" and declared themselves
|
|
10
|
+
// "blocked" when a provider was in fact running.
|
|
11
|
+
//
|
|
12
|
+
// The fix mirrors what tmux / gpg-agent / Docker do for local defaults: the
|
|
13
|
+
// daemon SELF-ADVERTISES where it is listening to a fixed, machine-global
|
|
14
|
+
// location (`~/.termfleet/providers/`), and clients read it back and verify
|
|
15
|
+
// liveness. "Default local provider" therefore means *observed serving right
|
|
16
|
+
// now*, never a hardcoded port that can drift.
|
|
17
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { isLocalProviderUrl, normalizeProviderOrigin } from "./contracts/provider-url.js";
|
|
21
|
+
// A resolution failure carries a machine-readable `code` and a `remedy` string
|
|
22
|
+
// so an agent can branch on the cause instead of regexing prose, and a human
|
|
23
|
+
// gets a copy-pasteable fix. The top-level CLI catch renders it as JSON.
|
|
24
|
+
export class ProviderResolutionError extends Error {
|
|
25
|
+
code;
|
|
26
|
+
remedy;
|
|
27
|
+
candidates;
|
|
28
|
+
constructor(code, message, remedy, candidates) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "ProviderResolutionError";
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.remedy = remedy;
|
|
33
|
+
if (candidates)
|
|
34
|
+
this.candidates = candidates;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// `~/.termfleet` is already where the CLI keeps machine-global state (the
|
|
38
|
+
// registry auth token lives there). TERMFLEET_HOME overrides it — used by tests
|
|
39
|
+
// to point at a scratch dir so they never touch the real home.
|
|
40
|
+
function termfleetHome() {
|
|
41
|
+
return process.env.TERMFLEET_HOME ?? join(homedir(), ".termfleet");
|
|
42
|
+
}
|
|
43
|
+
function providersDir() {
|
|
44
|
+
return join(termfleetHome(), "providers");
|
|
45
|
+
}
|
|
46
|
+
function currentContextFile() {
|
|
47
|
+
return join(termfleetHome(), "current.json");
|
|
48
|
+
}
|
|
49
|
+
// One file per provider, named by its origin so re-advertising the same address
|
|
50
|
+
// overwrites in place (idempotent) instead of piling up records.
|
|
51
|
+
function recordFileName(origin) {
|
|
52
|
+
return `${origin.replace(/[^a-zA-Z0-9]+/g, "_")}.json`;
|
|
53
|
+
}
|
|
54
|
+
// Called by `provider serve` once it is listening. Only LOOPBACK providers
|
|
55
|
+
// self-advertise here — a tunneled/LAN provider is discoverable through the
|
|
56
|
+
// registry, not the local machine store.
|
|
57
|
+
export function advertiseLocalProvider(record) {
|
|
58
|
+
const origin = normalizeProviderOrigin(record.baseUrl);
|
|
59
|
+
if (!isLocalProviderUrl(origin))
|
|
60
|
+
return;
|
|
61
|
+
mkdirSync(providersDir(), { mode: 0o700, recursive: true });
|
|
62
|
+
const payload = {
|
|
63
|
+
...record,
|
|
64
|
+
advertisedAt: record.advertisedAt ?? new Date().toISOString(),
|
|
65
|
+
baseUrl: origin
|
|
66
|
+
};
|
|
67
|
+
writeFileSync(join(providersDir(), recordFileName(origin)), `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
|
68
|
+
}
|
|
69
|
+
// Best-effort removal on shutdown. Stale records also self-heal: a record whose
|
|
70
|
+
// /healthz no longer answers is pruned the next time anyone resolves.
|
|
71
|
+
export function withdrawLocalProvider(baseUrl) {
|
|
72
|
+
try {
|
|
73
|
+
rmSync(join(providersDir(), recordFileName(normalizeProviderOrigin(baseUrl))));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
/* already gone / never written — fine */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function readAdvertisedLocalProviders() {
|
|
80
|
+
let files;
|
|
81
|
+
try {
|
|
82
|
+
files = readdirSync(providersDir()).filter((file) => file.endsWith(".json"));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
const records = [];
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
try {
|
|
90
|
+
const record = JSON.parse(readFileSync(join(providersDir(), file), "utf8"));
|
|
91
|
+
if (record && typeof record.baseUrl === "string")
|
|
92
|
+
records.push(record);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* skip a corrupt record rather than fail discovery */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return records;
|
|
99
|
+
}
|
|
100
|
+
// Liveness via the provider's own /healthz — `registered` is not `serving`.
|
|
101
|
+
async function probeHealth(baseUrl, timeoutMs) {
|
|
102
|
+
const origin = normalizeProviderOrigin(baseUrl);
|
|
103
|
+
try {
|
|
104
|
+
const response = await fetch(`${origin}/healthz`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
105
|
+
if (!response.ok)
|
|
106
|
+
return undefined;
|
|
107
|
+
const health = (await response.json());
|
|
108
|
+
if (health.ok !== true)
|
|
109
|
+
return undefined;
|
|
110
|
+
return { baseUrl: origin, ...(health.provider ? { kind: health.provider } : {}), ...(health.instanceId ? { instanceId: health.instanceId } : {}) };
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// The advertised providers that are ACTUALLY serving right now. Dead records are
|
|
117
|
+
// pruned as a side effect so the store self-cleans.
|
|
118
|
+
export async function liveLocalProviders(timeoutMs = 1500) {
|
|
119
|
+
const advertised = readAdvertisedLocalProviders();
|
|
120
|
+
const probed = await Promise.all(advertised.map(async (record) => ({ live: await probeHealth(record.baseUrl, timeoutMs), record })));
|
|
121
|
+
const live = [];
|
|
122
|
+
for (const { live: health, record } of probed) {
|
|
123
|
+
if (health) {
|
|
124
|
+
live.push({ ...record, ...health });
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
withdrawLocalProvider(record.baseUrl);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return live;
|
|
131
|
+
}
|
|
132
|
+
export function readCurrentProvider() {
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(readFileSync(currentContextFile(), "utf8"));
|
|
135
|
+
return typeof parsed.baseUrl === "string" && parsed.baseUrl.trim() ? parsed.baseUrl : undefined;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function writeCurrentProvider(baseUrl) {
|
|
142
|
+
const origin = normalizeProviderOrigin(baseUrl);
|
|
143
|
+
mkdirSync(termfleetHome(), { mode: 0o700, recursive: true });
|
|
144
|
+
writeFileSync(currentContextFile(), `${JSON.stringify({ baseUrl: origin }, null, 2)}\n`, { mode: 0o600 });
|
|
145
|
+
return origin;
|
|
146
|
+
}
|
|
147
|
+
export function clearCurrentProvider() {
|
|
148
|
+
try {
|
|
149
|
+
rmSync(currentContextFile());
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
/* nothing to clear */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// THE chain. When no provider was named on the command line, resolve one in a
|
|
156
|
+
// fixed, explainable order. `--provider` (a registry/alias lookup) is handled by
|
|
157
|
+
// the caller before this; everything else flows through here so behavior is
|
|
158
|
+
// identical across every command.
|
|
159
|
+
//
|
|
160
|
+
// Order rationale: an explicit flag beats everything; an env var (how agents /
|
|
161
|
+
// CI inject a target) beats a human's persisted default; the persisted default
|
|
162
|
+
// (`termfleet use`) beats blind auto-discovery; auto-discovery is the
|
|
163
|
+
// zero-config local convenience. Ambiguity (more than one live local provider)
|
|
164
|
+
// is a LOUD error with the candidates listed — never a silent "most recent"
|
|
165
|
+
// guess, which would make agent behavior nondeterministic.
|
|
166
|
+
export async function resolveDefaultProvider(opts) {
|
|
167
|
+
const url = opts.url?.trim();
|
|
168
|
+
if (url)
|
|
169
|
+
return { baseUrl: url, source: "flag:--url" };
|
|
170
|
+
const env = opts.env?.trim();
|
|
171
|
+
if (env)
|
|
172
|
+
return { baseUrl: env, source: "env:TERMFLEET_PROVIDER_URL" };
|
|
173
|
+
const context = readCurrentProvider();
|
|
174
|
+
if (context)
|
|
175
|
+
return { baseUrl: context, source: "current-context" };
|
|
176
|
+
const live = await liveLocalProviders(opts.probeTimeoutMs);
|
|
177
|
+
if (live.length === 1 && live[0]) {
|
|
178
|
+
return { baseUrl: live[0].baseUrl, source: "auto-local", ...(live[0].name || live[0].kind ? { label: live[0].name ?? live[0].kind } : {}) };
|
|
179
|
+
}
|
|
180
|
+
if (live.length > 1) {
|
|
181
|
+
throw new ProviderResolutionError("ambiguous_provider", `Multiple local providers are live (${live.map((provider) => provider.baseUrl).join(", ")}); cannot pick a default.`, "Pass --url/--provider, set TERMFLEET_PROVIDER_URL, or choose a default with `termfleet use <url>`.", live);
|
|
182
|
+
}
|
|
183
|
+
throw new ProviderResolutionError("no_provider", "No provider specified and no live local provider was found.", "Start one with `termfleet provider serve --kind iterm`, or pass --url/--provider, or set TERMFLEET_PROVIDER_URL.");
|
|
184
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
type LocalTunnelOptions = {
|
|
2
|
+
tunnelId?: string;
|
|
3
|
+
};
|
|
4
|
+
export declare function resolveIframeSrcThroughLocalTunnel(rawSrc: string, options?: LocalTunnelOptions): Promise<string>;
|
|
5
|
+
export declare function resolveProviderOriginThroughLocalTunnel(rawOrigin: string, options?: LocalTunnelOptions): Promise<string>;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { asError } from "@termfleet/core/lib/errors.js";
|
|
2
|
+
import { commandExists } from "@termfleet/core/lib/exec.js";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { createWriteStream, mkdirSync } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { isLocalProviderUrl } from "./contracts/provider-url.js";
|
|
8
|
+
// A tunnel can fail two ways: the process exits, or it wedges (alive but no
|
|
9
|
+
// longer forwarding because its control connection dropped). The first is
|
|
10
|
+
// caught by the "exit" event; the second needs an out-of-band health probe,
|
|
11
|
+
// since a wedged process never exits. Both trigger a respawn.
|
|
12
|
+
const TUNNEL_HEALTH_INTERVAL_MS = 20_000;
|
|
13
|
+
const TUNNEL_UNHEALTHY_PROBES = 2;
|
|
14
|
+
const TUNNEL_RESPAWN_DELAY_MS = 1_500;
|
|
15
|
+
const TUNNEL_RESPAWN_MAX_DELAY_MS = 30_000;
|
|
16
|
+
const tunnelByOrigin = new Map();
|
|
17
|
+
// Tunnel children are unref'd (so they don't keep the process alive) — but unref'd
|
|
18
|
+
// children are NOT killed when the parent exits, so without this they orphan on a
|
|
19
|
+
// console exit/restart and keep forwarding (+ hold their log fd), accumulating. Track
|
|
20
|
+
// the live ones and kill them on process exit. (A SIGKILL can't be caught, so a
|
|
21
|
+
// hard-killed console can still orphan one — unavoidable.)
|
|
22
|
+
const liveTunnelProcesses = new Set();
|
|
23
|
+
let tunnelShutdownRegistered = false;
|
|
24
|
+
function registerTunnelShutdown() {
|
|
25
|
+
if (tunnelShutdownRegistered) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
tunnelShutdownRegistered = true;
|
|
29
|
+
const killAll = () => {
|
|
30
|
+
for (const child of liveTunnelProcesses) {
|
|
31
|
+
try {
|
|
32
|
+
child.kill();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// already gone
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
process.once("exit", killAll);
|
|
40
|
+
process.once("SIGINT", killAll);
|
|
41
|
+
process.once("SIGTERM", killAll);
|
|
42
|
+
}
|
|
43
|
+
export async function resolveIframeSrcThroughLocalTunnel(rawSrc, options = {}) {
|
|
44
|
+
const src = new URL(rawSrc);
|
|
45
|
+
if (!isLocalProviderUrl(src.origin)) {
|
|
46
|
+
return src.href;
|
|
47
|
+
}
|
|
48
|
+
const handle = await ensureLocalTunnel(src, options);
|
|
49
|
+
return `${handle.publicOrigin}${src.pathname}${src.search}${src.hash}`;
|
|
50
|
+
}
|
|
51
|
+
export async function resolveProviderOriginThroughLocalTunnel(rawOrigin, options = {}) {
|
|
52
|
+
const origin = new URL(rawOrigin);
|
|
53
|
+
if (!isLocalProviderUrl(origin.origin)) {
|
|
54
|
+
return origin.origin;
|
|
55
|
+
}
|
|
56
|
+
const handle = await ensureLocalTunnel(origin, options);
|
|
57
|
+
return handle.publicOrigin;
|
|
58
|
+
}
|
|
59
|
+
async function ensureLocalTunnel(src, options) {
|
|
60
|
+
const origin = src.origin;
|
|
61
|
+
const cacheKey = `${origin}#${options.tunnelId ?? ""}`;
|
|
62
|
+
const existing = tunnelByOrigin.get(cacheKey);
|
|
63
|
+
if (existing) {
|
|
64
|
+
const handle = await existing;
|
|
65
|
+
if (!isTunnelProcessLive(handle)) {
|
|
66
|
+
tunnelByOrigin.delete(cacheKey);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
return handle;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const next = startLocalTunnel(src, options).then((handle) => {
|
|
73
|
+
superviseLocalTunnel(src, options, cacheKey, handle);
|
|
74
|
+
return handle;
|
|
75
|
+
}).catch((error) => {
|
|
76
|
+
tunnelByOrigin.delete(cacheKey);
|
|
77
|
+
throw error;
|
|
78
|
+
});
|
|
79
|
+
tunnelByOrigin.set(cacheKey, next);
|
|
80
|
+
return await next;
|
|
81
|
+
}
|
|
82
|
+
function startLocalTunnel(src, options) {
|
|
83
|
+
const port = Number(src.port || (src.protocol === "https:" ? 443 : 80));
|
|
84
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
85
|
+
throw new Error(`Cannot tunnel iframe URL with invalid local port: ${src.href}`);
|
|
86
|
+
}
|
|
87
|
+
const bin = process.env.TERMFLEET_LOCAL_TUNNEL_BIN ?? "volter-tunnel";
|
|
88
|
+
if (!commandExists(bin)) {
|
|
89
|
+
throw new Error(`Opening localhost iframe panels requires ${bin} on PATH. Set TERMFLEET_LOCAL_TUNNEL_BIN to your tunnel client.`);
|
|
90
|
+
}
|
|
91
|
+
const tunnelServerUrl = process.env.TUNNEL_SERVER_URL ??
|
|
92
|
+
process.env.TERMFLEET_TUNNEL_SERVER_URL ??
|
|
93
|
+
'https://volter-tunnel.aaron-0ed.workers.dev';
|
|
94
|
+
if (!tunnelServerUrl) {
|
|
95
|
+
throw new Error("Tunneling requires a tunnel server. Set TERMFLEET_TUNNEL_SERVER_URL to your tunnel server's URL (or use a standalone tunnel like ngrok/cloudflared — see the README).");
|
|
96
|
+
}
|
|
97
|
+
const args = [
|
|
98
|
+
"--port", String(port),
|
|
99
|
+
"--host", tunnelServerUrl,
|
|
100
|
+
// The tunnel layer stays in passthrough (no tunnel-level auth); the console's
|
|
101
|
+
// own remote-access gate is what requires a session for non-loopback requests.
|
|
102
|
+
// volter-tunnel's flag is --auth-not-required (there is no --no-auth).
|
|
103
|
+
"--auth-not-required"
|
|
104
|
+
];
|
|
105
|
+
if (options.tunnelId) {
|
|
106
|
+
args.push("--tunnel-id", options.tunnelId);
|
|
107
|
+
}
|
|
108
|
+
const child = spawn(bin, args, {
|
|
109
|
+
env: process.env,
|
|
110
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
111
|
+
});
|
|
112
|
+
child.unref();
|
|
113
|
+
liveTunnelProcesses.add(child);
|
|
114
|
+
registerTunnelShutdown();
|
|
115
|
+
child.once("exit", () => liveTunnelProcesses.delete(child));
|
|
116
|
+
let stderr = "";
|
|
117
|
+
child.stderr.on("data", (chunk) => {
|
|
118
|
+
stderr += chunk.toString();
|
|
119
|
+
});
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
const timeout = setTimeout(() => {
|
|
122
|
+
child.kill();
|
|
123
|
+
reject(new Error(`Timed out starting local iframe tunnel for ${src.origin}.${stderr ? ` ${stderr.trim()}` : ""}`));
|
|
124
|
+
}, 15_000);
|
|
125
|
+
const cleanup = () => {
|
|
126
|
+
clearTimeout(timeout);
|
|
127
|
+
child.stdout.off("data", onStdout);
|
|
128
|
+
child.off("exit", onExit);
|
|
129
|
+
child.off("error", onError);
|
|
130
|
+
};
|
|
131
|
+
const onStdout = (chunk) => {
|
|
132
|
+
const text = chunk.toString();
|
|
133
|
+
const match = text.match(/https?:\/\/[^\s]+/);
|
|
134
|
+
if (!match?.[0]) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
cleanup();
|
|
138
|
+
// The tunnel logs heartbeats for its whole life. Once we have the URL we
|
|
139
|
+
// stop parsing, but we must keep draining stdout/stderr — leaving either
|
|
140
|
+
// pipe unconsumed fills its ~64KB OS buffer and the tunnel blocks and dies.
|
|
141
|
+
// Pipe both to a per-tunnel log file: that drains the pipes AND makes a
|
|
142
|
+
// wedged tunnel diagnosable instead of silent.
|
|
143
|
+
child.stderr.removeAllListeners("data");
|
|
144
|
+
const logPath = openTunnelLog(options.tunnelId, child);
|
|
145
|
+
const handle = {
|
|
146
|
+
logPath,
|
|
147
|
+
origin: src.origin,
|
|
148
|
+
process: child,
|
|
149
|
+
publicOrigin: new URL(match[0]).origin,
|
|
150
|
+
tunnelId: options.tunnelId
|
|
151
|
+
};
|
|
152
|
+
resolve(handle);
|
|
153
|
+
};
|
|
154
|
+
const onExit = (code) => {
|
|
155
|
+
cleanup();
|
|
156
|
+
reject(new Error(`Local iframe tunnel for ${src.origin} exited with ${code ?? "unknown"} before returning a URL.${stderr ? ` ${stderr.trim()}` : ""}`));
|
|
157
|
+
};
|
|
158
|
+
const onError = (error) => {
|
|
159
|
+
cleanup();
|
|
160
|
+
reject(error);
|
|
161
|
+
};
|
|
162
|
+
child.stdout.on("data", onStdout);
|
|
163
|
+
child.on("exit", onExit);
|
|
164
|
+
child.on("error", onError);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
function isTunnelProcessLive(handle) {
|
|
168
|
+
return handle.process.exitCode === null && handle.process.signalCode === null;
|
|
169
|
+
}
|
|
170
|
+
function openTunnelLog(tunnelId, child) {
|
|
171
|
+
const dir = join(homedir(), ".termfleet", "logs");
|
|
172
|
+
mkdirSync(dir, { mode: 0o700, recursive: true });
|
|
173
|
+
const logPath = join(dir, `tunnel-${tunnelId ?? "default"}.log`);
|
|
174
|
+
const stream = createWriteStream(logPath, { flags: "a", mode: 0o600 });
|
|
175
|
+
child.stdout.pipe(stream);
|
|
176
|
+
child.stderr.pipe(stream);
|
|
177
|
+
return logPath;
|
|
178
|
+
}
|
|
179
|
+
// Keep a tunnel alive behind its public URL. The URL is derived from the stable
|
|
180
|
+
// tunnelId, so it is unchanged across restarts — callers keep their resolved
|
|
181
|
+
// origin. Respawn on process exit OR on a sustained health-probe failure (the
|
|
182
|
+
// wedged-but-alive case). The two paths are mutually exclusive per generation
|
|
183
|
+
// via `replacing`, so a probe-triggered kill does not double-spawn through the
|
|
184
|
+
// exit handler it fires.
|
|
185
|
+
function superviseLocalTunnel(src, options, cacheKey, handle) {
|
|
186
|
+
let replacing = false;
|
|
187
|
+
const respawn = (reason) => {
|
|
188
|
+
if (replacing) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
replacing = true;
|
|
192
|
+
clearInterval(timer);
|
|
193
|
+
console.warn(`[termfleet:tunnel] ${src.origin} ${reason}; restarting (log: ${handle.logPath})`);
|
|
194
|
+
try {
|
|
195
|
+
handle.process.kill();
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Already gone — the respawn below brings up a fresh one.
|
|
199
|
+
}
|
|
200
|
+
// Keep retrying with backoff until the tunnel is back. The tunnel server can
|
|
201
|
+
// be briefly unreachable (a slow register times the child out and it exits);
|
|
202
|
+
// a single failed respawn must not give up — and crucially must not reject
|
|
203
|
+
// into a promise nobody awaits, which surfaces as an unhandled rejection that
|
|
204
|
+
// takes the whole console down. We own the retry here and the promise always
|
|
205
|
+
// resolves (to the eventual live handle) or stays pending while we retry.
|
|
206
|
+
tunnelByOrigin.set(cacheKey, respawnWithRetry(src, options, cacheKey));
|
|
207
|
+
};
|
|
208
|
+
handle.process.once("exit", (code) => respawn(`exited (code ${code ?? "unknown"})`));
|
|
209
|
+
let failures = 0;
|
|
210
|
+
const timer = setInterval(() => {
|
|
211
|
+
void (async () => {
|
|
212
|
+
try {
|
|
213
|
+
const response = await fetch(`${handle.publicOrigin}/healthz`, { signal: AbortSignal.timeout(8_000) });
|
|
214
|
+
// Any HTTP status means the tunnel forwarded to the local app (even a
|
|
215
|
+
// 401 from the app's own auth gate). Only a tunnel-server gateway error
|
|
216
|
+
// means the tunnel itself stopped forwarding.
|
|
217
|
+
failures = response.status === 502 || response.status === 504 ? failures + 1 : 0;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
failures += 1;
|
|
221
|
+
}
|
|
222
|
+
if (failures >= TUNNEL_UNHEALTHY_PROBES) {
|
|
223
|
+
respawn("stopped forwarding (wedged)");
|
|
224
|
+
}
|
|
225
|
+
})();
|
|
226
|
+
}, TUNNEL_HEALTH_INTERVAL_MS);
|
|
227
|
+
timer.unref();
|
|
228
|
+
}
|
|
229
|
+
// Bring a tunnel back after a failure, retrying with capped exponential backoff
|
|
230
|
+
// until startLocalTunnel succeeds. Deliberately never rejects: the tunnel server
|
|
231
|
+
// can be briefly unreachable (a slow register times the child out and it exits
|
|
232
|
+
// before printing a URL), and a respawn that rejected into this unawaited promise
|
|
233
|
+
// would surface as an unhandled rejection — which used to take the whole console
|
|
234
|
+
// down, blanking the UI ("the chat never comes up"). Retrying instead lets a
|
|
235
|
+
// transient outage self-heal while the console keeps serving everything else.
|
|
236
|
+
// Only one retry loop runs per origin at a time: respawn() is guarded by its
|
|
237
|
+
// generation's `replacing` flag, and each next generation begins only after a
|
|
238
|
+
// successful start.
|
|
239
|
+
async function respawnWithRetry(src, options, cacheKey) {
|
|
240
|
+
let backoff = TUNNEL_RESPAWN_DELAY_MS;
|
|
241
|
+
for (;;) {
|
|
242
|
+
await delay(backoff);
|
|
243
|
+
try {
|
|
244
|
+
const restarted = await startLocalTunnel(src, options);
|
|
245
|
+
superviseLocalTunnel(src, options, cacheKey, restarted);
|
|
246
|
+
return restarted;
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
backoff = Math.min(backoff * 2, TUNNEL_RESPAWN_MAX_DELAY_MS);
|
|
250
|
+
console.error(`[termfleet:tunnel] ${src.origin} restart failed (retrying in ${backoff}ms): ${asError(error).message}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function delay(ms) {
|
|
255
|
+
return new Promise((resolve) => {
|
|
256
|
+
setTimeout(resolve, ms).unref();
|
|
257
|
+
});
|
|
258
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const PROVIDER_ACCESS_TOKEN_DURATION_MS: number;
|
|
2
|
+
export type ProviderAccessTokenPayload = {
|
|
3
|
+
aud: "termfleet-provider";
|
|
4
|
+
exp: number;
|
|
5
|
+
iat: number;
|
|
6
|
+
org?: string;
|
|
7
|
+
provider: string;
|
|
8
|
+
sub: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function signProviderAccessToken(payload: ProviderAccessTokenPayload, secret: string): Promise<string>;
|
|
11
|
+
export declare function verifyProviderAccessTokenPayload(token: string | null | undefined, secret: string): Promise<ProviderAccessTokenPayload>;
|