@the-open-engine/zeroshot 6.27.0 → 6.29.0

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.
Files changed (59) hide show
  1. package/README.md +3 -3
  2. package/cli/index.js +28 -21
  3. package/docker/zeroshot-cluster/Dockerfile +2 -3
  4. package/docker/zeroshot-oecp/Cargo.toml +12 -0
  5. package/docker/zeroshot-oecp/Dockerfile +65 -0
  6. package/docker/zeroshot-oecp/src/main.rs +32 -0
  7. package/lib/agent-cli-provider/adapters/codex.d.ts.map +1 -1
  8. package/lib/agent-cli-provider/adapters/codex.js +1 -0
  9. package/lib/agent-cli-provider/adapters/codex.js.map +1 -1
  10. package/lib/cluster-worker/engine-adapter.js +11 -15
  11. package/lib/cluster-worker/engine-input.js +14 -0
  12. package/lib/cluster-worker/profiles.js +42 -1
  13. package/lib/start-cluster.js +3 -3
  14. package/lib/target/bounded-json.d.ts +6 -0
  15. package/lib/target/bounded-json.js +43 -0
  16. package/lib/target/credential-lock.d.ts +1 -0
  17. package/lib/target/credential-lock.js +38 -0
  18. package/lib/target/credential-store.d.ts +27 -0
  19. package/lib/target/credential-store.js +113 -0
  20. package/lib/target/device-flow.d.ts +42 -0
  21. package/lib/target/device-flow.js +109 -0
  22. package/lib/target/discovery.d.ts +12 -0
  23. package/lib/target/discovery.js +97 -0
  24. package/lib/target/hosted-run/client.d.ts +26 -0
  25. package/lib/target/hosted-run/client.js +158 -0
  26. package/lib/target/hosted-run/commands.d.ts +4 -0
  27. package/lib/target/hosted-run/commands.js +113 -0
  28. package/lib/target/hosted-run/contracts.d.ts +49 -0
  29. package/lib/target/hosted-run/contracts.js +3 -0
  30. package/lib/target/hosted-run/input.d.ts +5 -0
  31. package/lib/target/hosted-run/input.js +200 -0
  32. package/lib/target/hosted-run.d.ts +4 -0
  33. package/lib/target/hosted-run.js +12 -0
  34. package/lib/target/index.d.ts +6 -0
  35. package/lib/target/index.js +38 -0
  36. package/lib/target/target-registry.d.ts +45 -0
  37. package/lib/target/target-registry.js +132 -0
  38. package/lib/target/target-session.d.ts +40 -0
  39. package/lib/target/target-session.js +163 -0
  40. package/package.json +25 -7
  41. package/scripts/audit-production-dependencies.js +150 -0
  42. package/scripts/opcore-agent-gate.js +159 -0
  43. package/scripts/opcore-agent-tool-overlays.js +154 -0
  44. package/scripts/opcore-introduced-check.js +290 -0
  45. package/src/agent-cli-provider/adapters/codex.ts +1 -0
  46. package/src/isolation-manager.js +16 -1
  47. package/src/target/bounded-json.ts +48 -0
  48. package/src/target/credential-lock.ts +35 -0
  49. package/src/target/credential-store.ts +107 -0
  50. package/src/target/device-flow.ts +168 -0
  51. package/src/target/discovery.ts +131 -0
  52. package/src/target/hosted-run/client.ts +198 -0
  53. package/src/target/hosted-run/commands.ts +140 -0
  54. package/src/target/hosted-run/contracts.ts +53 -0
  55. package/src/target/hosted-run/input.ts +199 -0
  56. package/src/target/hosted-run.ts +8 -0
  57. package/src/target/index.ts +55 -0
  58. package/src/target/target-registry.ts +174 -0
  59. package/src/target/target-session.ts +253 -0
