claude-autorouter 0.4.0 → 0.5.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,65 @@
1
+ import * as fileSystem from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ // A launcher that is killed hard (SIGKILL, power loss) cannot delete its
6
+ // private status directory, which also holds the rewritten Claude settings.
7
+ // The next launch removes directories whose owning process is gone. Only
8
+ // directories this user created, with owner-only permissions, are considered.
9
+ const PREFIX = 'autorouter-status-';
10
+ const MAX_ENTRIES = 200;
11
+ const SNAPSHOT_LIMIT = 1024 * 1024;
12
+ // Without a readable snapshot, wait before treating a directory as abandoned so
13
+ // a launch that is still starting is never removed.
14
+ const UNREADABLE_GRACE_MS = 10 * 60 * 1000;
15
+ // A reused process ID can look alive. The heartbeat is written every few
16
+ // seconds, so a snapshot this old belongs to an earlier process even if a
17
+ // sleeping machine suspended the real one for a while.
18
+ const HEARTBEAT_STALE_MS = 24 * 60 * 60 * 1000;
19
+
20
+ const defaultAlive = pid => {
21
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
22
+ };
23
+
24
+ async function readSnapshot(io, path) {
25
+ let handle;
26
+ try {
27
+ handle = await io.open(path, 'r');
28
+ const stat = await handle.stat();
29
+ if (!stat.isFile() || stat.size > SNAPSHOT_LIMIT) return undefined;
30
+ return JSON.parse(await handle.readFile('utf8'));
31
+ } catch { return undefined; }
32
+ finally { try { await handle?.close(); } catch {} }
33
+ }
34
+
35
+ /**
36
+ * Best effort and never throws. Returns the number of directories removed.
37
+ * @param {{directory?:string,io?:typeof fileSystem,alive?:(pid:number)=>boolean,now?:number,uid?:number}} [options]
38
+ */
39
+ export async function removeStaleStatusDirectories({
40
+ directory = tmpdir(), io = fileSystem, alive = defaultAlive, now = Date.now(), uid = process.getuid?.(),
41
+ } = {}) {
42
+ let removed = 0;
43
+ try {
44
+ if (uid === undefined) return 0;
45
+ const names = (await io.readdir(directory)).filter(name => name.startsWith(PREFIX)).slice(0, MAX_ENTRIES);
46
+ for (const name of names) {
47
+ try {
48
+ const path = join(directory, name);
49
+ const stat = await io.lstat(path);
50
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== uid || (stat.mode & 0o077) !== 0) continue;
51
+ const snapshot = await readSnapshot(io, join(path, 'state.json'));
52
+ const pid = snapshot?.pid;
53
+ let stale;
54
+ if (Number.isSafeInteger(pid) && pid > 0) {
55
+ const heartbeat = Number.isFinite(snapshot.heartbeat_at) ? snapshot.heartbeat_at : 0;
56
+ stale = !alive(pid) || now - heartbeat > HEARTBEAT_STALE_MS;
57
+ } else stale = now - stat.mtimeMs > UNREADABLE_GRACE_MS;
58
+ if (!stale) continue;
59
+ await io.rm(path, { recursive: true, force: true });
60
+ removed++;
61
+ } catch {}
62
+ }
63
+ } catch {}
64
+ return removed;
65
+ }
@@ -1,4 +1,5 @@
1
1
  // @ts-check
2
+ import { redactSensitive } from './redaction.mjs';
2
3
  // Shared, bounded telemetry contract. Never copy request bodies, headers, raw
3
4
  // errors or arbitrary provider fields into status snapshots or saved history.
4
5
  export const TELEMETRY_SCHEMA_VERSION = 2;
