scalemax 0.1.15 → 0.1.17

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 CHANGED
@@ -114,15 +114,15 @@ For clients without a stable official config file, the installer writes a ScaleM
114
114
 
115
115
  ### Claude Code
116
116
 
117
- ScaleMax updates `~/.claude/settings.json` with `ANTHROPIC_BASE_URL=https://api.scalemax.pro`, the stored key as `ANTHROPIC_AUTH_TOKEN`, and gateway model discovery. The Anthropic base URL intentionally has no `/v1` suffix because Claude Code adds `/v1/messages` itself. The CLI preserves unrelated settings, selects a live model from the authenticated catalog (preferring `claude-opus-4-8`), and verifies a one-token Messages request. If verification fails, it removes only the ScaleMax-managed changes. Claude Code picker aliases such as `claude-opus-4-8[1m]` are normalized by the gateway.
117
+ ScaleMax updates `~/.claude/settings.json` with `ANTHROPIC_BASE_URL=https://api.scalemax.pro`, gateway model discovery, and a cached `apiKeyHelper` that reads a private `~/.claude/scalemax.key` file. The raw key is not stored in the settings JSON. The Anthropic base URL intentionally has no `/v1` suffix because Claude Code adds `/v1/messages` itself. The CLI preserves unrelated settings, selects a live main model from the authenticated catalog (preferring `claude-opus-4-8`), pins authenticated Haiku for lightweight background work when available, and sets `CLAUDE_CODE_DISABLE_THINKING=1` because third-party Anthropic-compatible providers do not support Claude Code's proprietary extended-thinking control envelope. It verifies a one-token Messages request and, if verification fails, removes only the ScaleMax-managed changes. Claude Code picker aliases such as `claude-opus-4-8[1m]` are normalized by the gateway.
118
118
 
119
119
  ### OpenCode
120
120
 
121
- ScaleMax merges a named `scalemax` provider into `~/.config/opencode/opencode.json` (or the platform-equivalent config directory). It uses `https://api.scalemax.pro/v1`, keeps unrelated providers and the user's non-ScaleMax default model, and loads every model available to the authenticated key. Each generated model has an explicit output limit of at most 4096 tokens so OpenCode does not send an unsupported default limit.
121
+ ScaleMax merges a named `scalemax` provider into `~/.config/opencode/opencode.json` (or the platform-equivalent config directory). It uses `https://api.scalemax.pro/v1`, reads its credential from the private `~/.config/opencode/scalemax.key` file through OpenCode's `{file:...}` substitution, keeps unrelated providers and the user's non-ScaleMax default model, and loads every model available to the authenticated key. A fresh ScaleMax setup uses authenticated Haiku as `small_model` for quicker lightweight work. Each generated model has an explicit output limit of at most 4096 tokens so OpenCode does not send an unsupported default limit.
122
122
 
123
123
  ### OpenClaw
124
124
 
125
- ScaleMax merges a native `anthropic-messages` provider into `~/.openclaw/openclaw.json`, using the Anthropic origin `https://api.scalemax.pro`. It adds every authenticated model to the provider and agent allowlist, caps generated output limits at 4096 tokens, and preserves unrelated providers and agent settings. Legacy ScaleMax artifacts are removed only when they carry the ScaleMax managed marker.
125
+ ScaleMax merges a native `anthropic-messages` provider into `~/.openclaw/openclaw.json`, using the Anthropic origin `https://api.scalemax.pro`. The API key and the compatibility User-Agent are both OpenClaw file-backed SecretRefs stored in private `0600` files under `~/.openclaw/secrets/`; neither value is embedded in the provider JSON. The migration removes exact plaintext ScaleMax key copies from generated agent model registries. It adds every authenticated model to the provider and agent allowlist, uses authenticated Haiku for utility work when ScaleMax is the active provider, caps generated output limits at 4096 tokens, explicitly denies private-network routing for the public gateway, and preserves unrelated providers and agent settings. Legacy ScaleMax artifacts are removed only when they carry the ScaleMax managed marker.
126
126
 
127
127
  ### Hermes
128
128
 
@@ -153,6 +153,7 @@ npx scalemax models
153
153
  ```
154
154
 
155
155
  The model list is fetched live from `https://api.scalemax.pro/v1/models` every time, so disabled or unsupported models disappear immediately without manual cache cleanup.
156
+ Key metadata and the authenticated model catalog are fetched concurrently on the normal path. Safe GET checks receive one short, jittered retry on transient network, rate-limit, or server failures; model-generation POST requests are never retried by the installer.
156
157
 
157
158
  Run diagnostics:
158
159
 
@@ -274,7 +275,11 @@ Never publish over an existing npm version. Use semantic versioning for every re
274
275
 
275
276
  - Existing client configuration files are backed up before writes.
276
277
  - Writes are atomic.
278
+ - Identical configurations are not rewritten or backed up again.
279
+ - Invalid JSON/JSON5 client files fail closed instead of being replaced; valid JSON5 values are merged safely.
280
+ - Backup directories are user-only and backup files are written with private permissions.
277
281
  - API keys are masked in terminal output.
282
+ - Claude Code, OpenCode, OpenClaw, and Hermes keep generated client credentials in private files rather than shared provider configuration blocks.
278
283
  - The published npm package contains only runtime files: `dist/`, `README.md`, `LICENSE`, and `package.json`.
279
284
  - Tests, TypeScript source, scripts, local caches, `.env` files, tarballs, and `node_modules` are excluded from the published package.
280
285
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
  function cmp(a,b){a=String(a).replace(/^v/,'').split('.').map(Number);b=String(b).split('.').map(Number);for(var i=0;i<3;i++){var d=(a[i]||0)-(b[i]||0);if(d)return d;}return 0;}