@@ -0,0 +1,154 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const { pathToFileURL } = require('node:url');
4
+
5
+ const writeTools = new Set(['write']);
6
+ const editTools = new Set(['edit']);
7
+ const multiEditTools = new Set(['multiedit', 'multi_edit']);
8
+ const applyPatchTools = new Set(['applypatch', 'apply_patch']);
9
+ let editApiPromise;
10
+
11
+ function firstString(...values) {
12
+ return values.find((value) => typeof value === 'string');
13
+ }
14
+
15
+ function isRecord(value) {
16
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
17
+ }
18
+
19
+ function firstRecord(...values) {
20
+ return values.find(isRecord);
21
+ }
22
+
23
+ function requiredString(value, name) {
24
+ if (value === undefined) throw new Error(`${name} must be a string`);
25
+ return value;
26
+ }
27
+
28
+ function resolveTargetPath(repoRoot, filePath) {
29
+ const absolute = path.isAbsolute(filePath)
30
+ ? path.resolve(filePath)
31
+ : path.resolve(repoRoot, filePath);
32
+ const relative = path.relative(repoRoot, absolute);
33
+ if (
34
+ !relative ||
35
+ relative.startsWith('..') ||
36
+ path.isAbsolute(relative) ||
37
+ relative.split(path.sep).includes('..')
38
+ ) {
39
+ throw new Error(`pre-write target must stay inside the repo: ${filePath}`);
40
+ }
41
+ return relative.replaceAll('\\', '/');
42
+ }
43
+
44
+ function extractToolRequest(envelope) {
45
+ const nested = firstRecord(envelope.tool, envelope.toolCall, envelope.tool_call);
46
+ const toolName = firstString(
47
+ envelope.tool_name,
48
+ envelope.toolName,
49
+ envelope.name,
50
+ nested?.name,
51
+ nested?.tool_name,
52
+ nested?.toolName
53
+ );
54
+ if (!toolName) return null;
55
+ return {
56
+ toolName,
57
+ normalizedToolName: toolName.toLowerCase().replaceAll('-', '_'),
58
+ input:
59
+ firstRecord(
60
+ envelope.tool_input,
61
+ envelope.toolInput,
62
+ envelope.input,
63
+ nested?.input,
64
+ nested?.tool_input,
65
+ nested?.toolInput
66
+ ) || envelope,
67
+ cwd: firstString(envelope.cwd),
68
+ };
69
+ }
70
+
71
+ function writeOverlay(repoRoot, input) {
72
+ return {
73
+ action: 'write',
74
+ path: resolveTargetPath(
75
+ repoRoot,
76
+ requiredString(firstString(input.file_path, input.filePath, input.path), 'file_path')
77
+ ),
78
+ content: requiredString(firstString(input.content, input.text), 'content'),
79
+ };
80
+ }
81
+
82
+ function editOverlay(repoRoot, input) {
83
+ const relative = resolveTargetPath(
84
+ repoRoot,
85
+ requiredString(firstString(input.file_path, input.filePath, input.path), 'file_path')
86
+ );
87
+ const oldString = requiredString(firstString(input.old_string, input.oldString), 'old_string');
88
+ const newString = requiredString(firstString(input.new_string, input.newString), 'new_string');
89
+ const existing = fs.readFileSync(path.resolve(repoRoot, relative), 'utf8');
90
+ if (!existing.includes(oldString)) throw new Error(`old_string was not found in ${relative}`);
91
+ return { action: 'write', path: relative, content: existing.replace(oldString, newString) };
92
+ }
93
+
94
+ function multiEditOverlay(repoRoot, input) {
95
+ const relative = resolveTargetPath(
96
+ repoRoot,
97
+ requiredString(firstString(input.file_path, input.filePath, input.path), 'file_path')
98
+ );
99
+ let content = fs.readFileSync(path.resolve(repoRoot, relative), 'utf8');
100
+ for (const edit of Array.isArray(input.edits) ? input.edits : []) {
101
+ if (!isRecord(edit)) throw new Error(`MultiEdit edit for ${relative} must be an object`);
102
+ const oldString = requiredString(firstString(edit.old_string, edit.oldString), 'old_string');
103
+ const newString = requiredString(firstString(edit.new_string, edit.newString), 'new_string');
104
+ if (!content.includes(oldString)) throw new Error(`old_string was not found in ${relative}`);
105
+ content = content.replace(oldString, newString);
106
+ }
107
+ return { action: 'write', path: relative, content };
108
+ }
109
+
110
+ function loadEditApi() {
111
+ if (!editApiPromise) {
112
+ const opcoreEntrypoint = require.resolve('opcore');
113
+ const editModule = path.resolve(
114
+ path.dirname(opcoreEntrypoint),
115
+ '../node_modules/@the-open-engine/opcore-edit/dist/index.js'
116
+ );
117
+ editApiPromise = import(pathToFileURL(editModule).href);
118
+ }
119
+ return editApiPromise;
120
+ }
121
+
122
+ async function patchOverlays(repoRoot, input) {
123
+ const patch = firstString(input.command, input.patch);
124
+ if (!patch) return [];
125
+ const { createNodeEditWorkspace, createPatchEditPlan, isCodexApplyPatch } = await loadEditApi();
126
+ if (!isCodexApplyPatch(patch)) return [];
127
+ const workspace = await createNodeEditWorkspace({ repoRoot });
128
+ const planned = await createPatchEditPlan(workspace, {
129
+ repo: { repoRoot },
130
+ validation: { required: false },
131
+ patch,
132
+ });
133
+ if (!planned.ok) throw new Error(planned.refusal.message);
134
+ return Object.entries(planned.afterState).flatMap(([filePath, content]) => {
135
+ if (content === undefined) return [];
136
+ return [
137
+ {
138
+ action: content === null ? 'delete' : 'write',
139
+ path: filePath,
140
+ ...(content === null ? {} : { content }),
141
+ },
142
+ ];
143
+ });
144
+ }
145
+
146
+ function overlaysForTool(repoRoot, tool) {
147
+ if (writeTools.has(tool.normalizedToolName)) return [writeOverlay(repoRoot, tool.input)];
148
+ if (editTools.has(tool.normalizedToolName)) return [editOverlay(repoRoot, tool.input)];
149
+ if (multiEditTools.has(tool.normalizedToolName)) return [multiEditOverlay(repoRoot, tool.input)];
150
+ if (applyPatchTools.has(tool.normalizedToolName)) return patchOverlays(repoRoot, tool.input);
151
+ return [];
152
+ }
153
+
154
+ module.exports = { extractToolRequest, isRecord, overlaysForTool };
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { isUtf8 } = require('node:buffer');
4
+ const { spawnSync } = require('node:child_process');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+
9
+ const TYPESCRIPT_AUTHORITIES = [
10
+ 'tsconfig.agent-cli-provider.json',
11
+ 'tsconfig.cluster.json',
12
+ 'tsconfig.hosted-session.json',
13
+ 'tsconfig.hosted-target.json',
14
+ 'tsconfig.target.json',
15
+ ];
16
+
17
+ const DEFAULT_CHECKS = [
18
+ 'typescript.syntax',
19
+ 'typescript.types',
20
+ 'typescript.import-graph',
21
+ 'typescript.dead-code',
22
+ 'typescript.function-metrics',
23
+ 'typescript.relevant-tests',
24
+ 'typescript.file-length',
25
+ 'rust.source-hygiene',
26
+ 'rust.fmt',
27
+ 'rust.cargo-check',
28
+ 'rust.clippy',
29
+ 'rust.rustdoc',
30
+ 'rust.import-graph',
31
+ 'rust.dead-code',
32
+ 'rust.graph-signals',
33
+ 'rust.file-length',
34
+ 'rust.function-metrics',
35
+ 'clone.duplication',
36
+ ].join(',');
37
+
38
+ function run(command, args, options = {}) {
39
+ const result = spawnSync(command, args, {
40
+ cwd: options.cwd,
41
+ encoding: options.encoding ?? 'buffer',
42
+ env: options.env ?? process.env,
43
+ maxBuffer: 64 * 1024 * 1024,
44
+ timeout: options.timeout ?? 300_000,
45
+ });
46
+ if (result.error) throw result.error;
47
+ return result;
48
+ }
49
+
50
+ function git(repo, args, options = {}) {
51
+ const result = run('git', args, {
52
+ cwd: repo,
53
+ encoding: options.encoding ?? 'buffer',
54
+ env: options.env,
55
+ });
56
+ if (result.status !== 0) {
57
+ const stderr = Buffer.isBuffer(result.stderr) ? result.stderr.toString('utf8') : result.stderr;
58
+ throw new Error(`git ${args[0]} failed: ${stderr.trim() || `exit ${result.status}`}`);
59
+ }
60
+ return result.stdout;
61
+ }
62
+
63
+ function withoutGitLocalEnv(repo) {
64
+ const names = git(repo, ['rev-parse', '--local-env-vars'], { encoding: 'utf8' })
65
+ .split('\n')
66
+ .filter(Boolean);
67
+ const env = { ...process.env };
68
+ for (const name of names) delete env[name];
69
+ return env;
70
+ }
71
+
72
+ function valueOption(argv, index) {
73
+ const arg = argv[index];
74
+ for (const key of ['base', 'checks']) {
75
+ const flag = `--${key}`;
76
+ if (arg === flag) return { key, value: argv[index + 1], consumed: 1 };
77
+ if (arg.startsWith(`${flag}=`)) return { key, value: arg.slice(flag.length + 1), consumed: 0 };
78
+ }
79
+ return null;
80
+ }
81
+
82
+ function parseArgs(argv) {
83
+ const options = { base: 'HEAD', checks: DEFAULT_CHECKS, staged: false };
84
+ let index = 0;
85
+ while (index < argv.length) {
86
+ const arg = argv[index];
87
+ if (arg === '--staged') {
88
+ options.staged = true;
89
+ index += 1;
90
+ continue;
91
+ }
92
+ if (arg === '--json') {
93
+ index += 1;
94
+ continue;
95
+ }
96
+ const option = valueOption(argv, index);
97
+ if (!option) throw new Error(`Unsupported Opcore introduced-check argument: ${arg}`);
98
+ options[option.key] = option.value;
99
+ index += option.consumed + 1;
100
+ }
101
+ if (!options.base) throw new Error('--base requires a Git revision');
102
+ if (options.staged && options.base !== 'HEAD') {
103
+ throw new Error('--staged cannot be combined with a non-HEAD --base');
104
+ }
105
+ return options;
106
+ }
107
+
108
+ function nulTokens(buffer) {
109
+ return buffer
110
+ .toString('utf8')
111
+ .split('\0')
112
+ .filter((token) => token.length > 0);
113
+ }
114
+
115
+ function parseChanges(repo, options) {
116
+ const args = options.staged
117
+ ? ['diff', '--cached', '--name-status', '-z', '--find-renames', 'HEAD', '--']
118
+ : ['diff', '--name-status', '-z', '--find-renames', options.base, '--'];
119
+ const tokens = nulTokens(git(repo, args));
120
+ const changes = [];
121
+
122
+ for (let index = 0; index < tokens.length; ) {
123
+ const status = tokens[index++];
124
+ const kind = status[0];
125
+ if (kind === 'R' || kind === 'C') {
126
+ changes.push({ kind, oldPath: tokens[index++], path: tokens[index++] });
127
+ } else if ('AMDT'.includes(kind)) {
128
+ changes.push({ kind, path: tokens[index++] });
129
+ } else {
130
+ throw new Error(`Unsupported Git change status: ${status}`);
131
+ }
132
+ }
133
+
134
+ if (!options.staged) {
135
+ for (const untrackedPath of nulTokens(
136
+ git(repo, ['ls-files', '--others', '--exclude-standard', '-z', '--'])
137
+ )) {
138
+ changes.push({ kind: 'A', path: untrackedPath });
139
+ }
140
+ }
141
+
142
+ const byPath = new Map();
143
+ for (const change of changes) byPath.set(change.path, change);
144
+ return [...byPath.values()];
145
+ }
146
+
147
+ function safePath(root, relativePath) {
148
+ const absolute = path.resolve(root, relativePath);
149
+ const relative = path.relative(root, absolute);
150
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
151
+ throw new Error(`Changed path escapes repository root: ${relativePath}`);
152
+ }
153
+ return absolute;
154
+ }
155
+
156
+ function readAfter(repo, change, staged) {
157
+ let content;
158
+ if (staged) {
159
+ content = git(repo, ['show', `:${change.path}`]);
160
+ } else {
161
+ const absolute = safePath(repo, change.path);
162
+ const stat = fs.lstatSync(absolute);
163
+ if (!stat.isFile() || stat.isSymbolicLink()) {
164
+ throw new Error(`Opcore introduced gate supports regular changed files only: ${change.path}`);
165
+ }
166
+ content = fs.readFileSync(absolute);
167
+ }
168
+ return isUtf8(content) && !content.includes(0) ? content.toString('utf8') : null;
169
+ }
170
+
171
+ function copyPolicy(repo, baseline) {
172
+ const source = path.join(repo, '.opcore', 'config');
173
+ if (!fs.existsSync(source)) return;
174
+ const target = path.join(baseline, '.opcore', 'config');
175
+ fs.mkdirSync(path.dirname(target), { recursive: true });
176
+ fs.copyFileSync(source, target);
177
+ }
178
+
179
+ function linkDependencies(repo, baseline) {
180
+ const source = path.join(repo, 'node_modules');
181
+ const target = path.join(baseline, 'node_modules');
182
+ if (fs.existsSync(source) && !fs.existsSync(target)) fs.symlinkSync(source, target, 'dir');
183
+ }
184
+
185
+ function normalizeBaselineRename(baseline, change) {
186
+ if (change.kind !== 'R') return;
187
+ const source = safePath(baseline, change.oldPath);
188
+ const target = safePath(baseline, change.path);
189
+ if (!fs.existsSync(source)) return;
190
+ fs.mkdirSync(path.dirname(target), { recursive: true });
191
+ fs.renameSync(source, target);
192
+ }
193
+
194
+ function createRequest(repo, baseline, changes, options) {
195
+ const overlays = [];
196
+ const files = [];
197
+ for (const change of changes) {
198
+ normalizeBaselineRename(baseline, change);
199
+ if (change.kind === 'D') {
200
+ overlays.push({ action: 'delete', path: change.path });
201
+ files.push(change.path);
202
+ continue;
203
+ }
204
+ const content = readAfter(repo, change, options.staged);
205
+ if (content === null) continue;
206
+ overlays.push({ action: 'write', path: change.path, content });
207
+ files.push(change.path);
208
+ }
209
+ for (const authority of TYPESCRIPT_AUTHORITIES) {
210
+ if (fs.existsSync(safePath(baseline, authority))) files.push(authority);
211
+ }
212
+
213
+ return {
214
+ requestId: `zeroshot-opcore-introduced-${process.pid}`,
215
+ repo: { repoRoot: baseline },
216
+ scope: { kind: 'files', files: [...new Set(files)] },
217
+ graph: { mode: 'optional', provider: 'opcore-graph' },
218
+ overlays,
219
+ ...(options.checks ? { checks: options.checks.split(',').filter(Boolean) } : {}),
220
+ reportMode: 'introduced',
221
+ };
222
+ }
223
+
224
+ function runOpcoreDirect(repo, args, env = process.env) {
225
+ const entrypoint = require.resolve('opcore');
226
+ return run(process.execPath, [entrypoint, ...args], {
227
+ cwd: repo,
228
+ encoding: 'utf8',
229
+ env: {
230
+ ...env,
231
+ PATH: `${path.join(repo, 'node_modules', '.bin')}${path.delimiter}${env.PATH || ''}`,
232
+ },
233
+ });
234
+ }
235
+
236
+ function emit(result) {
237
+ if (result.stdout) process.stdout.write(result.stdout);
238
+ if (result.stderr) process.stderr.write(result.stderr);
239
+ process.exitCode = result.status ?? 1;
240
+ }
241
+
242
+ function main() {
243
+ const options = parseArgs(process.argv.slice(2));
244
+ const repo = git(process.cwd(), ['rev-parse', '--show-toplevel'], {
245
+ encoding: 'utf8',
246
+ }).trim();
247
+ git(repo, ['rev-parse', '--verify', `${options.base}^{commit}`]);
248
+ const changes = parseChanges(repo, options);
249
+ if (changes.length === 0) {
250
+ emit(runOpcoreDirect(repo, ['check', '--changed', '--json']));
251
+ return;
252
+ }
253
+
254
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'zeroshot-opcore-baseline-'));
255
+ const baseline = path.join(tempRoot, 'repo');
256
+ try {
257
+ const baselineEnv = withoutGitLocalEnv(repo);
258
+ git(repo, ['clone', '--quiet', '--shared', '--no-checkout', repo, baseline], {
259
+ env: baselineEnv,
260
+ });
261
+ git(baseline, ['checkout', '--quiet', '--detach', options.base], {
262
+ env: baselineEnv,
263
+ });
264
+ linkDependencies(repo, baseline);
265
+ copyPolicy(repo, baseline);
266
+ const request = createRequest(repo, baseline, changes, options);
267
+ if (request.overlays.length === 0) {
268
+ emit(runOpcoreDirect(repo, ['check', '--changed', '--json']));
269
+ return;
270
+ }
271
+ const requestPath = path.join(tempRoot, 'validation-request.json');
272
+ fs.writeFileSync(requestPath, `${JSON.stringify(request)}\n`);
273
+ emit(
274
+ runOpcoreDirect(
275
+ baseline,
276
+ ['validate', 'hypothetical', '--request-file', requestPath, '--json'],
277
+ baselineEnv
278
+ )
279
+ );
280
+ } finally {
281
+ fs.rmSync(tempRoot, { recursive: true, force: true });
282
+ }
283
+ }
284
+
285
+ try {
286
+ main();
287
+ } catch (error) {
288
+ console.error(error instanceof Error ? error.message : String(error));
289
+ process.exitCode = 1;
290
+ }
@@ -28,6 +28,7 @@ import { parseCodexEvent } from './codex-parser';
28
28
 