@@ -161,6 +162,9 @@ export function normalizeTelemetryEvent(entry) {
161
162
  function excerpt(value) {
162
163
  let text = '', length = 0;
163
164
  if (typeof value !== 'string') return { text, truncated: false };
165
+ // Defense in depth, and it also covers records written before redaction
166
+ // existed when they are read back. Already-redacted text is unchanged.
167
+ value = redactSensitive(value);
164
168
  for (const character of value) {
165
169
  if (length++ === 500) return { text: text.toWellFormed(), truncated: true };
166
170
  text += character;
@@ -5,9 +5,11 @@ import {
5
5
  import { createHash, randomBytes } from 'node:crypto';
6
6
  import { homedir } from 'node:os';
7
7
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
8
+ import { createKeychain } from './keychain.mjs';
9
+ import { applyPolicy, loadPolicy } from './policy.mjs';
8
10
 
9
11
  export const CONFIG_KEYS = Object.freeze([
10
- 'AUTOROUTER_AUTH_MODE', 'AUTOROUTER_CLIENT_PROFILE',
12
+ 'AUTOROUTER_AUTH_MODE', 'AUTOROUTER_CLIENT_PROFILE', 'AUTOROUTER_SECRET_STORE',
11
13
  'ANTHROPIC_API_KEY', 'TYPESAFE_API_KEY', 'AUTOROUTER_TOKEN',
12
14
  'AUTOROUTER_UPSTREAM_URL', 'AUTOROUTER_JEV_URL', 'AUTOROUTER_JEV_MODEL',
13
15
  'AUTOROUTER_HAIKU_MODEL', 'AUTOROUTER_SONNET_MODEL', 'AUTOROUTER_OPUS_MODEL',
@@ -19,7 +21,13 @@ export const CONFIG_KEYS = Object.freeze([
19
21
  'AUTOROUTER_OLLAMA_TIMEOUT_MS', 'AUTOROUTER_OLLAMA_KEEP_ALIVE',
20
22
  ]);
21
23
  export const SECRET_CONFIG_KEYS = Object.freeze(['ANTHROPIC_API_KEY', 'TYPESAFE_API_KEY', 'AUTOROUTER_TOKEN']);
24
+ export const SECRET_STORES = Object.freeze(['file', 'keychain']);
22
25
  const allowedKeys = new Set(CONFIG_KEYS);
26
+ const defaultKeychain = createKeychain();
27
+ // Items are scoped to one configuration file, so separate configurations
28
+ // (including test fixtures) never share or overwrite each other's secrets.
29
+ const keychainAccount = (path, key) => `${key}:${createHash('sha256').update(path).digest('hex').slice(0, 16)}`;
30
+ const keychainLabel = (path, key) => `AutoRouter ${key} (${path})`;
23
31
  const revision = content => createHash('sha256').update(content).digest('hex');
24
32
  const SAFE_FS_CODES = new Set([
25
33
  'EACCES', 'EPERM', 'ENOENT', 'ENOTDIR', 'EISDIR', 'ENOSPC', 'EROFS',
@@ -57,6 +65,9 @@ function validate(values) {
57
65
  }
58
66
  validated[key] = descriptor.value;
59
67
  }
68
+ if (validated.AUTOROUTER_SECRET_STORE !== undefined && !SECRET_STORES.includes(validated.AUTOROUTER_SECRET_STORE)) {
69
+ throw configError('AUTOROUTER_SECRET_STORE must be file or keychain.');
70
+ }
60
71
  return validated;
61
72
  }
62
73
 
@@ -74,14 +85,20 @@ export function getConfigPath(env = process.env) {
74
85
  return join(xdg || join(homedir(), '.config'), 'claude-autorouter', 'config.json');
75
86
  }
76
87
 
77
- export function loadUserConfig(env = process.env, { allowMissing = false } = {}) {
88
+ // The saved store setting decides where saved secrets live; the environment
89
+ // only overrides values. With the keychain store, `values` includes secrets
90
+ // read from the keychain so callers can update settings without losing them.
91
+ function readUserConfig(env, { allowMissing = false, readSecrets = true, keychain = defaultKeychain } = {}) {
78
92
  const path = getConfigPath(env);
79
93
  let content;
80
94
  try {
81
95
  content = readFileSync(path, 'utf8');
82
96
  } catch (error) {
83
97
  if (error.code === 'ENOENT') {
84
- if (env.AUTOROUTER_CONFIG === undefined || allowMissing) return { env: { ...env }, values: {}, path, exists: false, revision: null };
98
+ if (env.AUTOROUTER_CONFIG === undefined || allowMissing) {
99
+ return { env: { ...env }, values: {}, path, exists: false, revision: null,
100
+ secretStore: 'file', keychainSecrets: [], unavailableSecrets: [] };
101
+ }
85
102
  throw configError('AUTOROUTER_CONFIG points to a missing configuration file.');
86
103
  }
87
104
  throw filesystemError(error, 'read');
@@ -90,7 +107,48 @@ export function loadUserConfig(env = process.env, { allowMissing = false } = {})
90
107
  try { parsed = JSON.parse(content); }
91
108
  catch { throw configError('AutoRouter configuration must contain valid JSON.'); }
92
109
  const values = validate(parsed);
93
- return { env: { ...values, ...env }, values, path, exists: true, revision: revision(content) };
110
+ const secretStore = values.AUTOROUTER_SECRET_STORE ?? 'file';
111
+ const keychainSecrets = [], unavailableSecrets = [];
112
+ if (secretStore === 'keychain' && readSecrets) {
113
+ for (const key of SECRET_CONFIG_KEYS) {
114
+ if (Object.hasOwn(values, key)) continue;
115
+ let value;
116
+ try { value = keychain.read(keychainAccount(path, key)); }
117
+ catch (error) {
118
+ // An environment value can stand in for a locked keychain, such as
119
+ // over SSH. Only a missing required value stops the caller.
120
+ if (env[key] !== undefined) { unavailableSecrets.push(key); continue; }
121
+ throw error;
122
+ }
123
+ if (value !== undefined) { values[key] = value; keychainSecrets.push(key); }
124
+ }
125
+ }
126
+ return { env: { ...values, ...env }, values, path, exists: true, revision: revision(content),
127
+ secretStore, keychainSecrets, unavailableSecrets };
128
+ }
129
+
130
+ // An organization policy, when present, is applied last. Its allowlists reject
131
+ // a disallowed choice and its locks replace file and environment values, so
132
+ // every consumer of the effective environment sees the enforced settings.
133
+ // `policy` options exist for tests; production always uses the system path.
134
+ export function loadUserConfig(env = process.env, { policy: policyOptions, enforcePolicy = true, ...options } = {}) {
135
+ const policy = loadPolicy(policyOptions);
136
+ const loaded = readUserConfig(env, options);
137
+ if (!policy) return loaded;
138
+ const applied = applyPolicy(loaded.env, policy.values, { allowlists: enforcePolicy });
139
+ return { ...loaded, env: applied.env, policy: policy.values, policyPath: policy.path, policyLocked: applied.locked };
140
+ }
141
+
142
+ // Keychain items to delete when saving `nextStore`, given what was loaded.
143
+ // Moving between stores needs every saved secret, so refuse while one could
144
+ // not be read; deleting it unseen would lose it.
145
+ export function keychainRemovals(loaded, nextStore, { replace = false, values = {} } = {}) {
146
+ if (loaded.secretStore !== 'keychain') return [];
147
+ if (nextStore !== 'keychain' && loaded.unavailableSecrets?.length) {
148
+ throw configError('Could not read every saved secret from the macOS Keychain. Unlock the login keychain and retry.');
149
+ }
150
+ if (nextStore !== 'keychain') return [...loaded.keychainSecrets];
151
+ return replace ? loaded.keychainSecrets.filter(key => !Object.hasOwn(values, key)) : [];
94
152
  }
95
153
 
96
154
  function existingFile(path) {
@@ -102,8 +160,17 @@ function existingFile(path) {
102
160
  return stat;
103
161
  }
104
162
 
105
- export function saveUserConfig(values, { env = process.env, overwrite = false, expectedRevision } = {}) {
163
+ // With the keychain store, secrets are written to the keychain before the file
164
+ // (an interrupted move leaves both copies, never neither) and stale items are
165
+ // removed only after the file is saved.
166
+ export function saveUserConfig(values, {
167
+ env = process.env, overwrite = false, expectedRevision, removeSecrets = [], keychain = defaultKeychain,
168
+ } = {}) {
106
169
  const validated = validate(values);
170
+ const store = validated.AUTOROUTER_SECRET_STORE ?? 'file';
171
+ const keychainKeys = store === 'keychain' ? SECRET_CONFIG_KEYS.filter(key => Object.hasOwn(validated, key)) : [];
172
+ const fileValues = { ...validated };
173
+ for (const key of keychainKeys) delete fileValues[key];
107
174
  const path = getConfigPath(env);
108
175
  const parent = dirname(path);
109
176
  let temporary;
@@ -118,13 +185,17 @@ export function saveUserConfig(values, { env = process.env, overwrite = false, e
118
185
  throw configError('AutoRouter configuration already exists; use overwrite to replace it.');
119
186
  }
120
187
  checkRevision();
188
+ if (store === 'keychain' && !keychain.available) {
189
+ throw configError('The macOS Keychain secret store is available only on macOS.');
190
+ }
191
+ for (const key of keychainKeys) keychain.write(keychainAccount(path, key), validated[key], keychainLabel(path, key));
121
192
  // mkdir leaves existing directory permissions unchanged. Only directories
122
193
  // created for this configuration receive the private creation mode.
123
194
  mkdirSync(parent, { recursive: true, mode: 0o700 });
124
195
  temporary = join(parent, `.${basename(path)}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`);
125
196
  descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW ?? 0), 0o600);
126
197
  fchmodSync(descriptor, 0o600);
127
- writeFileSync(descriptor, `${JSON.stringify(validated, null, 2)}\n`, 'utf8');
198
+ writeFileSync(descriptor, `${JSON.stringify(fileValues, null, 2)}\n`, 'utf8');
128
199
  fsyncSync(descriptor);
129
200
  closeSync(descriptor);
130
201
  descriptor = undefined;
@@ -143,6 +214,9 @@ export function saveUserConfig(values, { env = process.env, overwrite = false, e
143
214
  // symlink can never be replaced by the default save operation.
144
215
  linkSync(temporary, path);
145
216
  }
217
+ for (const key of removeSecrets) {
218
+ if (SECRET_CONFIG_KEYS.includes(key) && !keychainKeys.includes(key)) keychain.remove(keychainAccount(path, key));
219
+ }
146
220
  return path;
147
221
  } catch (error) {
148
222
  if (error?.code === 'EEXIST') {