openzoo 0.51.16 → 0.51.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.
@@ -20,7 +20,7 @@ export function installerAsset(platform = process.platform, arch = process.arch)
20
20
 
21
21
  export function runCommand(command, args, options = {}) {
22
22
  return new Promise((resolve, reject) => {
23
- const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], ...options });
23
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, ...options });
24
24
  let stdout = '', stderr = '';
25
25
  child.stdout?.on('data', b => { stdout = (stdout + b).slice(-1024 * 1024); });
26
26
  child.stderr?.on('data', b => { stderr = (stderr + b).slice(-8192); });
package/lib/chatgpt.js CHANGED
@@ -11,6 +11,7 @@ import fs from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { config } from './config.js';
14
+ import { launchWindowsPackage } from './windows-chatgpt.js';
14
15
  import { startChatGptSpend } from './chatgpt-spend.js';
15
16
  import { installChatGpt, windowsChatGptApp, runCommand } from './chatgpt-install.js';
16
17
 
@@ -222,7 +223,7 @@ async function ensureProxy({ tunnel }) {
222
223
  const fd = fs.openSync(logPath, 'a');
223
224
  const env = { ...process.env };
224
225
  if (!tunnel && !env.OPENZOO_NO_TUNNEL) env.OPENZOO_NO_TUNNEL = '1';
225
- const child = spawn(process.execPath, [process.argv[1], 'proxy'], { detached: true, stdio: ['ignore', fd, fd], env });
226
+ const child = spawn(process.execPath, [process.argv[1], 'proxy'], { detached: true, windowsHide: true, stdio: ['ignore', fd, fd], env });
226
227
  child.unref();
227
228
  fs.closeSync(fd);
228
229
  process.stderr.write(`openzoo: starting the proxy (v${packageVersion()}) in the background, log ${logPath}`);
@@ -231,8 +232,7 @@ async function ensureProxy({ tunnel }) {
231
232
  await new Promise((r) => setTimeout(r, 1000));
232
233
  process.stderr.write('.');
233
234
  try {
234
- const r = await fetch(`http://localhost:${config.port}/v1/info`);
235
- if (r.ok) { process.stderr.write('\n'); return { started: true, logPath }; }
235
+ if (await oursOn(config.port)) { process.stderr.write('\n'); return { started: true, logPath }; }
236
236
  } catch { /* not yet */ }
237
237
  }
238
238
  process.stderr.write('\n');
@@ -334,6 +334,10 @@ export async function setupChatGpt(argv = [], dependencies = {}) {
334
334
 
335
335
  /** LaunchServices handles app bundles on macOS; detect early failures elsewhere. */
336
336
  export async function launchChatGpt(app, args, { env, platform = process.platform, run = runCommand, spawnImpl = spawn } = {}) {
337
+ if (platform === 'win32' && /[\\/]WindowsApps[\\/]/i.test(app)) {
338
+ await launchWindowsPackage(app, args, { env, run });
339
+ return;
340
+ }
337
341
  if (platform === 'darwin' && app.endsWith('/Contents/MacOS/ChatGPT')) {
338
342
  await run('open', ['-n', '--env', `CODEX_HOME=${env.CODEX_HOME}`,
339
343
  '--env', `CODEX_ELECTRON_USER_DATA_PATH=${env.CODEX_ELECTRON_USER_DATA_PATH}`,
@@ -1,16 +1,19 @@
1
1
  /** Coalesce live reads, retain unknowns, and never cache an empty balance for a minute. */
2
2
  export function liveBalance(read, { ttlMs = 3000, now = Date.now } = {}) {
3
- let value = null, at = 0, inflight;
3
+ let value = null, at = 0, inflight, generation = 0;
4
4
  return {
5
+ reset() { generation++; value = null; at = 0; inflight = undefined; },
5
6
  get value() { return value; },
6
7
  get checkedAt() { return at || null; },
7
8
  async refresh(force = false) {
8
9
  if (inflight) return inflight;
9
10
  if (!force && at && now() - at < ttlMs) return value;
11
+ const readGeneration = generation;
10
12
  inflight = Promise.resolve().then(read).then(next => {
11
13
  if (typeof next !== 'number' || !Number.isFinite(next) || next < 0) throw new Error('Balance unavailable');
14
+ if (readGeneration !== generation) return value;
12
15
  value = next; at = now(); return value;
13
- }).catch(() => value).finally(() => { inflight = null; });
16
+ }).catch(() => value).finally(() => { if (readGeneration === generation) inflight = null; });
14
17
  return inflight;
15
18
  },
16
19
  };
package/lib/info.js CHANGED
@@ -257,10 +257,10 @@ export { priceHoldings, formatHoldingMoney } from './livestatus.js';
257
257
  /** Current prepaid credit for this wallet's namespace. */
258
258
  export { CREDIT_TIMEOUT_MS };
259
259
 
260
- export async function creditBalance() {
260
+ export async function creditBalance(wallet) {
261
261
  const { withNamespace } = await import('./namespace.js');
262
262
  const r = await fetch(`${config.apiBase}/v1/credits`, {
263
- headers: withNamespace({}), cache: 'no-store',
263
+ headers: withNamespace({}, wallet), cache: 'no-store',
264
264
  signal: AbortSignal.timeout(CREDIT_TIMEOUT_MS),
265
265
  });
266
266
  if (!r.ok) throw new Error('Credit balance is temporarily unavailable');
package/lib/proxy.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { receiptLedger } from './receipt-ledger.js';
2
2
  import { billingObserver, finalBilling } from './billing.js';
3
3
  import { liveBalance } from './funding-status.js';
4
+ import { walletReloader } from './wallet-reload.js';
5
+ import { loadOrCreateWallet } from './wallet.js';
4
6
  import { paymentError } from './payment-error.js';
5
7
  import { readFileSync, appendFileSync, mkdirSync } from 'node:fs';
6
8
  import os from 'node:os';
@@ -59,7 +61,7 @@ export function killListen(port, run = execSync, platform = process.platform) {
59
61
  };
60
62
  const tryRun = (cmd, opts = {}) => {
61
63
  try {
62
- return run(cmd, { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'], ...opts });
64
+ return run(cmd, { encoding: 'utf8', timeout: 2000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'], ...opts });
63
65
  } catch { return ''; }
64
66
  };
65
67
  if (platform === 'win32') {
@@ -72,7 +74,7 @@ export function killListen(port, run = execSync, platform = process.platform) {
72
74
  if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) seen.add(pid);
73
75
  }
74
76
  for (const pid of seen) {
75
- try { run(`taskkill /PID ${pid} /F`, { stdio: 'ignore', timeout: 2000 }); } catch { /* already gone */ }
77
+ try { run(`taskkill /PID ${pid} /F`, { stdio: 'ignore', timeout: 2000, windowsHide: true }); } catch { /* already gone */ }
76
78
  }
77
79
  return [...seen];
78
80
  }
@@ -387,7 +389,8 @@ function replayPut(key, data, settle) {
387
389
  * default, so localhost behaviour is unchanged.
388
390
  */
389
391
  export async function startProxy({ silent = false, requireToken = null, sessionMaxUsd = null, autoTunnel = false } = {}) {
390
- const client = new PayClient();
392
+ let client = new PayClient();
393
+ const currentWallet = walletReloader(client, { file: client.walletPath, load: () => new PayClient(loadOrCreateWallet({ create: false })) });
391
394
  // HOUSE OUTAGE GATE. Measured 2026-09-01: TOKEN payments settled on chain
392
395
  // (tx 3rwy5z…) and the gateway still answered 402 because ITS upstream
393
396
  // (OpenRouter) was out of credits. Paying again buys nothing, and the
@@ -496,7 +499,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
496
499
  let walletUsd = null;
497
500
  let walletAt = 0;
498
501
  const walletStatus = liveBalance(async () => {
499
- const snap = await snapshotBalances(client);
502
+ const readingClient = client;
503
+ const snap = await snapshotBalances(readingClient);
504
+ if (readingClient !== client) throw new Error("Wallet changed during balance read");
500
505
  if (!snap.length) throw new Error('Balances unavailable');
501
506
  const previous = new Map((lastSnap || []).map(b => [`${b.chain}:${b.symbol}`, b]));
502
507
  for (const b of snap) {
@@ -507,14 +512,20 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
507
512
  lastSnap = [...previous.values()];
508
513
  return priceHoldings(lastSnap, lastPrices).chainUsd;
509
514
  });
510
- const creditStatus = liveBalance(creditBalance);
515
+ const creditStatus = liveBalance(() => creditBalance(client));
511
516
  const refreshWallet = async (force = false) => {
512
- walletUsd = await walletStatus.refresh(force);
517
+ const readingClient = client;
518
+ const value = await walletStatus.refresh(force);
519
+ if (readingClient !== client) return walletUsd;
520
+ walletUsd = value;
513
521
  walletAt = walletStatus.checkedAt;
514
522
  return walletUsd;
515
523
  };
516
524
  const refreshCredit = async (force = false) => {
517
- creditUsd = await creditStatus.refresh(force);
525
+ const readingClient = client;
526
+ const value = await creditStatus.refresh(force);
527
+ if (readingClient !== client) return creditUsd;
528
+ creditUsd = value;
518
529
  creditAt = creditStatus.checkedAt;
519
530
  return creditUsd;
520
531
  };
@@ -532,7 +543,22 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
532
543
  const walletMoney = () => priceHoldings(lastSnap || [], lastPrices);
533
544
  let tunnelError = null;
534
545
 
546
+ const reloadWallet = () => {
547
+ const next = currentWallet();
548
+ if (next !== client) {
549
+ client = next;
550
+ resetBalanceCache(); resetRailMemory();
551
+ walletStatus.reset(); creditStatus.reset();
552
+ lastSnap = null; walletUsd = creditUsd = null; walletAt = creditAt = 0;
553
+ say(`wallet replaced · now using Solana ${client.address} · Base ${client.evmAddress}`);
554
+ refreshWallet(true); refreshCredit(true);
555
+ }
556
+ return client;
557
+ };
535
558
  const server = http.createServer(async (req, res) => {
559
+ // Every request keeps one client even if the file changes mid-response.
560
+ let client;
561
+ try { client = reloadWallet(); } catch (error) { jsonErr(res, 503, error.message); return; }
536
562
  // MCP on the SAME port as the proxy. One `npx openzoo` gives a harness
537
563
  // both surfaces: point base_url at /v1, or add /mcp for tools.
538
564
  if (req.method === 'GET' && (req.url || '').split('?')[0] === '/v1/session') {
@@ -1137,7 +1163,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1137
1163
  // Funding checks run for desktop/background launches as well as terminal sessions.
1138
1164
  refreshBalances = () => refreshWallet(true);
1139
1165
  refreshWallet(); refreshCredit(); refreshPrices();
1140
- const fundingTimer = setInterval(() => { refreshCredit(); refreshWallet(); refreshPrices(); }, 3000);
1166
+ const fundingTimer = setInterval(() => { try { reloadWallet(); refreshCredit(); refreshWallet(); refreshPrices(); } catch { /* request path reports an incomplete wallet replacement */ } }, 3000);
1141
1167
  fundingTimer.unref?.();
1142
1168
  server.on('close', () => clearInterval(fundingTimer));
1143
1169
 
@@ -0,0 +1,23 @@
1
+ import fs from 'node:fs';
2
+ import crypto from 'node:crypto';
3
+ /** Swap whole clients, never mutate the signer of an in-flight request. */
4
+ export function walletReloader(initial, { file, load }) {
5
+ const fingerprint = () => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
6
+ let current = initial, stamp = fingerprint();
7
+ return () => {
8
+ try {
9
+ const next = fingerprint();
10
+ if (next !== stamp) {
11
+ const replacement = load();
12
+ // load may migrate the legacy array format after validating it.
13
+ stamp = fingerprint();
14
+ current = replacement;
15
+ }
16
+ return current;
17
+ } catch {
18
+ // Do not print parser errors containing secret-key bytes, or keep paying
19
+ // from the old wallet while a replacement is missing/partially copied.
20
+ throw new Error('Could not load wallet.json. Finish copying a valid OpenZoo wallet file; payments are paused until it is ready.');
21
+ }
22
+ };
23
+ }
package/lib/wallet.js CHANGED
@@ -10,7 +10,7 @@ import { config } from './config.js';
10
10
  * Legacy format (bare 64-byte array, solana-keygen style) is migrated in place.
11
11
  * Your keys never leave this machine — the zoo only ever sees signed transfers.
12
12
  */
13
- export function loadOrCreateWallet() {
13
+ export function loadOrCreateWallet({ create = true } = {}) {
14
14
  const p = config.walletPath;
15
15
  let solanaBytes;
16
16
  let evmKey;
@@ -25,16 +25,20 @@ export function loadOrCreateWallet() {
25
25
  evmKey = raw.evm;
26
26
  }
27
27
  } else {
28
+ if (!create) throw new Error("wallet.json is missing. Finish replacing the wallet file and try again.");
28
29
  solanaBytes = Keypair.generate().secretKey;
29
30
  created = true;
30
31
  }
32
+ // Validate before migrating, so a partial replacement is never overwritten.
33
+ const keypair = Keypair.fromSecretKey(solanaBytes);
34
+ if (evmKey && !/^0x[0-9a-f]{64}$/i.test(evmKey)) throw new Error("Invalid EVM wallet key");
31
35
  if (!evmKey) {
32
36
  evmKey = `0x${crypto.randomBytes(32).toString('hex')}`;
33
37
  fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
34
38
  fs.writeFileSync(p, JSON.stringify({ solana: Array.from(solanaBytes), evm: evmKey }), { mode: 0o600 });
35
39
  }
36
40
  return {
37
- keypair: Keypair.fromSecretKey(solanaBytes),
41
+ keypair,
38
42
  evmPrivateKey: evmKey,
39
43
  created,
40
44
  path: p,
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ // CommandLineToArgvW quoting, without invoking cmd.exe or interpreting shell syntax.
6
+ export function windowsArgument(value) {
7
+ return '"' + String(value).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1') + '"';
8
+ }
9
+
10
+ export function packagedLaunchScript({ app, args, env, statusFile }) {
11
+ const data = Buffer.from(JSON.stringify({ app, args, statusFile,
12
+ env: { CODEX_HOME: env.CODEX_HOME, CODEX_ELECTRON_USER_DATA_PATH: env.CODEX_ELECTRON_USER_DATA_PATH,
13
+ OPENZOO_API_KEY: env.OPENZOO_API_KEY } }), 'utf8').toString('base64');
14
+ // This initializer runs INSIDE the package identity. Explicitly set the
15
+ // isolated profile there instead of relying on activation-broker inheritance.
16
+ const child = `$ErrorActionPreference = 'Stop'
17
+ $d = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${data}')) | ConvertFrom-Json
18
+ try {
19
+ foreach ($entry in $d.env.PSObject.Properties) { [Environment]::SetEnvironmentVariable($entry.Name, [string]$entry.Value, 'Process') }
20
+ $p = Start-Process -FilePath $d.app -ArgumentList $d.args -WorkingDirectory (Split-Path -LiteralPath $d.app) -PassThru
21
+ Start-Sleep -Milliseconds 1500
22
+ if ($p.HasExited -and $p.ExitCode -ne 0) { throw "ChatGPT exited during launch ($($p.ExitCode))" }
23
+ @{ok=$true;pid=$p.Id} | ConvertTo-Json -Compress | Set-Content -LiteralPath $d.statusFile -Encoding UTF8
24
+ } catch { @{ok=$false;error=$_.Exception.Message} | ConvertTo-Json -Compress | Set-Content -LiteralPath $d.statusFile -Encoding UTF8; exit 1 }
25
+ `;
26
+ const encoded = Buffer.from(child, 'utf16le').toString('base64');
27
+ return `$ErrorActionPreference = 'Stop'
28
+ $target = $env:OPENZOO_PACKAGED_APP
29
+ $match = $null
30
+ foreach ($pkg in (Get-AppxPackage | Where-Object { $_.Name -match 'OpenAI|ChatGPT|Codex' })) {
31
+ $manifest = Get-AppxPackageManifest -Package $pkg.PackageFullName
32
+ foreach ($entry in $manifest.Package.Applications.Application) {
33
+ if ($entry.Executable -and ((Join-Path $pkg.InstallLocation $entry.Executable) -eq $target)) {
34
+ $match = @{family=$pkg.PackageFamilyName;id=[string]$entry.Id}
35
+ break
36
+ }
37
+ }
38
+ if ($match) { break }
39
+ }
40
+ if (!$match) { throw 'The ChatGPT package registration could not be found. Repair ChatGPT in Windows Settings and try again.' }
41
+ Invoke-CommandInDesktopPackage -PackageFamilyName $match.family -AppId $match.id -Command "$PSHOME\\powershell.exe" -Args '-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encoded}' -PreventBreakaway | Out-Null
42
+ $deadline = [DateTime]::UtcNow.AddSeconds(25)
43
+ while (!(Test-Path -LiteralPath $env:OPENZOO_LAUNCH_STATUS)) {
44
+ if ([DateTime]::UtcNow -gt $deadline) { throw 'ChatGPT did not confirm startup. Close ChatGPT and try Launch again.' }
45
+ Start-Sleep -Milliseconds 200
46
+ }
47
+ $result = Get-Content -LiteralPath $env:OPENZOO_LAUNCH_STATUS -Raw | ConvertFrom-Json
48
+ if (!$result.ok) { throw $result.error }
49
+ Write-Output $result.pid
50
+ `;
51
+ }
52
+
53
+ export async function launchWindowsPackage(app, args, { env, run }) {
54
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'openzoo-launch-'));
55
+ const statusFile = path.join(scratch, 'status.json');
56
+ try {
57
+ const commandLine = [`--user-data-dir=${env.CODEX_ELECTRON_USER_DATA_PATH}`, ...args].map(windowsArgument).join(' ');
58
+ await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command',
59
+ packagedLaunchScript({ app, args: commandLine, env, statusFile })],
60
+ { windowsHide: true, env: { ...env, OPENZOO_PACKAGED_APP: app, OPENZOO_LAUNCH_STATUS: statusFile } });
61
+ } finally { fs.rmSync(scratch, { recursive: true, force: true }); }
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.51.16",
3
+ "version": "0.51.17",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",