@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,199 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+
5
+ import type { HostedOptions, ResolvedInput } from './contracts.ts';
6
+
7
+ const SUBMISSION_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{12}$/i;
8
+ const CAPSULE_SIZES = new Set(['tiny', 'small', 'standard', 'large']);
9
+
10
+ function validRepository(value: string): boolean {
11
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) return false;
12
+ return value.split('/').every((segment) => segment !== '.' && segment !== '..');
13
+ }
14
+
15
+ function validModel(value: string): boolean {
16
+ return (
17
+ value.length <= 256 && /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value)
18
+ );
19
+ }
20
+
21
+ function repositoryFromRemote(cwd = process.cwd()): string | null {
22
+ let remote: string;
23
+ try {
24
+ remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
25
+ cwd,
26
+ encoding: 'utf8',
27
+ stdio: ['ignore', 'pipe', 'ignore'],
28
+ }).trim();
29
+ } catch {
30
+ return null;
31
+ }
32
+ const match = remote.match(
33
+ /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https?:\/\/github\.com\/)([^/]+\/[^/]+?)(?:\.git)?$/
34
+ );
35
+ const repository = match?.[1];
36
+ return repository && validRepository(repository) ? repository : null;
37
+ }
38
+
39
+ function isolationProfile(options: HostedOptions): string {
40
+ return options.pr ? 'isolation.pr@1' : 'isolation.worktree@1';
41
+ }
42
+
43
+ function providerProfile(options: HostedOptions): string {
44
+ return options.pr ? 'provider.codex-openrouter-pr@1' : 'provider.codex-openrouter@1';
45
+ }
46
+
47
+ function promptRequest(prompt: string, options: HostedOptions): Record<string, unknown> {
48
+ return {
49
+ source: 'prompt',
50
+ prompt,
51
+ artifacts: [],
52
+ isolationProfile: isolationProfile(options),
53
+ providerProfile: providerProfile(options),
54
+ };
55
+ }
56
+
57
+ function issueRequest(issue: string, options: HostedOptions): Record<string, unknown> {
58
+ return {
59
+ source: 'issue',
60
+ issue,
61
+ artifacts: [],
62
+ isolationProfile: isolationProfile(options),
63
+ providerProfile: providerProfile(options),
64
+ };
65
+ }
66
+
67
+ function issueInput(value: string, options: HostedOptions): ResolvedInput | null {
68
+ const shorthand = value.match(/^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#([1-9][0-9]*)$/);
69
+ if (shorthand?.[1] && shorthand[2] && validRepository(shorthand[1])) {
70
+ const issue = `https://github.com/${shorthand[1]}/issues/${shorthand[2]}`;
71
+ return { repository: shorthand[1], request: issueRequest(issue, options) };
72
+ }
73
+ let url: URL;
74
+ try {
75
+ url = new URL(value);
76
+ } catch {
77
+ return null;
78
+ }
79
+ const match = url.pathname.match(/^\/([^/]+)\/([^/]+)\/issues\/([1-9][0-9]*)\/?$/);
80
+ const repository = match?.[1] && match[2] ? `${match[1]}/${match[2]}` : '';
81
+ if (url.hostname !== 'github.com' || !match || !validRepository(repository)) return null;
82
+ return { repository, request: issueRequest(url.href, options) };
83
+ }
84
+
85
+ async function readStdin(): Promise<string> {
86
+ if (process.stdin.isTTY) throw new Error('zeroshot run - requires piped input');
87
+ const chunks: Buffer[] = [];
88
+ let bytes = 0;
89
+ for await (const chunk of process.stdin) {
90
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
91
+ bytes += value.length;
92
+ if (bytes > 1024 * 1024) throw new Error('hosted task input exceeds 1 MiB');
93
+ chunks.push(value);
94
+ }
95
+ const value = Buffer.concat(chunks).toString('utf8').trim();
96
+ if (!value) throw new Error('hosted task input is empty');
97
+ return value;
98
+ }
99
+
100
+ function readTaskFile(filename: string): string | null {
101
+ const flags =
102
+ fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0);
103
+ let descriptor: number;
104
+ try {
105
+ descriptor = fs.openSync(filename, flags);
106
+ } catch (error) {
107
+ const code = (error as NodeJS.ErrnoException).code;
108
+ if (code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR') return null;
109
+ if (code === 'ELOOP') throw new Error(`hosted task file must not be a symlink: ${filename}`);
110
+ throw error;
111
+ }
112
+ try {
113
+ if (!fs.fstatSync(descriptor).isFile()) return null;
114
+ return fs.readFileSync(descriptor, 'utf8');
115
+ } finally {
116
+ fs.closeSync(descriptor);
117
+ }
118
+ }
119
+
120
+ export async function resolveHostedInput(
121
+ input: string,
122
+ options: HostedOptions,
123
+ environment: NodeJS.ProcessEnv = process.env
124
+ ): Promise<ResolvedInput> {
125
+ const explicitIssue = issueInput(input, options);
126
+ if (explicitIssue) return explicitIssue;
127
+ const repository =
128
+ options.repository ?? environment['ZEROSHOT_REPOSITORY'] ?? repositoryFromRemote();
129
+ if (!validRepository(repository ?? '')) {
130
+ throw new Error(
131
+ 'hosted runs need a GitHub repository; use org/repo#123, --repository owner/name, ' +
132
+ 'ZEROSHOT_REPOSITORY, or run inside a GitHub checkout'
133
+ );
134
+ }
135
+ if (/^[1-9][0-9]*$/.test(input)) {
136
+ return { repository: repository!, request: issueRequest(input, options) };
137
+ }
138
+ const prompt = input === '-' ? await readStdin() : (readTaskFile(path.resolve(input)) ?? input);
139
+ if (!prompt.trim()) throw new Error('hosted task input is empty');
140
+ return { repository: repository!, request: promptRequest(prompt.trim(), options) };
141
+ }
142
+
143
+ export function githubToken(environment: NodeJS.ProcessEnv): string {
144
+ const configured = environment['GH_TOKEN'] ?? environment['GITHUB_TOKEN'];
145
+ if (configured?.trim()) return configured.trim();
146
+ try {
147
+ const token = execFileSync('gh', ['auth', 'token'], {
148
+ encoding: 'utf8',
149
+ stdio: ['ignore', 'pipe', 'ignore'],
150
+ }).trim();
151
+ if (token) return token;
152
+ } catch {
153
+ // The actionable error below covers missing and unauthenticated gh alike.
154
+ }
155
+ throw new Error('hosted runs require GH_TOKEN/GITHUB_TOKEN or an authenticated gh CLI');
156
+ }
157
+
158
+ export function providerKey(environment: NodeJS.ProcessEnv): string {
159
+ const key = environment['OPENROUTER_API_KEY'];
160
+ if (!key?.trim()) throw new Error('hosted Codex runs require OPENROUTER_API_KEY');
161
+ return key.trim();
162
+ }
163
+
164
+ export function validateHostedOptions(options: HostedOptions): void {
165
+ const unsupported: ReadonlyArray<readonly [keyof HostedOptions, string]> = [
166
+ ['config', '--config'],
167
+ ['docker', '--docker'],
168
+ ['worktree', '--worktree'],
169
+ ['dockerImage', '--docker-image'],
170
+ ['strictSchema', '--strict-schema'],
171
+ ['ship', '--ship'],
172
+ ['prBase', '--pr-base'],
173
+ ['mergeQueue', '--merge-queue'],
174
+ ['closeIssue', '--close-issue'],
175
+ ['workers', '--workers'],
176
+ ['gitlab', '--gitlab'],
177
+ ['jira', '--jira'],
178
+ ['devops', '--devops'],
179
+ ['linear', '--linear'],
180
+ ['mount', '--mount'],
181
+ ['noMounts', '--no-mounts'],
182
+ ['containerHome', '--container-home'],
183
+ ];
184
+ const selected = unsupported
185
+ .filter(([name]) => options[name] !== undefined && options[name] !== false)
186
+ .map(([, flag]) => flag);
187
+ if (options.provider && options.provider !== 'codex') selected.push('--provider');
188
+ if (selected.length) throw new Error(`hosted runs do not support ${selected.join(', ')}`);
189
+ if (!options.target) throw new Error('hosted runs require --target');
190
+ if (options.model !== undefined && !validModel(options.model)) {
191
+ throw new Error('hosted runs require an exact provider/model slug');
192
+ }
193
+ if (!CAPSULE_SIZES.has(options.size ?? 'standard')) {
194
+ throw new Error('hosted runs require --size tiny, small, standard, or large');
195
+ }
196
+ if (options.submissionKey !== undefined && !SUBMISSION_KEY.test(options.submissionKey)) {
197
+ throw new Error('hosted runs require --submission-key to be a random UUID');
198
+ }
199
+ }
@@ -0,0 +1,8 @@
1
+ export { HostedRunHttpError } from './hosted-run/client.ts';
2
+ export { cancelHostedRun, runHosted, statusHostedRun } from './hosted-run/commands.ts';
3
+ export { resolveHostedInput, validateHostedOptions } from './hosted-run/input.ts';
4
+ export type {
5
+ HostedOptions,
6
+ HostedRunDependencies,
7
+ HostedRunIntent,
8
+ } from './hosted-run/contracts.ts';
@@ -0,0 +1,55 @@
1
+ export {
2
+ CredentialStoreUnavailableError,
3
+ KeyringCredentialStore,
4
+ FakeCredentialStore,
5
+ targetServiceKey,
6
+ TARGET_ACCOUNT,
7
+ type TargetCredentialStore,
8
+ } from './credential-store.ts';
9
+
10
+ export { acquireTargetLock } from './credential-lock.ts';
11
+
12
+ export {
13
+ requestDeviceCode,
14
+ pollForToken,
15
+ DeviceFlowDeniedError,
16
+ DeviceFlowExpiredError,
17
+ UnboundSessionError,
18
+ type DeviceCodeResponse,
19
+ type TokenResponse,
20
+ type HttpTransport,
21
+ type Clock,
22
+ } from './device-flow.ts';
23
+
24
+ export {
25
+ addTarget,
26
+ removeTarget,
27
+ getTarget,
28
+ listTargets,
29
+ updateTargetOrganization,
30
+ validateTargetName,
31
+ normalizeAndValidateUrl,
32
+ TargetNameInvalidError,
33
+ TargetNameExistsError,
34
+ TargetNotFoundError,
35
+ TargetUrlInvalidError,
36
+ type TargetRecord,
37
+ type SettingsPort,
38
+ } from './target-registry.ts';
39
+
40
+ export {
41
+ targetLogin,
42
+ refreshAccessToken,
43
+ getAccessTokenProvider,
44
+ revokeAndCleanup,
45
+ LoginRequiredError,
46
+ type BrowserOpener,
47
+ type TargetSessionDeps,
48
+ type TargetAccessTokenProvider,
49
+ } from './target-session.ts';
50
+
51
+ export {
52
+ discoverTargetSessionEndpoints,
53
+ TargetDiscoveryError,
54
+ type TargetSessionEndpoints,
55
+ } from './discovery.ts';
@@ -0,0 +1,174 @@
1
+ import crypto from 'node:crypto';
2
+
3
+ export interface TargetRecord {
4
+ readonly id: string;
5
+ readonly url: string;
6
+ readonly adapterVersion: string;
7
+ readonly deviceToken: string;
8
+ readonly organization?: { readonly id: string; readonly name: string };
9
+ readonly createdAt: string;
10
+ }
11
+
12
+ export class TargetNameInvalidError extends Error {
13
+ constructor(name: string) {
14
+ super(
15
+ `Invalid target name "${name}". Must be 1-64 characters, alphanumeric and hyphens only.`,
16
+ );
17
+ this.name = 'TargetNameInvalidError';
18
+ }
19
+ }
20
+
21
+ export class TargetNameExistsError extends Error {
22
+ constructor(name: string) {
23
+ super(`Target "${name}" already exists. Remove it first or choose a different name.`);
24
+ this.name = 'TargetNameExistsError';
25
+ }
26
+ }
27
+
28
+ export class TargetNotFoundError extends Error {
29
+ constructor(name: string) {
30
+ super(`Target "${name}" not found.`);
31
+ this.name = 'TargetNotFoundError';
32
+ }
33
+ }
34
+
35
+ export class TargetUrlInvalidError extends Error {
36
+ constructor(url: string, reason: string) {
37
+ super(`Invalid target URL "${url}": ${reason}`);
38
+ this.name = 'TargetUrlInvalidError';
39
+ }
40
+ }
41
+
42
+ const TARGET_NAME_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}[a-zA-Z0-9])?$/;
43
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
44
+
45
+ export function validateTargetName(name: string): void {
46
+ if (!TARGET_NAME_PATTERN.test(name) || name.length > 64) {
47
+ throw new TargetNameInvalidError(name);
48
+ }
49
+ }
50
+
51
+ export function normalizeAndValidateUrl(rawUrl: string): string {
52
+ let parsed: URL;
53
+ try {
54
+ parsed = new URL(rawUrl);
55
+ } catch {
56
+ throw new TargetUrlInvalidError(rawUrl, 'not a valid URL');
57
+ }
58
+
59
+ if (parsed.username || parsed.password) {
60
+ throw new TargetUrlInvalidError(rawUrl, 'URL must not contain userinfo');
61
+ }
62
+
63
+ if (parsed.search || parsed.hash) {
64
+ throw new TargetUrlInvalidError(rawUrl, 'URL must not contain query or fragment');
65
+ }
66
+
67
+ const isLoopback = LOOPBACK_HOSTS.has(parsed.hostname);
68
+ if (parsed.protocol !== 'https:' && !isLoopback) {
69
+ throw new TargetUrlInvalidError(rawUrl, 'HTTPS required for non-loopback targets');
70
+ }
71
+
72
+ let normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
73
+ if (normalized.endsWith('/') && normalized.length > 1) {
74
+ normalized = normalized.slice(0, -1);
75
+ }
76
+ return normalized;
77
+ }
78
+
79
+ interface SettingsWithTargets {
80
+ _targets?: Record<string, TargetRecord>;
81
+ [key: string]: unknown;
82
+ }
83
+
84
+ export interface SettingsPort {
85
+ load(): SettingsWithTargets;
86
+ mutate(mutator: (settings: SettingsWithTargets) => void): void;
87
+ }
88
+
89
+ export function addTarget(
90
+ name: string,
91
+ rawUrl: string,
92
+ settings: SettingsPort,
93
+ ): TargetRecord {
94
+ validateTargetName(name);
95
+ const url = normalizeAndValidateUrl(rawUrl);
96
+
97
+ const existing = settings.load();
98
+ if (existing._targets?.[name]) {
99
+ throw new TargetNameExistsError(name);
100
+ }
101
+
102
+ const record: TargetRecord = {
103
+ id: crypto.randomUUID(),
104
+ url,
105
+ adapterVersion: 'v1',
106
+ deviceToken: crypto.randomUUID(),
107
+ createdAt: new Date().toISOString(),
108
+ };
109
+
110
+ settings.mutate((s) => {
111
+ if (!s._targets) {
112
+ s._targets = {};
113
+ }
114
+ s._targets[name] = record;
115
+ });
116
+
117
+ return record;
118
+ }
119
+
120
+ export function removeTarget(
121
+ name: string,
122
+ settings: SettingsPort,
123
+ ): TargetRecord {
124
+ const existing = settings.load();
125
+ const record = existing._targets?.[name];
126
+ if (!record) {
127
+ throw new TargetNotFoundError(name);
128
+ }
129
+
130
+ settings.mutate((s) => {
131
+ if (s._targets) {
132
+ delete s._targets[name];
133
+ }
134
+ });
135
+
136
+ return record;
137
+ }
138
+
139
+ export function getTarget(
140
+ name: string,
141
+ settings: SettingsPort,
142
+ ): TargetRecord | null {
143
+ const existing = settings.load();
144
+ return existing._targets?.[name] ?? null;
145
+ }
146
+
147
+ export function listTargets(
148
+ settings: SettingsPort,
149
+ ): Array<{ name: string; record: TargetRecord }> {
150
+ const existing = settings.load();
151
+ const targets = existing._targets ?? {};
152
+ return Object.entries(targets).map(([name, record]) => ({ name, record }));
153
+ }
154
+
155
+ export function updateTargetOrganization(
156
+ name: string,
157
+ organization: { id: string; name: string },
158
+ settings: SettingsPort,
159
+ ): void {
160
+ const existing = settings.load();
161
+ if (!existing._targets?.[name]) {
162
+ throw new TargetNotFoundError(name);
163
+ }
164
+
165
+ settings.mutate((s) => {
166
+ const target = s._targets?.[name];
167
+ if (target) {
168
+ (s._targets as Record<string, TargetRecord>)[name] = {
169
+ ...target,
170
+ organization,
171
+ };
172
+ }
173
+ });
174
+ }
@@ -0,0 +1,253 @@
1
+ import type { TargetCredentialStore } from './credential-store.ts';
2
+ import type { TargetRecord, SettingsPort } from './target-registry.ts';
3
+ import {
4
+ requestDeviceCode,
5
+ pollForToken,
6
+ UnboundSessionError,
7
+ type HttpTransport,
8
+ type Clock,
9
+ type TokenResponse,
10
+ } from './device-flow.ts';
11
+ import { targetServiceKey, TARGET_ACCOUNT } from './credential-store.ts';
12
+ import { updateTargetOrganization } from './target-registry.ts';
13
+
14
+ export class LoginRequiredError extends Error {
15
+ readonly targetName: string;
16
+ constructor(targetName: string) {
17
+ super(`Login required. Run: zeroshot target login ${targetName}`);
18
+ this.name = 'LoginRequiredError';
19
+ this.targetName = targetName;
20
+ }
21
+ }
22
+
23
+ export interface BrowserOpener {
24
+ open(url: string): Promise<void>;
25
+ }
26
+
27
+ export interface TargetSessionDeps {
28
+ readonly http: HttpTransport;
29
+ readonly clock: Clock;
30
+ readonly browserOpener: BrowserOpener;
31
+ readonly stderr: { write(s: string): void };
32
+ readonly discoveryEndpoints: {
33
+ readonly deviceAuthorizationEndpoint: string;
34
+ readonly tokenEndpoint: string;
35
+ readonly revocationEndpoint?: string;
36
+ readonly clientId: string;
37
+ readonly capsuleApiBaseUrl: string;
38
+ };
39
+ }
40
+
41
+ export async function targetLogin(
42
+ targetName: string,
43
+ target: TargetRecord,
44
+ credentialStore: TargetCredentialStore,
45
+ acquireLock: () => Promise<() => Promise<void>>,
46
+ settings: SettingsPort,
47
+ deps: TargetSessionDeps
48
+ ): Promise<{ organization: { id: string; name: string } }> {
49
+ const { http, clock, browserOpener, stderr, discoveryEndpoints } = deps;
50
+ const { deviceAuthorizationEndpoint, tokenEndpoint, clientId } = discoveryEndpoints;
51
+
52
+ const codeResponse = await requestDeviceCode(deviceAuthorizationEndpoint, clientId, http);
53
+
54
+ stderr.write(
55
+ `\nOpen this URL to authorize:\n ${codeResponse.verification_uri}\n\nEnter code: ${codeResponse.user_code}\n\n`
56
+ );
57
+
58
+ if (codeResponse.verification_uri_complete) {
59
+ try {
60
+ await browserOpener.open(codeResponse.verification_uri_complete);
61
+ } catch {
62
+ // Browser open is best-effort
63
+ }
64
+ }
65
+
66
+ const tokenResponse = await pollForToken(
67
+ tokenEndpoint,
68
+ clientId,
69
+ codeResponse.device_code,
70
+ codeResponse.interval,
71
+ codeResponse.expires_in,
72
+ http,
73
+ clock,
74
+ undefined,
75
+ { token: target.deviceToken, label: 'Zeroshot CLI' }
76
+ );
77
+
78
+ if (!tokenResponse.organization) {
79
+ throw new UnboundSessionError(codeResponse.verification_uri);
80
+ }
81
+
82
+ const serviceKey = targetServiceKey(target.id);
83
+ const release = await acquireLock();
84
+ try {
85
+ await credentialStore.set(serviceKey, TARGET_ACCOUNT, tokenResponse.refresh_token);
86
+ } finally {
87
+ await release();
88
+ }
89
+
90
+ updateTargetOrganization(targetName, tokenResponse.organization, settings);
91
+
92
+ return { organization: tokenResponse.organization };
93
+ }
94
+
95
+ export async function refreshAccessToken(
96
+ targetName: string,
97
+ target: TargetRecord,
98
+ credentialStore: TargetCredentialStore,
99
+ acquireLock: () => Promise<() => Promise<void>>,
100
+ deps: Pick<TargetSessionDeps, 'http' | 'discoveryEndpoints'>
101
+ ): Promise<{ accessToken: string; expiresIn: number }> {
102
+ const { http, discoveryEndpoints } = deps;
103
+ const { tokenEndpoint, revocationEndpoint, clientId } = discoveryEndpoints;
104
+ const serviceKey = targetServiceKey(target.id);
105
+
106
+ const release = await acquireLock();
107
+ try {
108
+ const currentRefreshToken = await credentialStore.get(serviceKey, TARGET_ACCOUNT);
109
+ if (!currentRefreshToken) {
110
+ throw new LoginRequiredError(targetName);
111
+ }
112
+
113
+ const body = new URLSearchParams({
114
+ grant_type: 'refresh_token',
115
+ refresh_token: currentRefreshToken,
116
+ client_id: clientId,
117
+ audience: 'capsule',
118
+ });
119
+
120
+ let tokenResponse: TokenResponse;
121
+ const response = await http.fetch(tokenEndpoint, {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
124
+ body: body.toString(),
125
+ redirect: 'error',
126
+ });
127
+
128
+ if (!response.ok) {
129
+ const errorBody = (await response.json().catch(() => ({ error: 'unknown' }))) as {
130
+ error: string;
131
+ };
132
+ if (errorBody.error === 'invalid_grant') {
133
+ await credentialStore.delete(serviceKey, TARGET_ACCOUNT);
134
+ throw new LoginRequiredError(targetName);
135
+ }
136
+ throw new Error(`Token refresh failed (${response.status}): ${errorBody.error}`);
137
+ }
138
+
139
+ tokenResponse = (await response.json()) as TokenResponse;
140
+
141
+ try {
142
+ await credentialStore.set(serviceKey, TARGET_ACCOUNT, tokenResponse.refresh_token);
143
+ } catch {
144
+ await bestEffortRevoke(tokenResponse.refresh_token, revocationEndpoint, clientId, http);
145
+ await credentialStore.delete(serviceKey, TARGET_ACCOUNT);
146
+ throw new LoginRequiredError(targetName);
147
+ }
148
+
149
+ return { accessToken: tokenResponse.access_token, expiresIn: tokenResponse.expires_in };
150
+ } finally {
151
+ await release();
152
+ }
153
+ }
154
+
155
+ async function bestEffortRevoke(
156
+ token: string,
157
+ revocationEndpoint: string | undefined,
158
+ clientId: string,
159
+ http: HttpTransport
160
+ ): Promise<void> {
161
+ if (!revocationEndpoint) return;
162
+ try {
163
+ const body = new URLSearchParams({
164
+ token,
165
+ client_id: clientId,
166
+ token_type_hint: 'refresh_token',
167
+ });
168
+ await http.fetch(revocationEndpoint, {
169
+ method: 'POST',
170
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
171
+ body: body.toString(),
172
+ redirect: 'error',
173
+ });
174
+ } catch {
175
+ // Best-effort
176
+ }
177
+ }
178
+
179
+ export interface TargetAccessTokenProvider {
180
+ getAccessToken(signal?: AbortSignal): Promise<string>;
181
+ }
182
+
183
+ export function getAccessTokenProvider(
184
+ targetName: string,
185
+ target: TargetRecord,
186
+ credentialStore: TargetCredentialStore,
187
+ acquireLock: () => Promise<() => Promise<void>>,
188
+ deps: Pick<TargetSessionDeps, 'http' | 'discoveryEndpoints'>,
189
+ clock: Clock = { now: () => Date.now() }
190
+ ): TargetAccessTokenProvider {
191
+ let cachedToken: string | null = null;
192
+ let expiresAt = 0;
193
+
194
+ return {
195
+ async getAccessToken(_signal?: AbortSignal): Promise<string> {
196
+ if (cachedToken && clock.now() < expiresAt - 30_000) {
197
+ return cachedToken;
198
+ }
199
+ const result = await refreshAccessToken(
200
+ targetName,
201
+ target,
202
+ credentialStore,
203
+ acquireLock,
204
+ deps
205
+ );
206
+ cachedToken = result.accessToken;
207
+ expiresAt = clock.now() + result.expiresIn * 1000;
208
+ return result.accessToken;
209
+ },
210
+ };
211
+ }
212
+
213
+ export async function revokeAndCleanup(
214
+ target: TargetRecord,
215
+ credentialStore: TargetCredentialStore,
216
+ acquireLock: () => Promise<() => Promise<void>>,
217
+ deps: Pick<TargetSessionDeps, 'http' | 'discoveryEndpoints'>,
218
+ force: boolean
219
+ ): Promise<void> {
220
+ const { http, discoveryEndpoints } = deps;
221
+ const { revocationEndpoint, clientId } = discoveryEndpoints;
222
+ const serviceKey = targetServiceKey(target.id);
223
+
224
+ const release = await acquireLock();
225
+ try {
226
+ const refreshToken = await credentialStore.get(serviceKey, TARGET_ACCOUNT);
227
+ if (refreshToken && revocationEndpoint) {
228
+ const body = new URLSearchParams({
229
+ token: refreshToken,
230
+ client_id: clientId,
231
+ token_type_hint: 'refresh_token',
232
+ });
233
+ try {
234
+ const response = await http.fetch(revocationEndpoint, {
235
+ method: 'POST',
236
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
237
+ body: body.toString(),
238
+ redirect: 'error',
239
+ });
240
+ if (!response.ok && !force) {
241
+ throw new Error(
242
+ `Remote revocation failed (${response.status}). Use --force to remove anyway.`
243
+ );
244
+ }
245
+ } catch (err) {
246
+ if (!force) throw err;
247
+ }
248
+ }
249
+ await credentialStore.delete(serviceKey, TARGET_ACCOUNT);
250
+ } finally {
251
+ await release();
252
+ }
253
+ }