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.
- package/.env.example +7 -4
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +21 -1
- package/README.md +13 -9
- package/SECURITY.md +25 -0
- package/SUPPORT.md +18 -0
- package/bin/autorouter.mjs +2 -0
- package/docs/reference.md +43 -9
- package/docs/releasing.md +7 -3
- package/docs/subscription-integration.md +27 -0
- package/package.json +10 -2
- package/src/cli-help.mjs +10 -7
- package/src/config-command.mjs +30 -9
- package/src/config.mjs +2 -2
- package/src/keychain.mjs +58 -0
- package/src/ollama-models.mjs +3 -0
- package/src/onboarding.mjs +57 -9
- package/src/policy.mjs +116 -0
- package/src/prompt-state.mjs +22 -7
- package/src/redaction.mjs +97 -0
- package/src/router.mjs +1 -1
- package/src/server.mjs +3 -1
- package/src/session-history.mjs +2 -1
- package/src/status-cleanup.mjs +65 -0
- package/src/telemetry-event.mjs +4 -0
- package/src/user-config.mjs +80 -6
|
@@ -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
|
+
}
|
package/src/telemetry-event.mjs
CHANGED
|
@@ -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;
|
package/src/user-config.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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') {
|