29
29
  const MODEL_CATALOG: Readonly<Record<string, ModelCatalogEntry>> = {
30
30
  'gpt-5.4': { rank: 2 },
31
+ 'openai/gpt-5.4': { rank: 2 },
31
32
  'gpt-5.5': { rank: 3 },
32
33
  'gpt-5.6': { rank: 3 },
33
34
  'gpt-5.6-sol': { rank: 3 },
@@ -36,6 +36,20 @@ const { provisionClaudeCredentials } = require('./claude-credentials');
36
36
 
37
37
  const DEFAULT_WORKTREE_SETUP_TIMEOUT_MS = 15 * 60 * 1000;
38
38
  const FRESH_BASE_REF_PREFIX = 'refs/zeroshot/base-fetch';
39
+ const DEFAULT_MIN_DISK_GB = 10;
40
+
41
+ function minimumDiskGigabytes(environment = process.env) {
42
+ const configured = environment.ZEROSHOT_MIN_DISK_GB;
43
+ if (configured === undefined) return DEFAULT_MIN_DISK_GB;
44
+ if (typeof configured !== 'string' || !/^[1-9][0-9]*$/.test(configured)) {
45
+ throw new Error('ZEROSHOT_MIN_DISK_GB must be an integer between 1 and 1000');
46
+ }
47
+ const minimum = Number(configured);
48
+ if (!Number.isSafeInteger(minimum) || minimum > 1000) {
49
+ throw new Error('ZEROSHOT_MIN_DISK_GB must be an integer between 1 and 1000');
50
+ }
51
+ return minimum;
52
+ }
39
53
 
40
54
  function runSync(command, args, options = {}) {
41
55
  const timeout = options.timeout ?? 30000;
@@ -2026,7 +2040,7 @@ class IsolationManager {
2026
2040
  // Disk space guard: prevent worktree creation when disk is critically low.
2027
2041
  // Uses standalone gc module (no Orchestrator dependency — avoids circular require).
2028
2042
  const { gcOrphanedWorktrees, getDiskSpace, countOrphanedWorktrees } = require('./lib/gc');
2029
- const MIN_DISK_GB = 10;
2043
+ const MIN_DISK_GB = minimumDiskGigabytes();
2030
2044
  const AUTO_GC_THRESHOLD_PERCENT = 80;
2031
2045
 
2032
2046
  const diskCheck = getDiskSpace(os.homedir());
@@ -2399,3 +2413,4 @@ class IsolationManager {
2399
2413
  }
2400
2414
 
2401
2415
  module.exports = IsolationManager;
2416
+ module.exports.minimumDiskGigabytes = minimumDiskGigabytes;
@@ -0,0 +1,48 @@
1
+ interface BoundedJsonErrors {
2
+ readonly tooLarge: () => Error;
3
+ readonly invalid: () => Error;
4
+ }
5
+
6
+ export async function readBoundedJson(
7
+ response: Response,
8
+ maxBytes: number,
9
+ errors: BoundedJsonErrors
10
+ ): Promise<unknown> {
11
+ const declared = response.headers.get('content-length');
12
+ if (declared !== null && Number(declared) > maxBytes) throw errors.tooLarge();
13
+
14
+ const reader = response.body?.getReader();
15
+ if (!reader) {
16
+ const bytes = new Uint8Array(await response.arrayBuffer());
17
+ if (bytes.byteLength > maxBytes) throw errors.tooLarge();
18
+ return parseJson(bytes, errors);
19
+ }
20
+
21
+ const chunks: Uint8Array[] = [];
22
+ let total = 0;
23
+ for (;;) {
24
+ const { done, value } = await reader.read();
25
+ if (done) break;
26
+ total += value.byteLength;
27
+ if (total > maxBytes) {
28
+ await reader.cancel();
29
+ throw errors.tooLarge();
30
+ }
31
+ chunks.push(value);
32
+ }
33
+ const bytes = new Uint8Array(total);
34
+ let offset = 0;
35
+ for (const chunk of chunks) {
36
+ bytes.set(chunk, offset);
37
+ offset += chunk.byteLength;
38
+ }
39
+ return parseJson(bytes, errors);
40
+ }
41
+
42
+ function parseJson(bytes: Uint8Array, errors: BoundedJsonErrors): unknown {
43
+ try {
44
+ return JSON.parse(new TextDecoder().decode(bytes));
45
+ } catch {
46
+ throw errors.invalid();
47
+ }
48
+ }
@@ -0,0 +1,35 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ // @ts-expect-error no declaration file for proper-lockfile
5
+ import lockfile from 'proper-lockfile';
6
+
7
+ const LOCK_STALE_MS = 10_000;
8
+ const LOCK_RETRIES = 100;
9
+ const LOCK_RETRY_MIN_TIMEOUT_MS = 50;
10
+ const LOCK_RETRY_MAX_TIMEOUT_MS = 5_000;
11
+
12
+ export async function acquireTargetLock(targetId: string): Promise<() => Promise<void>> {
13
+ const lockDir = path.join(os.homedir(), '.zeroshot');
14
+ await fs.mkdir(lockDir, { recursive: true });
15
+
16
+ const lockTarget = path.join(lockDir, `target-${targetId}.lock`);
17
+ try {
18
+ await fs.writeFile(lockTarget, '', { flag: 'wx' });
19
+ } catch (err: unknown) {
20
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
21
+ }
22
+
23
+ const release = await lockfile.lock(lockTarget, {
24
+ stale: LOCK_STALE_MS,
25
+ retries: {
26
+ retries: LOCK_RETRIES,
27
+ minTimeout: LOCK_RETRY_MIN_TIMEOUT_MS,
28
+ maxTimeout: LOCK_RETRY_MAX_TIMEOUT_MS,
29
+ },
30
+ });
31
+
32
+ return async () => {
33
+ await release();
34
+ };
35
+ }
@@ -0,0 +1,107 @@
1
+ export class CredentialStoreUnavailableError extends Error {
2
+ constructor(message?: string) {
3
+ super(
4
+ message ??
5
+ 'OS secure store unavailable. Install libsecret (Linux), or run on macOS/Windows. No plaintext fallback.',
6
+ );
7
+ this.name = 'CredentialStoreUnavailableError';
8
+ }
9
+ }
10
+
11
+ export interface TargetCredentialStore {
12
+ get(service: string, account: string): Promise<string | null>;
13
+ set(service: string, account: string, token: string): Promise<void>;
14
+ delete(service: string, account: string): Promise<void>;
15
+ }
16
+
17
+ export function targetServiceKey(targetId: string): string {
18
+ return `zeroshot-target-${targetId}`;
19
+ }
20
+
21
+ export const TARGET_ACCOUNT = 'refresh-token';
22
+
23
+ export class KeyringCredentialStore implements TargetCredentialStore {
24
+ private readonly Entry: new (service: string, account: string) => {
25
+ getPassword(): string;
26
+ setPassword(password: string): void;
27
+ deletePassword(): void;
28
+ };
29
+
30
+ private constructor(
31
+ Entry: new (service: string, account: string) => {
32
+ getPassword(): string;
33
+ setPassword(password: string): void;
34
+ deletePassword(): void;
35
+ },
36
+ ) {
37
+ this.Entry = Entry;
38
+ }
39
+
40
+ static async create(): Promise<KeyringCredentialStore> {
41
+ let keyringModule: { Entry: new (service: string, account: string) => {
42
+ getPassword(): string;
43
+ setPassword(password: string): void;
44
+ deletePassword(): void;
45
+ } };
46
+ try {
47
+ keyringModule = await import('@napi-rs/keyring') as typeof keyringModule;
48
+ } catch {
49
+ throw new CredentialStoreUnavailableError();
50
+ }
51
+ if (!keyringModule.Entry) {
52
+ throw new CredentialStoreUnavailableError();
53
+ }
54
+ return new KeyringCredentialStore(keyringModule.Entry);
55
+ }
56
+
57
+ async get(service: string, account: string): Promise<string | null> {
58
+ try {
59
+ const entry = new this.Entry(service, account);
60
+ return entry.getPassword();
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ async set(service: string, account: string, token: string): Promise<void> {
67
+ const entry = new this.Entry(service, account);
68
+ entry.setPassword(token);
69
+ }
70
+
71
+ async delete(service: string, account: string): Promise<void> {
72
+ try {
73
+ const entry = new this.Entry(service, account);
74
+ entry.deletePassword();
75
+ } catch {
76
+ // Already deleted or not present
77
+ }
78
+ }
79
+ }
80
+
81
+ export class FakeCredentialStore implements TargetCredentialStore {
82
+ private readonly store = new Map<string, string>();
83
+
84
+ private key(service: string, account: string): string {
85
+ return `${service}::${account}`;
86
+ }
87
+
88
+ async get(service: string, account: string): Promise<string | null> {
89
+ return this.store.get(this.key(service, account)) ?? null;
90
+ }
91
+
92
+ async set(service: string, account: string, token: string): Promise<void> {
93
+ this.store.set(this.key(service, account), token);
94
+ }
95
+
96
+ async delete(service: string, account: string): Promise<void> {
97
+ this.store.delete(this.key(service, account));
98
+ }
99
+
100
+ has(service: string, account: string): boolean {
101
+ return this.store.has(this.key(service, account));
102
+ }
103
+
104
+ clear(): void {
105
+ this.store.clear();
106
+ }
107
+ }