@rikcodes/teamclaude 1.1.13-rik.1

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.
@@ -0,0 +1,115 @@
1
+ // Rewrite the request body's account_uuid to match the account whose token we
2
+ // inject. Claude Code puts the logged-in account's UUID inside `metadata.user_id`
3
+ // (a stringified JSON) of /v1/messages; under rotation that would disagree with
4
+ // the injected token.
5
+ //
6
+ // This is a STREAMING, byte-exact JSON state machine — no regex, no whole-body
7
+ // buffering — so it handles arbitrarily large bodies fed in chunks. It tracks
8
+ // JSON structure (container stack, current key, in-string/escape) to find the
9
+ // `metadata.user_id` string value, and only inside that value does it look for
10
+ // the `account_uuid` field and overwrite its 36-char value with the new UUID
11
+ // (same length → no content-length/flow-control changes). A stray `account_uuid`
12
+ // elsewhere in the body (user content, tool results) is never touched.
13
+
14
+ // Byte sequence of `account_uuid":"` as it appears INSIDE the (escaped) user_id
15
+ // string: account_uuid \ " : \ "
16
+ const PREFIX = Buffer.from('account_uuid\\":\\"', 'latin1');
17
+
18
+ export class AccountUuidPatcher {
19
+ constructor(newUuid) {
20
+ this.newUuid = (typeof newUuid === 'string' && newUuid.length === 36) ? Buffer.from(newUuid, 'latin1') : null;
21
+ this.frames = []; // container stack: { container:'obj'|'arr', name, key, awaitingKey }
22
+ this.inStr = false;
23
+ this.esc = false;
24
+ this.readingKey = false;
25
+ this.keyBuf = [];
26
+ this.target = false; // inside the metadata.user_id string value
27
+ this.matchPos = 0; // PREFIX match progress (within target)
28
+ this.uuidRemaining = 0; // value bytes left to overwrite
29
+ this.done = false; // patched the one account_uuid already
30
+ this.changed = false;
31
+ }
32
+
33
+ /** Feed a chunk; returns a same-length chunk (patched in place). */
34
+ push(chunk) {
35
+ if (!this.newUuid || this.done) return chunk;
36
+ const out = Buffer.from(chunk);
37
+ for (let i = 0; i < out.length; i++) {
38
+ out[i] = this.#byte(out[i]);
39
+ if (this.done) break; // rest passes through unchanged
40
+ }
41
+ return out;
42
+ }
43
+
44
+ #top() { return this.frames[this.frames.length - 1]; }
45
+
46
+ #byte(b) {
47
+ if (this.target) return this.#targetByte(b);
48
+
49
+ if (this.inStr) {
50
+ if (this.esc) { this.esc = false; if (this.readingKey) this.keyBuf.push(b); return b; }
51
+ if (b === 0x5c) { this.esc = true; return b; } // backslash
52
+ if (b === 0x22) { // end of string
53
+ this.inStr = false;
54
+ if (this.readingKey) { this.#top().key = Buffer.from(this.keyBuf).toString('latin1'); this.keyBuf = []; this.readingKey = false; }
55
+ return b;
56
+ }
57
+ if (this.readingKey) this.keyBuf.push(b);
58
+ return b;
59
+ }
60
+
61
+ const top = this.#top();
62
+ switch (b) {
63
+ case 0x7b: this.frames.push({ container: 'obj', name: top ? top.key : null, key: null, awaitingKey: true }); break; // {
64
+ case 0x5b: this.frames.push({ container: 'arr', name: top ? top.key : null, key: null, awaitingKey: false }); break; // [
65
+ case 0x7d: case 0x5d: this.frames.pop(); break; // } ]
66
+ case 0x3a: if (top) top.awaitingKey = false; break; // : (key → value)
67
+ case 0x2c: if (top && top.container === 'obj') top.awaitingKey = true; break; // ,
68
+ case 0x22: // string start
69
+ if (top && top.container === 'obj' && top.awaitingKey) {
70
+ this.readingKey = true; this.keyBuf = []; this.inStr = true; this.esc = false;
71
+ } else {
72
+ this.inStr = true; this.esc = false; this.readingKey = false;
73
+ if (top && top.container === 'obj' && top.name === 'metadata' && top.key === 'user_id' && this.frames.length === 2) {
74
+ this.target = true; this.matchPos = 0; this.uuidRemaining = 0;
75
+ }
76
+ }
77
+ break;
78
+ default: break; // scalars / whitespace
79
+ }
80
+ return b;
81
+ }
82
+
83
+ // Inside the metadata.user_id string value: stream-match the account_uuid key
84
+ // and overwrite its 36-byte value. Detect the (unescaped) closing quote to exit.
85
+ #targetByte(b) {
86
+ if (this.uuidRemaining > 0) {
87
+ const outByte = this.newUuid[this.newUuid.length - this.uuidRemaining];
88
+ this.uuidRemaining--;
89
+ if (outByte !== b) this.changed = true;
90
+ if (this.uuidRemaining === 0) this.done = true; // only one account_uuid per body
91
+ return outByte;
92
+ }
93
+ if (this.esc) { this.esc = false; this.#match(b); return b; }
94
+ if (b === 0x5c) { this.esc = true; this.#match(b); return b; }
95
+ if (b === 0x22) { this.target = false; this.matchPos = 0; return b; } // end of user_id value
96
+ this.#match(b);
97
+ return b;
98
+ }
99
+
100
+ #match(b) {
101
+ if (b === PREFIX[this.matchPos]) {
102
+ this.matchPos++;
103
+ if (this.matchPos === PREFIX.length) { this.uuidRemaining = 36; this.matchPos = 0; }
104
+ } else {
105
+ this.matchPos = (b === PREFIX[0]) ? 1 : 0; // PREFIX has no internal repeat of its first byte
106
+ }
107
+ }
108
+ }
109
+
110
+ /** One-shot convenience (whole-buffer); returns the same instance if unchanged. */
111
+ export function patchAccountUuid(buf, newUuid) {
112
+ const p = new AccountUuidPatcher(newUuid);
113
+ const out = p.push(buf);
114
+ return p.changed ? out : buf;
115
+ }
package/src/alias.js ADDED
@@ -0,0 +1,125 @@
1
+ // `claude` shell alias — print or install/uninstall.
2
+ //
3
+ // The alias simply routes plain `claude` through `teamclaude run`, which probes
4
+ // the proxy and, when it's down, errors out rather than silently bypassing the
5
+ // proxy. All the smarts live in `run`; add `--auto-fallback` to the alias if you
6
+ // want plain `claude` to launch directly when the proxy is down instead.
7
+ //
8
+ // This only affects interactive shells (aliases aren't seen by editors/scripts
9
+ // that exec `claude` themselves). It's intentionally lighter than a PATH shim:
10
+ // no binary shadowing, one line per rc, trivially reversible.
11
+
12
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, realpathSync } from 'node:fs';
13
+ import { join, dirname } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+
16
+ const MARKER = '# teamclaude alias';
17
+
18
+ /** Basename of the user's login shell, e.g. "zsh". Defaults to bash. */
19
+ export function detectShell() {
20
+ return (process.env.SHELL || '').split('/').pop() || 'bash';
21
+ }
22
+
23
+ /** Whether a bare command resolves on the current $PATH. */
24
+ function commandOnPath(cmd) {
25
+ for (const dir of (process.env.PATH || '').split(':')) {
26
+ if (dir && existsSync(join(dir, cmd))) return true;
27
+ }
28
+ return false;
29
+ }
30
+
31
+ /**
32
+ * How the alias should invoke teamclaude. Prefer the bare `teamclaude` when it's
33
+ * on $PATH; otherwise embed the absolute path to this CLI (quoted) so the alias
34
+ * still works when teamclaude isn't installed on PATH — e.g. run from a clone.
35
+ */
36
+ export function teamclaudeRef() {
37
+ if (commandOnPath('teamclaude')) return 'teamclaude';
38
+ const entry = process.argv[1];
39
+ if (!entry) return 'teamclaude';
40
+ let abs;
41
+ try { abs = realpathSync(entry); } catch { abs = entry; }
42
+ return `"${abs}"`;
43
+ }
44
+
45
+ /** The alias definition for a given shell family. */
46
+ export function aliasLine(shell = detectShell(), ref = teamclaudeRef()) {
47
+ const body = `${ref} run --`;
48
+ if (shell === 'fish') return `alias claude '${body}'`;
49
+ return `alias claude='${body}'`;
50
+ }
51
+
52
+ /** The rc file an alias for this shell should live in. */
53
+ export function rcPathForShell(shell = detectShell()) {
54
+ const home = homedir();
55
+ switch (shell) {
56
+ case 'zsh': return join(home, '.zshrc');
57
+ case 'sh': return join(home, '.profile');
58
+ case 'fish': {
59
+ const cfg = process.env.XDG_CONFIG_HOME || join(home, '.config');
60
+ return join(cfg, 'fish', 'conf.d', 'teamclaude.fish');
61
+ }
62
+ case 'bash':
63
+ default: return join(home, '.bashrc');
64
+ }
65
+ }
66
+
67
+ export function printAlias({ shell = detectShell() } = {}) {
68
+ const line = aliasLine(shell);
69
+ console.log('# Route plain `claude` through the proxy (errors if the proxy is down;');
70
+ console.log('# append --auto-fallback before `--` to launch claude directly instead).');
71
+ console.log('# Add this to your shell config:');
72
+ console.log('');
73
+ console.log(` ${line}`);
74
+ console.log('');
75
+ console.log(`# Or install it automatically: teamclaude alias --install`);
76
+ console.log(`# → writes to ${rcPathForShell(shell)} (override with --shell <bash|zsh|fish|sh>)`);
77
+ }
78
+
79
+ export function installAlias({ shell = detectShell(), rcPath = rcPathForShell(shell) } = {}) {
80
+ const line = aliasLine(shell);
81
+ mkdirSync(dirname(rcPath), { recursive: true });
82
+ let text = existsSync(rcPath) ? readFileSync(rcPath, 'utf8') : '';
83
+
84
+ if (text.includes(line)) {
85
+ console.log(`Alias already present in ${rcPath}`);
86
+ return;
87
+ }
88
+ if (text && !text.endsWith('\n')) text += '\n';
89
+ text += `${MARKER}\n${line}\n`;
90
+ writeFileSync(rcPath, text);
91
+ console.log(`Installed alias in ${rcPath}`);
92
+ console.log('Reload your shell (or open a new terminal) to use it.');
93
+ }
94
+
95
+ export function uninstallAlias({ shell = detectShell(), rcPath = rcPathForShell(shell) } = {}) {
96
+ if (!existsSync(rcPath)) {
97
+ console.log(`Nothing to remove (${rcPath} does not exist)`);
98
+ return;
99
+ }
100
+ const text = readFileSync(rcPath, 'utf8');
101
+ // Strip our marked block: the marker comment + the single line after it.
102
+ // Matching by marker (not by exact alias text) makes this robust even if the
103
+ // embedded teamclaude path differs from what's computed now.
104
+ const blockRe = new RegExp(`\\n?${escapeRe(MARKER)}\\n[^\\n]*\\n?`, 'g');
105
+ let cleaned = text.replace(blockRe, '\n');
106
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
107
+
108
+ if (cleaned === text) {
109
+ console.log(`Alias not found in ${rcPath}`);
110
+ return;
111
+ }
112
+
113
+ // For the dedicated fish drop-file, remove it entirely if now empty.
114
+ if (rcPath.endsWith('teamclaude.fish') && cleaned.trim() === '') {
115
+ rmSync(rcPath);
116
+ console.log(`Removed ${rcPath}`);
117
+ return;
118
+ }
119
+ writeFileSync(rcPath, cleaned);
120
+ console.log(`Removed alias from ${rcPath}`);
121
+ }
122
+
123
+ function escapeRe(s) {
124
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
125
+ }
@@ -0,0 +1,65 @@
1
+ // Percent-encode an account name (or key) for a URL, leaving ONLY the unreserved
2
+ // set. encodeURIComponent alone is not enough here: it passes `( ) ' ! *`
3
+ // through untouched, and these lines are emitted as unquoted shell `export`
4
+ // statements for `eval "$(teamclaude env)"` — a name like "work (Acme)" would be
5
+ // a shell syntax error. Clients percent-decode userinfo before using it
6
+ // (verified against Claude Code 2.1.220), so the extra escaping is transparent.
7
+ export function encodePinComponent(s) {
8
+ return encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
9
+ }
10
+
11
+ // Build the shell `export` lines that point Claude Code — or any tool that
12
+ // spawns it, e.g. an agent multiplexer — at the proxy. This is the same
13
+ // environment `teamclaude run` sets up, but emitted for `eval "$(teamclaude
14
+ // env)"` instead of launching claude directly. Pure and side-effect free so it
15
+ // can be unit-tested; the caller resolves the port, cert path, and holdSeconds.
16
+ //
17
+ // MITM (forward-proxy) mode is the default, matching `teamclaude run`: it routes
18
+ // ALL of claude's traffic through the proxy — even hardcoded api.anthropic.com
19
+ // endpoints (e.g. the design MCP) — with claude trusting our leaf via
20
+ // NODE_EXTRA_CA_CERTS. base-URL mode only redirects the Anthropic base URL and
21
+ // leaves other hosts alone.
22
+ //
23
+ // No ANTHROPIC_API_KEY is emitted: loopback clients are exempt from the proxy's
24
+ // key gate, and setting it would drop Claude Code out of subscription mode (and
25
+ // its full model access). Remote clients that aren't on loopback must add the
26
+ // proxy key themselves.
27
+ // `account` pins the session to one account (TC_ACCT), exactly as `teamclaude
28
+ // run` does: in MITM mode it rides in the proxy URL's userinfo and reaches the
29
+ // proxy as the CONNECT's Basic username; in base-URL mode it becomes a
30
+ // `/tc-acct/` prefix. TC_ACCT itself is then unset, so the pin does not leak
31
+ // into claude or anything it spawns — same reasoning as `run` deleting it from
32
+ // the child environment.
33
+ export function buildClaudeEnvLines({ port, useMitm = true, caPath = null, holdSeconds = 0, account = null, proxyApiKey = '' }) {
34
+ const lines = [];
35
+ const pin = (account || '').trim();
36
+
37
+ if (useMitm) {
38
+ const userinfo = pin ? `${encodePinComponent(pin)}:${encodePinComponent(proxyApiKey || '')}@` : '';
39
+ const proxyUrl = `http://${userinfo}127.0.0.1:${port}`;
40
+ lines.push(
41
+ `export HTTPS_PROXY=${proxyUrl}`,
42
+ `export HTTP_PROXY=${proxyUrl}`,
43
+ `export https_proxy=${proxyUrl}`,
44
+ `export http_proxy=${proxyUrl}`,
45
+ 'export NO_PROXY=localhost,127.0.0.1,::1',
46
+ 'export no_proxy=localhost,127.0.0.1,::1',
47
+ );
48
+ if (caPath) lines.push(`export NODE_EXTRA_CA_CERTS=${caPath}`);
49
+ // Clear any stale base-URL so the two modes don't stack in one shell.
50
+ lines.push('unset ANTHROPIC_BASE_URL');
51
+ } else {
52
+ const prefix = pin ? `/tc-acct/${encodePinComponent(pin)}` : '';
53
+ lines.push(`export ANTHROPIC_BASE_URL=http://localhost:${port}${prefix}`);
54
+ }
55
+
56
+ // The pin is now carried by the routing itself; keep it out of the child.
57
+ if (pin) lines.push('unset TC_ACCT');
58
+
59
+ // Parity with `run`: if the proxy may hold the connection on exhaustion, raise
60
+ // the client-side timeout so it doesn't give up mid-hold.
61
+ const holdMs = (holdSeconds || 0) * 1000;
62
+ if (holdMs > 0) lines.push(`export API_TIMEOUT_MS=${holdMs + 60_000}`);
63
+
64
+ return lines;
65
+ }
package/src/config.js ADDED
@@ -0,0 +1,146 @@
1
+ import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
2
+ import { join, dirname } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { resolveUpstreamProxy, setUpstreamProxy } from './upstream-proxy.js';
6
+
7
+ export function getConfigPath() {
8
+ if (process.env.TEAMCLAUDE_CONFIG) return process.env.TEAMCLAUDE_CONFIG;
9
+ const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
10
+ return join(configDir, 'teamclaude.json');
11
+ }
12
+
13
+ /**
14
+ * Path to the runtime state file (a sibling of the config). This holds volatile
15
+ * data learned at runtime — e.g. quota utilization observed passively from
16
+ * traffic — kept out of the hand-editable config so config stays clean and
17
+ * isn't rewritten on every state save.
18
+ */
19
+ export function getStatePath() {
20
+ const cfg = getConfigPath();
21
+ return cfg.endsWith('.json') ? cfg.replace(/\.json$/, '.state.json') : cfg + '.state';
22
+ }
23
+
24
+ /**
25
+ * Path to the crash log (a sibling of the config), where a fatal error is
26
+ * recorded before the process exits.
27
+ */
28
+ export function getCrashLogPath() {
29
+ const cfg = getConfigPath();
30
+ return cfg.endsWith('.json') ? cfg.replace(/\.json$/, '-crash.log') : cfg + '-crash.log';
31
+ }
32
+
33
+ export async function loadState() {
34
+ try {
35
+ return JSON.parse(await readFile(getStatePath(), 'utf-8'));
36
+ } catch (err) {
37
+ if (err.code === 'ENOENT') return null;
38
+ throw err;
39
+ }
40
+ }
41
+
42
+ export async function saveState(state) {
43
+ const path = getStatePath();
44
+ await mkdir(dirname(path), { recursive: true });
45
+ await writeFile(path, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
46
+ // `mode` only applies when the file is CREATED; enforce 0600 on every save so
47
+ // a pre-existing state file (holding quota + tokens) can't linger world-readable.
48
+ await chmod(path, 0o600).catch(() => {});
49
+ }
50
+
51
+ export function createDefaultConfig() {
52
+ return {
53
+ proxy: {
54
+ port: 3456,
55
+ apiKey: 'tc-' + randomBytes(24).toString('base64url'),
56
+ },
57
+ upstream: 'https://api.anthropic.com',
58
+ switchThreshold: 0.98,
59
+ holdSeconds: 0,
60
+ distributeSessions: false,
61
+ eventLogging: 'hide',
62
+ blockedModels: [],
63
+ accounts: [],
64
+ };
65
+ }
66
+
67
+ export async function loadConfig() {
68
+ const path = getConfigPath();
69
+ try {
70
+ const config = JSON.parse(await readFile(path, 'utf-8'));
71
+ applyUpstreamProxy(config);
72
+ return config;
73
+ } catch (err) {
74
+ if (err.code === 'ENOENT') return null;
75
+ throw err;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Publish the config's egress proxy to the process-wide setting.
81
+ *
82
+ * Done here, in the one place every command loads its config, rather than at
83
+ * each of the sixteen call sites: `login`, `import`, `accounts`, `probe` and the
84
+ * server all reach the network, and a proxy that applied to only some of them
85
+ * would be worse than none — the account list would refresh while logging in
86
+ * failed, or vice versa.
87
+ *
88
+ * A bad value is fatal on purpose. Falling back to a direct connection on a host
89
+ * that has no route to the internet would turn one clear error into a pile of
90
+ * ETIMEDOUTs pointing nowhere near the typo that caused them.
91
+ */
92
+ function applyUpstreamProxy(config) {
93
+ try {
94
+ setUpstreamProxy(resolveUpstreamProxy(config));
95
+ } catch (err) {
96
+ console.error(`[TeamClaude] Bad proxy setting in ${getConfigPath()}: ${err.message}`);
97
+ process.exit(1);
98
+ }
99
+ }
100
+
101
+ export async function loadOrCreateConfig() {
102
+ let config = await loadConfig();
103
+ if (!config) {
104
+ config = createDefaultConfig();
105
+ await saveConfig(config);
106
+ console.log(`Created config at ${getConfigPath()}`);
107
+ }
108
+ return config;
109
+ }
110
+
111
+ export async function saveConfig(config) {
112
+ const path = getConfigPath();
113
+ await mkdir(dirname(path), { recursive: true });
114
+ await writeFile(path, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
115
+ // Enforce 0600 even if the file already existed (the proxy apiKey + account
116
+ // tokens live here); `mode` above is honored only on creation.
117
+ await chmod(path, 0o600).catch(() => {});
118
+ }
119
+
120
+ // Serialize config updates. atomicConfigUpdate is a read-modify-write, so two
121
+ // concurrent callers can both read the same config and then save in turn, and
122
+ // the later save silently drops the earlier caller's change. This bites hardest
123
+ // on startup, when several OAuth accounts refresh their tokens at once: only the
124
+ // last writer's rotated refresh token persists, and the other accounts keep a
125
+ // token that was just rotated away, so they fail on the next restart with
126
+ // invalid_grant and need a re-login. Chaining the updates keeps every write.
127
+ let configUpdateChain = Promise.resolve();
128
+
129
+ /**
130
+ * Atomically update the config: re-reads from disk, calls updater(config),
131
+ * then saves. Returns the updated config. This prevents overwriting changes
132
+ * made by other processes (e.g. `teamclaude import` while the server runs), and
133
+ * serializes concurrent callers so simultaneous updates queue instead of
134
+ * clobbering one another.
135
+ */
136
+ export function atomicConfigUpdate(updater) {
137
+ const run = async () => {
138
+ const config = await loadConfig() || createDefaultConfig();
139
+ await updater(config);
140
+ await saveConfig(config);
141
+ return config;
142
+ };
143
+ const result = configUpdateChain.then(run, run);
144
+ configUpdateChain = result.then(() => {}, () => {});
145
+ return result;
146
+ }
@@ -0,0 +1,27 @@
1
+ import { appendFileSync } from 'node:fs';
2
+
3
+ /**
4
+ * Write a fatal error to `path` before the process dies.
5
+ *
6
+ * Node prints an uncaught exception to stderr and exits, which is invisible in
7
+ * practice: the server runs under a full-screen TUI that repaints over the
8
+ * stack, and stderr usually goes nowhere anyone kept. The one artifact that
9
+ * explains a sudden exit has to outlive the process, so write it to a file.
10
+ *
11
+ * Handling these events replaces Node's own behaviour, so this must do what
12
+ * Node would: report and exit non-zero. Continuing after an uncaught exception
13
+ * would leave the proxy running on unknown state.
14
+ */
15
+ export function installCrashHandlers(path, { exit = process.exit, log = process.stderr } = {}) {
16
+ const report = (kind) => (err) => {
17
+ const stack = err?.stack || String(err);
18
+ const entry = `\n=== ${new Date().toISOString()} ${kind} ===\n${stack}\n`;
19
+ // 0600: a stack can carry request context. A write failure (read-only home,
20
+ // full disk) must not mask the crash itself — stderr still gets the entry.
21
+ try { appendFileSync(path, entry, { mode: 0o600 }); } catch { /* report to stderr regardless */ }
22
+ log.write(entry);
23
+ exit(1);
24
+ };
25
+ process.on('uncaughtException', report('uncaughtException'));
26
+ process.on('unhandledRejection', report('unhandledRejection'));
27
+ }
@@ -0,0 +1,132 @@
1
+ // Egress pinning — refuse to spend an account from the wrong exit IP.
2
+ //
3
+ // When a VPN drops mid-session, traffic silently continues from the machine's
4
+ // own address. Anthropic answers a request from an unexpected region with 403
5
+ // "Request not allowed", and Claude Code reads that 403 as a dead session and
6
+ // asks for a re-login — over a network event, with the token still valid. The
7
+ // account has already been used from the new address by then.
8
+ //
9
+ // So the check belongs BEFORE the request: if the exit IP is not the pinned
10
+ // one, hold the request until the tunnel is back rather than sending it. A held
11
+ // request looks like a slow response; a sent one can cost a re-login.
12
+ //
13
+ // Opt-in: without `egress.pin` in the config this is inert and nothing here runs.
14
+
15
+ const DEFAULT_CHECK_URL = 'https://api.ipify.org';
16
+ const DEFAULT_TTL_MS = 30_000;
17
+ const DEFAULT_HOLD_MS = 120_000;
18
+ const POLL_MS = 3_000;
19
+
20
+ export class EgressGuard {
21
+ /**
22
+ * @param {object} opts
23
+ * @param {string|string[]} opts.pin 'auto' (pin whatever is seen first), or one or more allowed IPs
24
+ */
25
+ constructor({ pin, checkUrl = DEFAULT_CHECK_URL, ttlMs = DEFAULT_TTL_MS, holdMs = DEFAULT_HOLD_MS,
26
+ fetchImpl = fetch, pollMs = POLL_MS, log = () => {} } = {}) {
27
+ this.pin = pin || null;
28
+ this.checkUrl = checkUrl;
29
+ this.ttlMs = ttlMs;
30
+ this.holdMs = holdMs;
31
+ this.pollMs = pollMs;
32
+ this._fetch = fetchImpl;
33
+ this.log = log;
34
+ this._ip = null; // last observed exit IP
35
+ this._checkedAt = 0; // when it was observed
36
+ this._auto = null; // the address 'auto' latched onto
37
+ this._inFlight = null; // coalesces concurrent probes
38
+ }
39
+
40
+ enabled() { return !!this.pin; }
41
+
42
+ /** The addresses considered correct, once known. */
43
+ allowed() {
44
+ if (this.pin === 'auto') return this._auto ? [this._auto] : [];
45
+ return Array.isArray(this.pin) ? this.pin : [this.pin];
46
+ }
47
+
48
+ /**
49
+ * Current exit IP, cached for ttlMs. Returns null when the probe fails — the
50
+ * caller treats that as "unknown", never as "wrong": a probe that cannot
51
+ * complete is usually the same outage that is about to fail the request
52
+ * anyway, and blocking on it would turn a third-party hiccup into an outage
53
+ * of our own.
54
+ */
55
+ async currentIp({ force = false } = {}) {
56
+ if (!force && this._ip && Date.now() - this._checkedAt < this.ttlMs) return this._ip;
57
+ if (this._inFlight) return this._inFlight;
58
+
59
+ this._inFlight = (async () => {
60
+ try {
61
+ const res = await this._fetch(this.checkUrl, { signal: AbortSignal.timeout(5_000) });
62
+ const ip = (await res.text()).trim();
63
+ if (!ip) return null;
64
+ this._ip = ip;
65
+ this._checkedAt = Date.now();
66
+ // 'auto' pins the first address it sees. The server starts with the
67
+ // tunnel up in the normal case, so that address is the one to hold for.
68
+ if (this.pin === 'auto' && !this._auto) {
69
+ this._auto = ip;
70
+ this.log(`[TeamClaude] Egress pinned to ${ip}`);
71
+ }
72
+ return ip;
73
+ } catch {
74
+ return null;
75
+ } finally {
76
+ this._inFlight = null;
77
+ }
78
+ })();
79
+
80
+ return this._inFlight;
81
+ }
82
+
83
+ /** Is `ip` one of the pinned addresses? Unknown (null) counts as allowed. */
84
+ matches(ip) {
85
+ if (!ip) return true;
86
+ const allowed = this.allowed();
87
+ return allowed.length === 0 || allowed.includes(ip);
88
+ }
89
+
90
+ /** One check against the pin: { ok, ip, expected }. */
91
+ async check(opts) {
92
+ const ip = await this.currentIp(opts);
93
+ return { ok: this.matches(ip), ip, expected: this.allowed() };
94
+ }
95
+
96
+ /**
97
+ * Block until the exit IP is the pinned one again, or the hold budget runs
98
+ * out. Returns { ok, ip, expected, waitedMs } — ok:false means the caller
99
+ * should refuse the request rather than send it from the wrong address.
100
+ */
101
+ async waitUntilPinned({ isAborted = () => false } = {}) {
102
+ const started = Date.now();
103
+ let state = await this.check();
104
+ if (state.ok) return { ...state, waitedMs: 0 };
105
+
106
+ this.log(`[TeamClaude] Egress is ${state.ip}, not the pinned ${state.expected.join(', ')} — holding requests`);
107
+ while (Date.now() - started < this.holdMs) {
108
+ if (isAborted()) return { ...state, waitedMs: Date.now() - started };
109
+ await new Promise(resolve => setTimeout(resolve, this.pollMs));
110
+ state = await this.check({ force: true });
111
+ if (state.ok) {
112
+ const waitedMs = Date.now() - started;
113
+ this.log(`[TeamClaude] Egress back on ${state.ip} after ${Math.round(waitedMs / 1000)}s`);
114
+ return { ...state, waitedMs };
115
+ }
116
+ }
117
+ return { ...state, waitedMs: Date.now() - started };
118
+ }
119
+ }
120
+
121
+ /** Build a guard from config, or null when the feature is not configured. */
122
+ export function createEgressGuard(config, log) {
123
+ const cfg = config?.egress;
124
+ if (!cfg?.pin) return null;
125
+ return new EgressGuard({
126
+ pin: cfg.pin,
127
+ checkUrl: cfg.checkUrl,
128
+ ttlMs: cfg.ttlSeconds != null ? cfg.ttlSeconds * 1000 : undefined,
129
+ holdMs: cfg.holdSeconds != null ? cfg.holdSeconds * 1000 : undefined,
130
+ log,
131
+ });
132
+ }