4
- var min='18.17.0';
4
+ var min="18.19.0";
5
5
  if (cmp(process.versions.node,min)<0) {
6
6
  console.error('ScaleMax requires Node.js >= '+min+'. Current: '+process.versions.node);
7
7
  console.error('Install Node.js LTS, then rerun: npx scalemax@latest');
@@ -1,57 +1,101 @@
1
1
  import fs from 'fs-extra';
2
2
  import crypto from 'node:crypto';
3
+ import path from 'node:path';
3
4
  import { atomicWrite, backupFile } from '../utils/fs.js';
4
5
  import { clientConfigPath } from './paths.js';
5
6
  import { writeMergedJsonConfig } from './common.js';
6
7
  import { GENERATED_HEADER } from '../constants.js';
7
- import { anthropicOrigin, modelIds, preferredModel } from './models.js';
8
+ import { redactApiKey } from '../utils/apiKey.js';
9
+ import { anthropicOrigin, fastModel, modelIds, preferredModel } from './models.js';
10
+ import { removeClientSecret, rollbackClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
8
11
  const id = 'claude';
9
12
  const name = 'Claude Code';
10
13
  const discoveryKey = 'CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY';
11
- const managedEnvKeys = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', discoveryKey];
14
+ const fastModelKey = 'ANTHROPIC_DEFAULT_HAIKU_MODEL';
15
+ const helperTtlKey = 'CLAUDE_CODE_API_KEY_HELPER_TTL_MS';
16
+ const disableThinkingKey = 'CLAUDE_CODE_DISABLE_THINKING';
17
+ const managedEnvKeys = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL', discoveryKey, fastModelKey, helperTtlKey, disableThinkingKey];
18
+ function secretPath(home) { return path.join(home, '.claude', 'scalemax.key'); }
19
+ function helperCommand(home, platform) {
20
+ const file = secretPath(home);
21
+ if (platform === 'win32')
22
+ return `powershell -NoProfile -Command "Get-Content -Raw -LiteralPath '${file.replace(/'/g, "''")}'"`;
23
+ return `cat '${file.replace(/'/g, `'\\''`)}'`;
24
+ }
12
25
  function asRecord(value) {
13
26
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
14
27
  }
15
28
  export async function configure(ctx) {
16
29
  const file = clientConfigPath(id, ctx.home, ctx.platform);
17
30
  const model = preferredModel(ctx);
31
+ const quick = fastModel(ctx);
18
32
  const baseUrl = anthropicOrigin(ctx.baseUrl);
19
- const result = await writeMergedJsonConfig(ctx, id, name, file, existing => {
20
- const env = asRecord(existing.env);
21
- const oldMetadata = asRecord(existing.scalemax);
22
- const previousEnv = oldMetadata.managedBy === 'ScaleMax CLI'
23
- ? asRecord(oldMetadata.previousEnv)
24
- : Object.fromEntries(managedEnvKeys.filter(key => Object.prototype.hasOwnProperty.call(env, key)).map(key => [key, env[key]]));
25
- return JSON.stringify({
26
- ...existing,
27
- env: {
33
+ const apiKeyHelper = helperCommand(ctx.home, ctx.platform);
34
+ const secret = await writeClientSecret(secretPath(ctx.home), ctx);
35
+ let result;
36
+ try {
37
+ result = await writeMergedJsonConfig(ctx, id, name, file, existing => {
38
+ const env = asRecord(existing.env);
39
+ const oldMetadata = asRecord(existing.scalemax);
40
+ const previousEnv = oldMetadata.managedBy === 'ScaleMax CLI'
41
+ ? asRecord(oldMetadata.previousEnv)
42
+ : Object.fromEntries(managedEnvKeys.filter(key => Object.prototype.hasOwnProperty.call(env, key)).map(key => [key, env[key]]));
43
+ const previousApiKeyHelper = oldMetadata.managedBy === 'ScaleMax CLI'
44
+ ? oldMetadata.previousApiKeyHelper ?? null
45
+ : Object.prototype.hasOwnProperty.call(existing, 'apiKeyHelper') ? existing.apiKeyHelper : null;
46
+ const nextEnv = {
28
47
  ...env,
29
- ANTHROPIC_AUTH_TOKEN: ctx.apiKey,
30
48
  ANTHROPIC_BASE_URL: baseUrl,
31
49
  ANTHROPIC_MODEL: model,
32
- [discoveryKey]: '1'
33
- },
34
- 'scalemax.managedBy': GENERATED_HEADER,
35
- scalemax: {
36
- managedBy: 'ScaleMax CLI',
37
- anthropicModel: model,
38
- anthropicBaseUrl: baseUrl,
39
- gatewayDiscovery: '1',
40
- authTokenSha256: crypto.createHash('sha256').update(ctx.apiKey).digest('hex'),
41
- previousEnv
42
- }
43
- }, null, 2) + '\n';
44
- });
45
- return { ...result, message: `configured Anthropic Messages routing with ${model} and gateway model discovery` };
50
+ [discoveryKey]: '1',
51
+ [helperTtlKey]: '900000',
52
+ [disableThinkingKey]: '1'
53
+ };
54
+ delete nextEnv.ANTHROPIC_AUTH_TOKEN;
55
+ delete nextEnv.ANTHROPIC_API_KEY;
56
+ if (quick)
57
+ nextEnv[fastModelKey] = quick;
58
+ else
59
+ delete nextEnv[fastModelKey];
60
+ return JSON.stringify({
61
+ ...existing,
62
+ apiKeyHelper,
63
+ env: nextEnv,
64
+ 'scalemax.managedBy': GENERATED_HEADER,
65
+ scalemax: {
66
+ managedBy: 'ScaleMax CLI',
67
+ anthropicModel: model,
68
+ anthropicBaseUrl: baseUrl,
69
+ gatewayDiscovery: '1',
70
+ fastModel: quick || null,
71
+ helperTtl: '900000',
72
+ disableThinking: '1',
73
+ apiKeyHelper,
74
+ authTokenSha256: crypto.createHash('sha256').update(ctx.apiKey).digest('hex'),
75
+ previousEnv,
76
+ previousApiKeyHelper
77
+ }
78
+ }, null, 2) + '\n';
79
+ });
80
+ }
81
+ catch (error) {
82
+ await rollbackClientSecret(secretPath(ctx.home), ctx, secret);
83
+ throw error;
84
+ }
85
+ return { ...result, changed: result.changed || secret.changed, message: `configured Anthropic Messages routing with ${model}, file-backed authentication, and gateway discovery${quick ? `; ${quick} handles background work` : ''}` };
46
86
  }
47
87
  export async function verify(ctx) {
48
88
  const file = clientConfigPath(id, ctx.home, ctx.platform);
49
89
  const settings = await fs.readJson(file);
50
90
  const env = asRecord(settings?.env);
51
91
  const model = String(env.ANTHROPIC_MODEL || '');
52
- if (!modelIds(ctx).includes(model) || env.ANTHROPIC_BASE_URL !== anthropicOrigin(ctx.baseUrl) || env.ANTHROPIC_AUTH_TOKEN !== ctx.apiKey || env[discoveryKey] !== '1') {
92
+ if (!modelIds(ctx).includes(model) || env.ANTHROPIC_BASE_URL !== anthropicOrigin(ctx.baseUrl) || settings.apiKeyHelper !== helperCommand(ctx.home, ctx.platform) || env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || env[discoveryKey] !== '1' || env[disableThinkingKey] !== '1') {
53
93
  throw new Error('Claude Code settings do not match the authenticated ScaleMax model catalog.');
54
94
  }
95
+ await verifyClientSecret(secretPath(ctx.home), ctx);
96
+ const quick = fastModel(ctx);
97
+ if (quick && env[fastModelKey] !== quick)
98
+ throw new Error('Claude Code background model does not match the fastest authenticated model.');
55
99
  const response = await fetch(`${anthropicOrigin(ctx.baseUrl)}/v1/messages`, {
56
100
  method: 'POST',
57
101
  headers: { Authorization: `Bearer ${ctx.apiKey}`, 'anthropic-version': '2023-06-01', 'content-type': 'application/json' },
@@ -65,14 +109,16 @@ export async function verify(ctx) {
65
109
  message = body?.error?.message || message;
66
110
  }
67
111
  catch { }
68
- throw new Error(`Claude Messages verification failed: ${message}`);
112
+ throw new Error(`Claude Messages verification failed: ${redactApiKey(message, ctx.apiKey)}`);
69
113
  }
70
114
  return { id, name, changed: false, path: file, message: `verified Anthropic Messages with ${model}` };
71
115
  }
72
116
  export async function remove(ctx) {
73
117
  const file = clientConfigPath(id, ctx.home, ctx.platform);
74
- if (!(await fs.pathExists(file)))
75
- return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
118
+ if (!(await fs.pathExists(file))) {
119
+ const removed = await removeClientSecret(secretPath(ctx.home), ctx);
120
+ return { id, name, changed: removed, path: file, message: removed ? 'removed orphaned ScaleMax Claude Code credential' : 'no ScaleMax configuration found' };
121
+ }
76
122
  const settings = await fs.readJson(file).catch(() => undefined);
77
123
  if (!settings || settings?.scalemax?.managedBy !== 'ScaleMax CLI')
78
124
  return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
@@ -91,8 +137,24 @@ export async function remove(ctx) {
91
137
  restore('ANTHROPIC_MODEL');
92
138
  if (env[discoveryKey] === managed.gatewayDiscovery)
93
139
  restore(discoveryKey);
140
+ if (env[fastModelKey] === managed.fastModel)
141
+ restore(fastModelKey);
142
+ if (env[helperTtlKey] === managed.helperTtl)
143
+ restore(helperTtlKey);
144
+ if (env[disableThinkingKey] === managed.disableThinking)
145
+ restore(disableThinkingKey);
146
+ if (!Object.prototype.hasOwnProperty.call(env, 'ANTHROPIC_AUTH_TOKEN'))
147
+ restore('ANTHROPIC_AUTH_TOKEN');
148
+ if (!Object.prototype.hasOwnProperty.call(env, 'ANTHROPIC_API_KEY'))
149
+ restore('ANTHROPIC_API_KEY');
94
150
  if (typeof env.ANTHROPIC_AUTH_TOKEN === 'string' && crypto.createHash('sha256').update(env.ANTHROPIC_AUTH_TOKEN).digest('hex') === managed.authTokenSha256)
95
151
  restore('ANTHROPIC_AUTH_TOKEN');
152
+ if (settings.apiKeyHelper === managed.apiKeyHelper) {
153
+ if (managed.previousApiKeyHelper === null || managed.previousApiKeyHelper === undefined)
154
+ delete settings.apiKeyHelper;
155
+ else
156
+ settings.apiKeyHelper = managed.previousApiKeyHelper;
157
+ }
96
158
  if (Object.keys(env).length)
97
159
  settings.env = env;
98
160
  else
@@ -104,5 +166,6 @@ export async function remove(ctx) {
104
166
  await atomicWrite(file, JSON.stringify(settings, null, 2) + '\n', 0o600, ctx.platform);
105
167
  else
106
168
  await fs.remove(file);
107
- return { id, name, changed: true, path: file, message: 'removed only ScaleMax Claude Code settings' };
169
+ await removeClientSecret(secretPath(ctx.home), ctx);
170
+ return { id, name, changed: true, path: file, message: 'removed only ScaleMax Claude Code settings and credential' };
108
171
  }
@@ -0,0 +1,11 @@
1
+ import { ConfigureContext } from '../types/client.js';
2
+ export declare function writeClientSecret(file: string, ctx: ConfigureContext): Promise<{
3
+ changed: boolean;
4
+ backup?: string;
5
+ }>;
6
+ export declare function rollbackClientSecret(file: string, ctx: ConfigureContext, change: {
7
+ changed: boolean;
8
+ backup?: string;
9
+ }): Promise<void>;
10
+ export declare function verifyClientSecret(file: string, ctx: ConfigureContext): Promise<void>;
11
+ export declare function removeClientSecret(file: string, ctx: ConfigureContext): Promise<boolean>;
@@ -0,0 +1,47 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { sanitizeApiKey } from '../utils/apiKey.js';
4
+ import { atomicWrite, backupFile } from '../utils/fs.js';
5
+ export async function writeClientSecret(file, ctx) {
6
+ const content = `${sanitizeApiKey(ctx.apiKey)}\n`;
7
+ const current = await fs.readFile(file, 'utf8').catch(() => '');
8
+ await fs.ensureDir(path.dirname(file), 0o700);
9
+ await fs.chmod(path.dirname(file), 0o700).catch(() => undefined);
10
+ if (current === content) {
11
+ await fs.chmod(file, 0o600).catch(() => undefined);
12
+ return { changed: false };
13
+ }
14
+ const backup = await backupFile(file, ctx.home);
15
+ await atomicWrite(file, content, 0o600, ctx.platform);
16
+ return { changed: true, backup };
17
+ }
18
+ export async function rollbackClientSecret(file, ctx, change) {
19
+ if (!change.changed)
20
+ return;
21
+ if (change.backup) {
22
+ const previous = await fs.readFile(change.backup, 'utf8');
23
+ await atomicWrite(file, previous, 0o600, ctx.platform);
24
+ return;
25
+ }
26
+ await fs.remove(file).catch(() => undefined);
27
+ }
28
+ export async function verifyClientSecret(file, ctx) {
29
+ const content = await fs.readFile(file, 'utf8').catch(() => '');
30
+ if (sanitizeApiKey(content) !== sanitizeApiKey(ctx.apiKey))
31
+ throw new Error('ScaleMax client credential is missing or does not match the authenticated key.');
32
+ if (ctx.platform !== 'win32') {
33
+ const mode = (await fs.stat(file)).mode & 0o777;
34
+ if (mode !== 0o600)
35
+ throw new Error(`ScaleMax client credential permissions are ${mode.toString(8)}; expected 600.`);
36
+ }
37
+ }
38
+ export async function removeClientSecret(file, ctx) {
39
+ if (!(await fs.pathExists(file)))
40
+ return false;
41
+ const content = await fs.readFile(file, 'utf8').catch(() => '');
42
+ if (sanitizeApiKey(content) !== sanitizeApiKey(ctx.apiKey))
43
+ return false;
44
+ await backupFile(file, ctx.home);
45
+ await fs.remove(file);
46
+ return true;
47
+ }
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'fs-extra';
3
- import { atomicWrite, backupFile, listBackups, readJson } from '../utils/fs.js';
3
+ import JSON5 from 'json5';
4
+ import { atomicWrite, backupFile, listBackups } from '../utils/fs.js';
4
5
  import { GENERATED_HEADER } from '../constants.js';
5
6
  export function isScaleMaxManagedText(text) {
6
7
  return text.includes(GENERATED_HEADER) || text.includes('"scalemax.managedBy"') || text.includes('"managedBy": "ScaleMax CLI generated file"');
@@ -11,6 +12,10 @@ export async function writeManagedConfig(ctx, id, name, file, content) {
11
12
  const current = await fs.readFile(file, 'utf8');
12
13
  if (!isScaleMaxManagedText(current))
13
14
  throw new Error(`Refusing to overwrite unmanaged configuration: ${file}`);
15
+ if (current === content) {
16
+ await fs.chmod(file, 0o600).catch(() => undefined);
17
+ return { id, name, changed: false, path: file, message: 'already current' };
18
+ }
14
19
  }
15
20
  const backup = await backupFile(file, ctx.home);
16
21
  await atomicWrite(file, content, 0o600, ctx.platform);
@@ -18,9 +23,30 @@ export async function writeManagedConfig(ctx, id, name, file, content) {
18
23
  }
19
24
  export async function writeMergedJsonConfig(ctx, id, name, file, build) {
20
25
  await fs.ensureDir(path.dirname(file));
21
- const existing = await readJson(file, {});
26
+ const current = await fs.readFile(file, 'utf8').catch((error) => {
27
+ if (error.code === 'ENOENT')
28
+ return '';
29
+ throw error;
30
+ });
31
+ let existing = {};
32
+ if (current) {
33
+ try {
34
+ const parsed = JSON5.parse(current);
35
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
36
+ throw new Error('the root value must be an object');
37
+ existing = parsed;
38
+ }
39
+ catch (error) {
40
+ throw new Error(`Refusing to overwrite invalid JSON/JSON5 configuration at ${file}: ${error instanceof Error ? error.message : String(error)}`);
41
+ }
42
+ }
43
+ const content = build(existing);
44
+ if (current === content) {
45
+ await fs.chmod(file, 0o600).catch(() => undefined);
46
+ return { id, name, changed: false, path: file, message: 'already current' };
47
+ }
22
48
  const backup = await backupFile(file, ctx.home);
23
- await atomicWrite(file, build(existing), 0o600, ctx.platform);
49
+ await atomicWrite(file, content, 0o600, ctx.platform);
24
50
  return { id, name, changed: true, path: file, message: backup ? `configured (backup: ${backup})` : 'configured' };
25
51
  }
26
52
  async function latestRestorableBackup(file, home) {
@@ -55,7 +55,8 @@ async function removeLegacyArtifact(ctx) {
55
55
  export async function configure(ctx) {
56
56
  const file = clientConfigPath(id, ctx.home, ctx.platform);
57
57
  const secrets = envPath(ctx.home);
58
- await fs.ensureDir(path.dirname(file));
58
+ await fs.ensureDir(path.dirname(file), 0o700);
59
+ await fs.chmod(path.dirname(file), 0o700).catch(() => undefined);
59
60
  const originalConfig = await fs.readFile(file, 'utf8').catch(() => '');
60
61
  const originalEnv = await fs.readFile(secrets, 'utf8').catch(() => '');
61
62
  const doc = parseYaml(originalConfig);
@@ -76,10 +77,18 @@ export async function configure(ctx) {
76
77
  if (activatesScaleMax) {
77
78
  doc.set('model', { ...currentModel, provider: 'custom:scalemax', default: preferredModel(ctx), max_tokens: 4096 });
78
79
  }
80
+ const nextConfig = String(doc);
81
+ const nextEnv = managedEnv(originalEnv, ctx.apiKey);
82
+ if (originalConfig === nextConfig && originalEnv === nextEnv) {
83
+ await fs.chmod(file, 0o600).catch(() => undefined);
84
+ await fs.chmod(secrets, 0o600).catch(() => undefined);
85
+ await removeLegacyArtifact(ctx);
86
+ return { id, name, changed: false, path: file, message: `already current with ${modelIds(ctx).length} models and a private credential` };
87
+ }
79
88
  const backups = [await backupFile(file, ctx.home), await backupFile(secrets, ctx.home)].filter(Boolean);
80
89
  try {
81
- await atomicWrite(file, String(doc), 0o600, ctx.platform);
82
- await atomicWrite(secrets, managedEnv(originalEnv, ctx.apiKey), 0o600, ctx.platform);
90
+ await atomicWrite(file, nextConfig, 0o600, ctx.platform);
91
+ await atomicWrite(secrets, nextEnv, 0o600, ctx.platform);
83
92
  await removeLegacyArtifact(ctx);
84
93
  }
85
94
  catch (error) {
@@ -1,6 +1,7 @@
1
1
  import { ConfigureContext } from '../types/client.js';
2
2
  export declare function modelIds(ctx: ConfigureContext): string[];
3
3
  export declare function preferredModel(ctx: ConfigureContext): string;
4
+ export declare function fastModel(ctx: ConfigureContext): string | undefined;
4
5
  export declare function modelName(ctx: ConfigureContext, id: string): string;
5
6
  export declare function anthropicOrigin(baseUrl: string): string;
6
7
  export declare function openAiBaseUrl(baseUrl: string): string;
@@ -19,6 +19,11 @@ export function preferredModel(ctx) {
19
19
  return ids[0];
20
20
  throw new Error('No compatible models are available for this API key; the client was not changed.');
21
21
  }
22
+ export function fastModel(ctx) {
23
+ const ids = modelIds(ctx);
24
+ return ids.find(id => id === 'claude-haiku-4-5-20251001')
25
+ || ids.find(id => /(^|-)haiku([-.]|$)/i.test(id));
26
+ }
22
27
  export function modelName(ctx, id) {
23
28
  const model = (ctx.models || []).find(candidate => candidate.id === id);
24
29
  if (model?.display_name)
@@ -3,9 +3,20 @@ import fs from 'fs-extra';
3
3
  import { atomicWrite, backupFile } from '../utils/fs.js';
4
4
  import { clientConfigPath } from './paths.js';
5
5
  import { isScaleMaxManagedText, writeMergedJsonConfig } from './common.js';
6
- import { anthropicOrigin, modelIds, modelName, preferredModel } from './models.js';
6
+ import { anthropicOrigin, fastModel, modelIds, modelName, preferredModel } from './models.js';
7
+ import { removeClientSecret, rollbackClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
7
8
  const id = 'openclaw';
8
9
  const name = 'OpenClaw';
10
+ const secretProviderId = 'scalemax_cli';
11
+ const secretConfigPath = '~/.openclaw/secrets/scalemax-api-key';
12
+ const secretReference = { source: 'file', provider: secretProviderId, id: 'value' };
13
+ const userAgentProviderId = 'scalemax_cli_user_agent';
14
+ const userAgentConfigPath = '~/.openclaw/secrets/scalemax-user-agent';
15
+ const userAgentValue = 'ScaleMax-OpenClaw/1.0';
16
+ const userAgentReference = { source: 'file', provider: userAgentProviderId, id: 'value' };
17
+ function secretPath(home) { return path.join(home, '.openclaw', 'secrets', 'scalemax-api-key'); }
18
+ function userAgentPath(home) { return path.join(home, '.openclaw', 'secrets', 'scalemax-user-agent'); }
19
+ function userAgentContext(ctx) { return { ...ctx, apiKey: userAgentValue }; }
9
20
  function asRecord(value) {
10
21
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
11
22
  }
@@ -33,54 +44,125 @@ async function removeLegacyArtifact(ctx) {
33
44
  await backupFile(legacy, ctx.home);
34
45
  await fs.remove(legacy);
35
46
  }
47
+ async function scrubGeneratedPlaintextCredentials(ctx) {
48
+ const agentsDir = path.join(ctx.home, '.openclaw', 'agents');
49
+ const agents = await fs.readdir(agentsDir, { withFileTypes: true }).catch(() => []);
50
+ let changed = 0;
51
+ for (const agent of agents) {
52
+ if (!agent.isDirectory())
53
+ continue;
54
+ const file = path.join(agentsDir, agent.name, 'agent', 'models.json');
55
+ if (!(await fs.pathExists(file)))
56
+ continue;
57
+ const original = await fs.readFile(file, 'utf8').catch(() => '');
58
+ let data;
59
+ try {
60
+ data = JSON.parse(original);
61
+ }
62
+ catch {
63
+ continue;
64
+ }
65
+ const roots = [asRecord(data.providers), asRecord(asRecord(data.models).providers)];
66
+ let scrubbed = false;
67
+ for (const providers of roots) {
68
+ const provider = asRecord(providers.scalemax);
69
+ if (provider.baseUrl === anthropicOrigin(ctx.baseUrl) && provider.apiKey === ctx.apiKey) {
70
+ delete provider.apiKey;
71
+ scrubbed = true;
72
+ }
73
+ }
74
+ if (!scrubbed)
75
+ continue;
76
+ await backupFile(file, ctx.home);
77
+ await atomicWrite(file, JSON.stringify(data, null, 2) + '\n', 0o600, ctx.platform);
78
+ changed++;
79
+ }
80
+ return changed;
81
+ }
36
82
  export async function configure(ctx) {
37
83
  const file = clientConfigPath(id, ctx.home, ctx.platform);
38
84
  const primary = `scalemax/${preferredModel(ctx)}`;
39
- const result = await writeMergedJsonConfig(ctx, id, name, file, existing => {
40
- const modelsRoot = asRecord(existing.models);
41
- const providers = asRecord(modelsRoot.providers);
42
- const agents = asRecord(existing.agents);
43
- const defaults = asRecord(agents.defaults);
44
- const allowlist = asRecord(defaults.models);
45
- for (const modelId of modelIds(ctx))
46
- allowlist[`scalemax/${modelId}`] = asRecord(allowlist[`scalemax/${modelId}`]);
47
- const currentModel = defaults.model;
48
- const currentPrimary = typeof currentModel === 'string' ? currentModel : asRecord(currentModel).primary;
49
- const nextModel = !currentPrimary || String(currentPrimary).startsWith('scalemax/')
50
- ? { ...asRecord(currentModel), primary }
51
- : currentModel;
52
- return JSON.stringify({
53
- ...existing,
54
- models: {
55
- ...modelsRoot,
56
- mode: modelsRoot.mode || 'merge',
57
- providers: {
58
- ...providers,
59
- scalemax: {
60
- baseUrl: anthropicOrigin(ctx.baseUrl),
61
- apiKey: ctx.apiKey,
62
- api: 'anthropic-messages',
63
- maxTokens: 4096,
64
- models: entries(ctx)
85
+ const quick = fastModel(ctx);
86
+ let secret = { changed: false };
87
+ let userAgentSecret = { changed: false };
88
+ let result;
89
+ try {
90
+ secret = await writeClientSecret(secretPath(ctx.home), ctx);
91
+ userAgentSecret = await writeClientSecret(userAgentPath(ctx.home), userAgentContext(ctx));
92
+ result = await writeMergedJsonConfig(ctx, id, name, file, existing => {
93
+ const modelsRoot = asRecord(existing.models);
94
+ const providers = asRecord(modelsRoot.providers);
95
+ const secretsRoot = asRecord(existing.secrets);
96
+ const secretProviders = asRecord(secretsRoot.providers);
97
+ const agents = asRecord(existing.agents);
98
+ const defaults = asRecord(agents.defaults);
99
+ const allowlist = asRecord(defaults.models);
100
+ for (const modelId of modelIds(ctx))
101
+ allowlist[`scalemax/${modelId}`] = asRecord(allowlist[`scalemax/${modelId}`]);
102
+ const currentModel = defaults.model;
103
+ const currentPrimary = typeof currentModel === 'string' ? currentModel : asRecord(currentModel).primary;
104
+ const activatesScaleMax = !currentPrimary || String(currentPrimary).startsWith('scalemax/');
105
+ const nextModel = activatesScaleMax
106
+ ? { ...asRecord(currentModel), primary }
107
+ : currentModel;
108
+ const currentUtility = typeof defaults.utilityModel === 'string' ? defaults.utilityModel : undefined;
109
+ const utilityModel = quick && (activatesScaleMax || currentUtility?.startsWith('scalemax/')) ? `scalemax/${quick}` : currentUtility;
110
+ return JSON.stringify({
111
+ ...existing,
112
+ secrets: {
113
+ ...secretsRoot,
114
+ providers: {
115
+ ...secretProviders,
116
+ [secretProviderId]: { source: 'file', path: secretConfigPath, mode: 'singleValue' },
117
+ [userAgentProviderId]: { source: 'file', path: userAgentConfigPath, mode: 'singleValue' }
65
118
  }
119
+ },
120
+ models: {
121
+ ...modelsRoot,
122
+ mode: modelsRoot.mode || 'merge',
123
+ providers: {
124
+ ...providers,
125
+ scalemax: {
126
+ baseUrl: anthropicOrigin(ctx.baseUrl),
127
+ apiKey: secretReference,
128
+ api: 'anthropic-messages',
129
+ maxTokens: 4096,
130
+ request: {
131
+ headers: { 'User-Agent': userAgentReference },
132
+ allowPrivateNetwork: false
133
+ },
134
+ models: entries(ctx)
135
+ }
136
+ }
137
+ },
138
+ agents: {
139
+ ...agents,
140
+ defaults: { ...defaults, model: nextModel, models: allowlist, ...(utilityModel ? { utilityModel } : {}) }
66
141
  }
67
- },
68
- agents: {
69
- ...agents,
70
- defaults: { ...defaults, model: nextModel, models: allowlist }
71
- }
72
- }, null, 2) + '\n';
73
- });
142
+ }, null, 2) + '\n';
143
+ });
144
+ }
145
+ catch (error) {
146
+ await rollbackClientSecret(userAgentPath(ctx.home), userAgentContext(ctx), userAgentSecret);
147
+ await rollbackClientSecret(secretPath(ctx.home), ctx, secret);
148
+ throw error;
149
+ }
74
150
  await removeLegacyArtifact(ctx);
75
- return { ...result, message: `configured native Anthropic provider with ${modelIds(ctx).length} models` };
151
+ const scrubbed = await scrubGeneratedPlaintextCredentials(ctx);
152
+ return { ...result, changed: result.changed || secret.changed || userAgentSecret.changed || scrubbed > 0, message: `configured native Anthropic provider with ${modelIds(ctx).length} models and file-backed SecretRefs${quick ? `; ${quick} handles utility work` : ''}${scrubbed ? `; scrubbed ${scrubbed} generated credential ${scrubbed === 1 ? 'copy' : 'copies'}` : ''}` };
76
153
  }
77
154
  export async function verify(ctx) {
78
155
  const file = clientConfigPath(id, ctx.home, ctx.platform);
79
156
  const config = await fs.readJson(file);
80
157
  const provider = config?.models?.providers?.scalemax;
81
- if (provider?.baseUrl !== anthropicOrigin(ctx.baseUrl) || provider?.apiKey !== ctx.apiKey || provider?.api !== 'anthropic-messages') {
158
+ const secretProvider = config?.secrets?.providers?.[secretProviderId];
159
+ const userAgentProvider = config?.secrets?.providers?.[userAgentProviderId];
160
+ const userAgent = provider?.request?.headers?.['User-Agent'];
161
+ if (provider?.baseUrl !== anthropicOrigin(ctx.baseUrl) || provider?.apiKey?.source !== secretReference.source || provider?.apiKey?.provider !== secretReference.provider || provider?.apiKey?.id !== secretReference.id || provider?.api !== 'anthropic-messages' || secretProvider?.source !== 'file' || secretProvider?.path !== secretConfigPath || secretProvider?.mode !== 'singleValue' || userAgent?.source !== userAgentReference.source || userAgent?.provider !== userAgentReference.provider || userAgent?.id !== userAgentReference.id || userAgentProvider?.source !== 'file' || userAgentProvider?.path !== userAgentConfigPath || userAgentProvider?.mode !== 'singleValue') {
82
162
  throw new Error('OpenClaw ScaleMax provider settings are incomplete.');
83
163
  }
164
+ await verifyClientSecret(secretPath(ctx.home), ctx);
165
+ await verifyClientSecret(userAgentPath(ctx.home), userAgentContext(ctx));
84
166
  const configured = new Set((Array.isArray(provider.models) ? provider.models : []).map((model) => model?.id));
85
167
  const missing = modelIds(ctx).filter(model => !configured.has(model));
86
168
  if (missing.length)
@@ -89,8 +171,11 @@ export async function verify(ctx) {
89
171
  }
90
172
  export async function remove(ctx) {
91
173
  const file = clientConfigPath(id, ctx.home, ctx.platform);
92
- if (!(await fs.pathExists(file)))
93
- return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
174
+ if (!(await fs.pathExists(file))) {
175
+ const removed = await removeClientSecret(secretPath(ctx.home), ctx);
176
+ const removedUserAgent = await removeClientSecret(userAgentPath(ctx.home), userAgentContext(ctx));
177
+ return { id, name, changed: removed || removedUserAgent, path: file, message: removed || removedUserAgent ? 'removed orphaned ScaleMax OpenClaw credentials' : 'no ScaleMax configuration found' };
178
+ }
94
179
  const config = await fs.readJson(file).catch(() => undefined);
95
180
  if (!config?.models?.providers?.scalemax || config.models.providers.scalemax.baseUrl !== anthropicOrigin(ctx.baseUrl)) {
96
181
  return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
@@ -115,15 +200,30 @@ export async function remove(ctx) {
115
200
  if (!Object.keys(defaults.model).length)
116
201
  delete defaults.model;
117
202
  }
203
+ if (typeof defaults?.utilityModel === 'string' && defaults.utilityModel.startsWith('scalemax/'))
204
+ delete defaults.utilityModel;
118
205
  if (defaults && !Object.keys(defaults).length)
119
206
  delete config.agents.defaults;
120
207
  if (config.agents && !Object.keys(config.agents).length)
121
208
  delete config.agents;
209
+ const secretProvider = config?.secrets?.providers?.[secretProviderId];
210
+ if (secretProvider?.source === 'file' && secretProvider?.path === secretConfigPath && secretProvider?.mode === 'singleValue') {
211
+ delete config.secrets.providers[secretProviderId];
212
+ }
213
+ const userAgentProvider = config?.secrets?.providers?.[userAgentProviderId];
214
+ if (userAgentProvider?.source === 'file' && userAgentProvider?.path === userAgentConfigPath && userAgentProvider?.mode === 'singleValue')
215
+ delete config.secrets.providers[userAgentProviderId];
216
+ if (config?.secrets?.providers && !Object.keys(config.secrets.providers).length)
217
+ delete config.secrets.providers;
218
+ if (config?.secrets && !Object.keys(config.secrets).length)
219
+ delete config.secrets;
122
220
  await backupFile(file, ctx.home);
123
221
  if (Object.keys(config).length)
124
222
  await atomicWrite(file, JSON.stringify(config, null, 2) + '\n', 0o600, ctx.platform);
125
223
  else
126
224
  await fs.remove(file);
225
+ await removeClientSecret(secretPath(ctx.home), ctx);
226
+ await removeClientSecret(userAgentPath(ctx.home), userAgentContext(ctx));
127
227
  await removeLegacyArtifact(ctx);
128
- return { id, name, changed: true, path: file, message: 'removed only the ScaleMax provider and model entries' };
228
+ return { id, name, changed: true, path: file, message: 'removed only the ScaleMax provider, model entries, and credential' };
129
229
  }
@@ -1,10 +1,14 @@
1
+ import path from 'node:path';
1
2
  import fs from 'fs-extra';
2
3
  import { atomicWrite, backupFile } from '../utils/fs.js';
3
4
  import { clientConfigPath } from './paths.js';
4
5
  import { writeMergedJsonConfig } from './common.js';
5
- import { modelIds, modelName, openAiBaseUrl, preferredModel } from './models.js';
6
+ import { fastModel, modelIds, modelName, openAiBaseUrl, preferredModel } from './models.js';
7
+ import { removeClientSecret, rollbackClientSecret, verifyClientSecret, writeClientSecret } from './client-secret.js';
6
8
  const id = 'opencode';
7
9
  const name = 'OpenCode';
10
+ const secretReference = '{file:~/.config/opencode/scalemax.key}';
11
+ function secretPath(home) { return path.join(home, '.config', 'opencode', 'scalemax.key'); }
8
12
  function asRecord(value) {
9
13
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
10
14
  }
@@ -22,31 +26,45 @@ function providerConfig(ctx) {
22
26
  return {
23
27
  npm: '@ai-sdk/openai-compatible',
24
28
  name: 'ScaleMax',
25
- options: { baseURL: openAiBaseUrl(ctx.baseUrl), apiKey: ctx.apiKey },
29
+ options: { baseURL: openAiBaseUrl(ctx.baseUrl), apiKey: secretReference },
26
30
  models
27
31
  };
28
32
  }
29
33
  export async function configure(ctx) {
30
34
  const file = clientConfigPath(id, ctx.home, ctx.platform);
31
35
  const selected = `scalemax/${preferredModel(ctx)}`;
32
- return writeMergedJsonConfig(ctx, id, name, file, existing => {
33
- const providers = asRecord(existing.provider);
34
- const currentModel = typeof existing.model === 'string' ? existing.model : undefined;
35
- return JSON.stringify({
36
- ...existing,
37
- $schema: typeof existing.$schema === 'string' ? existing.$schema : 'https://opencode.ai/config.json',
38
- provider: { ...providers, scalemax: providerConfig(ctx) },
39
- model: !currentModel || currentModel.startsWith('scalemax/') ? selected : currentModel
40
- }, null, 2) + '\n';
41
- });
36
+ const quick = fastModel(ctx);
37
+ const secret = await writeClientSecret(secretPath(ctx.home), ctx);
38
+ let result;
39
+ try {
40
+ result = await writeMergedJsonConfig(ctx, id, name, file, existing => {
41
+ const providers = asRecord(existing.provider);
42
+ const currentModel = typeof existing.model === 'string' ? existing.model : undefined;
43
+ const currentSmallModel = typeof existing.small_model === 'string' ? existing.small_model : undefined;
44
+ const activatesScaleMax = !currentModel || currentModel.startsWith('scalemax/');
45
+ return JSON.stringify({
46
+ ...existing,
47
+ $schema: typeof existing.$schema === 'string' ? existing.$schema : 'https://opencode.ai/config.json',
48
+ provider: { ...providers, scalemax: providerConfig(ctx) },
49
+ model: activatesScaleMax ? selected : currentModel,
50
+ ...(quick && (activatesScaleMax || currentSmallModel?.startsWith('scalemax/')) ? { small_model: `scalemax/${quick}` } : {})
51
+ }, null, 2) + '\n';
52
+ });
53
+ }
54
+ catch (error) {
55
+ await rollbackClientSecret(secretPath(ctx.home), ctx, secret);
56
+ throw error;
57
+ }
58
+ return { ...result, changed: result.changed || secret.changed, message: `${result.message}; file-backed credential${quick ? `; ${quick} handles lightweight work` : ''}` };
42
59
  }
43
60
  export async function verify(ctx) {
44
61
  const file = clientConfigPath(id, ctx.home, ctx.platform);
45
62
  const config = await fs.readJson(file);
46
63
  const provider = config?.provider?.scalemax;
47
- if (provider?.npm !== '@ai-sdk/openai-compatible' || provider?.options?.baseURL !== openAiBaseUrl(ctx.baseUrl) || provider?.options?.apiKey !== ctx.apiKey) {
64
+ if (provider?.npm !== '@ai-sdk/openai-compatible' || provider?.options?.baseURL !== openAiBaseUrl(ctx.baseUrl) || provider?.options?.apiKey !== secretReference) {
48
65
  throw new Error('OpenCode ScaleMax provider settings are incomplete.');
49
66
  }
67
+ await verifyClientSecret(secretPath(ctx.home), ctx);
50
68
  const configured = Object.keys(asRecord(provider.models));
51
69
  const missing = modelIds(ctx).filter(model => !configured.includes(model));
52
70
  if (missing.length)
@@ -55,8 +73,10 @@ export async function verify(ctx) {
55
73
  }
56
74
  export async function remove(ctx) {
57
75
  const file = clientConfigPath(id, ctx.home, ctx.platform);
58
- if (!(await fs.pathExists(file)))
59
- return { id, name, changed: false, path: file, message: 'no ScaleMax configuration found' };
76
+ if (!(await fs.pathExists(file))) {
77
+ const removed = await removeClientSecret(secretPath(ctx.home), ctx);
78
+ return { id, name, changed: removed, path: file, message: removed ? 'removed orphaned ScaleMax OpenCode credential' : 'no ScaleMax configuration found' };
79
+ }
60
80
  const config = await fs.readJson(file).catch(() => undefined);
61
81
  if (!config?.provider?.scalemax || config.provider.scalemax.name !== 'ScaleMax' || config.provider.scalemax.options?.baseURL !== openAiBaseUrl(ctx.baseUrl))
62
82
  return { id, name, changed: false, path: file, message: 'left unmanaged configuration unchanged' };
@@ -65,7 +85,10 @@ export async function remove(ctx) {
65
85
  delete config.provider;
66
86
  if (typeof config.model === 'string' && config.model.startsWith('scalemax/'))
67
87
  delete config.model;
88
+ if (typeof config.small_model === 'string' && config.small_model.startsWith('scalemax/'))
89
+ delete config.small_model;
68
90
  await backupFile(file, ctx.home);
69
91
  await atomicWrite(file, JSON.stringify(config, null, 2) + '\n', 0o600, ctx.platform);
70
- return { id, name, changed: true, path: file, message: 'removed only the ScaleMax provider' };
92
+ await removeClientSecret(secretPath(ctx.home), ctx);
93
+ return { id, name, changed: true, path: file, message: 'removed only the ScaleMax provider and credential' };
71
94
  }
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { modelsCommand } from './commands/models.js';
12
12
  import { updateCommand } from './commands/update.js';
13
13
  import { removeCommand } from './commands/remove.js';
14
14
  import { ensureInstallerApiKey } from './utils/installerConfig.js';
15
- import { isNodeSupported, nodeUpgradeHint } from './utils/runtime.js';
15
+ import { MIN_NODE, isNodeSupported, nodeUpgradeHint } from './utils/runtime.js';
16
16
  import { runSystemAudit } from './utils/systemAudit.js';
17
17
  function packageVersion() {
18
18
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -63,7 +63,7 @@ async function runInteractiveInstaller() {
63
63
  }
64
64
  async function main() {
65
65
  if (!isNodeSupported()) {
66
- console.error(chalk.red(`ScaleMax requires Node.js >=18.19. Current: ${process.versions.node}`));
66
+ console.error(chalk.red(`ScaleMax requires Node.js >=${MIN_NODE}. Current: ${process.versions.node}`));
67
67
  console.error(nodeUpgradeHint());
68
68
  process.exitCode = 1;
69
69
  return;
package/dist/utils/api.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { SCALEMAX_BASE_URL } from '../constants.js';
2
- import { sanitizeApiKey } from './apiKey.js';
2
+ import { redactApiKey, sanitizeApiKey } from './apiKey.js';
3
3
  export function isAnthropicModel(model) {
4
4
  const provider = String(model.provider || model.owned_by || '').trim().toLowerCase();
5
5
  if (provider)
@@ -31,27 +31,48 @@ async function friendlyError(res) {
31
31
  return message || 'ScaleMax is temporarily unavailable. Please try again shortly.';
32
32
  return message || `HTTP ${res.status}`;
33
33
  }
34
- async function request(path, apiKey, init = {}) {
34
+ function retryableStatus(status) {
35
+ return status === 408 || status === 429 || status >= 500;
36
+ }
37
+ async function request(path, apiKey, init = {}, retryGet = false) {
35
38
  const headers = new Headers(init.headers);
36
39
  if (apiKey)
37
40
  headers.set('Authorization', `Bearer ${sanitizeApiKey(apiKey)}`);
38
41
  headers.set('Cache-Control', 'no-cache');
39
42
  if (!headers.has('Content-Type') && init.body)
40
43
  headers.set('Content-Type', 'application/json');
41
- const timeout = AbortSignal.timeout(15000);
42
- const signal = init.signal && typeof AbortSignal.any === 'function' ? AbortSignal.any([init.signal, timeout]) : timeout;
43
- return fetch(`${SCALEMAX_BASE_URL}${path}`, { ...init, headers, signal });
44
+ const attempts = retryGet && (!init.method || init.method === 'GET') ? 2 : 1;
45
+ let lastError;
46
+ for (let attempt = 0; attempt < attempts; attempt++) {
47
+ const timeout = AbortSignal.timeout(15000);
48
+ const signal = init.signal && typeof AbortSignal.any === 'function' ? AbortSignal.any([init.signal, timeout]) : timeout;
49
+ try {
50
+ const response = await fetch(`${SCALEMAX_BASE_URL}${path}`, { ...init, headers, signal });
51
+ if (attempt + 1 === attempts || !retryableStatus(response.status))
52
+ return response;
53
+ await response.body?.cancel().catch(() => undefined);
54
+ }
55
+ catch (error) {
56
+ lastError = error;
57
+ if (attempt + 1 === attempts)
58
+ throw error;
59
+ }
60
+ await new Promise(resolve => setTimeout(resolve, 125 + Math.floor(Math.random() * 126)));
61
+ }
62
+ throw lastError;
44
63
  }
45
64
  export async function validateApiKey(apiKey) {
46
65
  try {
47
- const authCheck = await request('/usage', apiKey);
66
+ const [authCheck, modelsRes] = await Promise.all([
67
+ request('/usage', apiKey, {}, true),
68
+ request('/models', apiKey, {}, true)
69
+ ]);
48
70
  if (!authCheck.ok)
49
- return { ok: false, models: [], error: await friendlyError(authCheck) };
71
+ return { ok: false, models: [], error: redactApiKey(await friendlyError(authCheck), apiKey) };
50
72
  const usage = await authCheck.json().catch(() => ({}));
51
73
  const keyMeta = { provider_scope: usage?.key?.provider_scope || 'both', mode: usage?.key?.mode, prefix: usage?.key?.prefix, label: usage?.key?.label };
52
- const modelsRes = await request('/models', apiKey);
53
74
  if (!modelsRes.ok)
54
- return { ok: false, models: [], key: keyMeta, error: await friendlyError(modelsRes) };
75
+ return { ok: false, models: [], key: keyMeta, error: redactApiKey(await friendlyError(modelsRes), apiKey) };
55
76
  const data = await modelsRes.json();
56
77
  const catalog = Array.isArray(data.data) ? data.data : [];
57
78
  const models = filterModelsForScope(catalog, keyMeta.provider_scope);
@@ -60,7 +81,7 @@ export async function validateApiKey(apiKey) {
60
81
  return { ok: true, models, key: keyMeta };
61
82
  }
62
83
  catch (err) {
63
- const message = err instanceof Error ? err.message : String(err);
84
+ const message = redactApiKey(err instanceof Error ? err.message : String(err), apiKey);
64
85
  return { ok: false, models: [], error: /abort|timeout/i.test(message) ? 'ScaleMax request timed out after 15 seconds.' : message };
65
86
  }
66
87
  }
@@ -69,7 +90,7 @@ export async function testStreaming(apiKey, model) {
69
90
  try {
70
91
  const res = await request('/chat/completions', apiKey, { method: 'POST', body: JSON.stringify({ model: selected, stream: true, max_tokens: 8, messages: [{ role: 'user', content: 'Say OK' }] }) });
71
92
  if (!res.ok)
72
- return { ok: false, error: await friendlyError(res) };
93
+ return { ok: false, error: redactApiKey(await friendlyError(res), apiKey) };
73
94
  const ct = res.headers.get('content-type') || '';
74
95
  if (!ct.includes('text/event-stream'))
75
96
  return { ok: false, error: `Expected event stream, got ${ct}` };
@@ -1 +1,2 @@
1
1
  export declare function sanitizeApiKey(input: unknown): string;
2
+ export declare function redactApiKey(message: unknown, apiKey?: string): string;
@@ -6,3 +6,8 @@ export function sanitizeApiKey(input) {
6
6
  .replace(ASCII_WHITESPACE_RE, '')
7
7
  .trim();
8
8
  }
9
+ export function redactApiKey(message, apiKey) {
10
+ const text = String(message ?? '');
11
+ const key = apiKey ? sanitizeApiKey(apiKey) : '';
12
+ return key ? text.split(key).join('[REDACTED]') : text;
13
+ }
package/dist/utils/fs.js CHANGED
@@ -52,8 +52,10 @@ export async function backupFile(file, home) {
52
52
  if (!(await fs.pathExists(file)))
53
53
  return undefined;
54
54
  const dest = path.join(backupDir(home), `${timestamp()}__${backupNameFor(file)}`);
55
- await fs.ensureDir(path.dirname(dest));
55
+ await fs.ensureDir(path.dirname(dest), 0o700);
56
+ await fs.chmod(path.dirname(dest), 0o700).catch(() => undefined);
56
57
  await fs.copy(file, dest, { overwrite: false, errorOnExist: true });
58
+ await fs.chmod(dest, 0o600).catch(() => undefined);
57
59
  return dest;
58
60
  }
59
61
  export async function listBackups(file, home) {
@@ -1,4 +1,4 @@
1
- export declare const MIN_NODE = "18.17.0";
1
+ export declare const MIN_NODE = "18.19.0";
2
2
  export declare const RECOMMENDED_NODE = "22";
3
3
  export type OsFamily = 'macos' | 'ubuntu' | 'debian' | 'fedora' | 'arch' | 'windows' | 'wsl' | 'linux' | 'unknown';
4
4
  export type RuntimeAudit = {
@@ -1,7 +1,7 @@
1
1
  import os from 'node:os';
2
2
  import { execa } from 'execa';
3
3
  import { commandExists } from './exec.js';
4
- export const MIN_NODE = '18.17.0';
4
+ export const MIN_NODE = '18.19.0';
5
5
  export const RECOMMENDED_NODE = '22';
6
6
  export function compareSemver(a, b) { const pa = a.replace(/^v/, '').split('.').map(Number), pb = b.replace(/^v/, '').split('.').map(Number); for (let i = 0; i < 3; i++) {
7
7
  const d = (pa[i] || 0) - (pb[i] || 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scalemax",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Universal ScaleMax installer for AI coding clients",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,7 @@
31
31
  "chalk": "^5.3.0",
32
32
  "execa": "^9.5.1",
33
33
  "fs-extra": "^11.2.0",
34
+ "json5": "^2.2.3",
34
35
  "ora": "^8.1.1",
35
36
  "yaml": "^2.9.0"
36
37
  },
@@ -39,7 +40,7 @@
39
40
  "@types/node": "^22.10.2",
40
41
  "tsx": "^4.19.2",
41
42
  "typescript": "^5.7.2",
42
- "vitest": "^2.1.8"
43
+ "vitest": "^4.1.10"
43
44
  },
44
45
  "engines": {
45
46
  "node": ">=18.19"