@the-open-engine/zeroshot 6.26.0 → 6.28.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 (49) hide show
  1. package/cli/index.js +211 -2
  2. package/lib/cluster/client.cjs +30 -5
  3. package/lib/cluster/client.d.ts +11 -1
  4. package/lib/cluster/client.mjs +29 -5
  5. package/lib/cluster/connection.cjs +38 -2
  6. package/lib/cluster/connection.d.ts +3 -0
  7. package/lib/cluster/connection.mjs +37 -1
  8. package/lib/cluster/index.cjs +3 -1
  9. package/lib/cluster/index.d.ts +3 -3
  10. package/lib/cluster/index.mjs +2 -2
  11. package/lib/hosted-session/coordinator.cjs +101 -0
  12. package/lib/hosted-session/coordinator.d.ts +9 -0
  13. package/lib/hosted-session/coordinator.mjs +97 -0
  14. package/lib/hosted-session/index.cjs +5 -0
  15. package/lib/hosted-session/index.d.ts +2 -0
  16. package/lib/hosted-session/index.mjs +1 -0
  17. package/lib/hosted-session/types.cjs +2 -0
  18. package/lib/hosted-session/types.d.ts +20 -0
  19. package/lib/hosted-session/types.mjs +1 -0
  20. package/lib/target/credential-lock.d.ts +1 -0
  21. package/lib/target/credential-lock.js +38 -0
  22. package/lib/target/credential-store.d.ts +27 -0
  23. package/lib/target/credential-store.js +113 -0
  24. package/lib/target/device-flow.d.ts +38 -0
  25. package/lib/target/device-flow.js +104 -0
  26. package/lib/target/discovery.d.ts +11 -0
  27. package/lib/target/discovery.js +120 -0
  28. package/lib/target/index.d.ts +6 -0
  29. package/lib/target/index.js +38 -0
  30. package/lib/target/target-registry.d.ts +45 -0
  31. package/lib/target/target-registry.js +132 -0
  32. package/lib/target/target-session.d.ts +39 -0
  33. package/lib/target/target-session.js +162 -0
  34. package/package.json +20 -8
  35. package/scripts/build-cluster.js +21 -7
  36. package/src/cluster/client.ts +45 -5
  37. package/src/cluster/connection.ts +32 -1
  38. package/src/cluster/index.ts +4 -2
  39. package/src/cluster/ws.d.ts +1 -0
  40. package/src/hosted-session/coordinator.ts +110 -0
  41. package/src/hosted-session/index.ts +2 -0
  42. package/src/hosted-session/types.ts +21 -0
  43. package/src/target/credential-lock.ts +35 -0
  44. package/src/target/credential-store.ts +107 -0
  45. package/src/target/device-flow.ts +157 -0
  46. package/src/target/discovery.ts +149 -0
  47. package/src/target/index.ts +55 -0
  48. package/src/target/target-registry.ts +174 -0
  49. package/src/target/target-session.ts +249 -0
@@ -4,8 +4,10 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
6
  const root = path.resolve(__dirname, '..');
7
- const buildRoot = path.join(root, '.cluster-build');
8
- const outputRoot = path.join(root, 'lib/cluster');
7
+ const clusterBuildRoot = path.join(root, '.cluster-build');
8
+ const hostedSessionBuildRoot = path.join(root, '.hosted-session-build');
9
+ const clusterOutputRoot = path.join(root, 'lib/cluster');
10
+ const hostedSessionOutputRoot = path.join(root, 'lib/hosted-session');
9
11
 
10
12
  function filesBelow(directory) {
11
13
  const entries = fs.readdirSync(directory, { withFileTypes: true });
@@ -15,7 +17,7 @@ function filesBelow(directory) {
15
17
  });
16
18
  }
17
19
 
18
- function copyBuild(sourceRoot, mode) {
20
+ function copyBuild(sourceRoot, outputRoot, mode) {
19
21
  for (const source of filesBelow(sourceRoot)) {
20
22
  const relative = path.relative(sourceRoot, source);
21
23
  const extension = path.extname(relative);
@@ -37,7 +39,19 @@ function copyBuild(sourceRoot, mode) {
37
39
  }
38
40
  }
39
41
 
40
- fs.rmSync(outputRoot, { recursive: true, force: true });
41
- copyBuild(path.join(buildRoot, 'cjs'), 'cjs');
42
- copyBuild(path.join(buildRoot, 'esm'), 'esm');
43
- fs.rmSync(buildRoot, { recursive: true, force: true });
42
+ fs.rmSync(clusterOutputRoot, { recursive: true, force: true });
43
+ fs.rmSync(hostedSessionOutputRoot, { recursive: true, force: true });
44
+ copyBuild(path.join(clusterBuildRoot, 'cjs'), clusterOutputRoot, 'cjs');
45
+ copyBuild(path.join(clusterBuildRoot, 'esm'), clusterOutputRoot, 'esm');
46
+ copyBuild(
47
+ path.join(hostedSessionBuildRoot, 'cjs', 'hosted-session'),
48
+ hostedSessionOutputRoot,
49
+ 'cjs'
50
+ );
51
+ copyBuild(
52
+ path.join(hostedSessionBuildRoot, 'esm', 'hosted-session'),
53
+ hostedSessionOutputRoot,
54
+ 'esm'
55
+ );
56
+ fs.rmSync(clusterBuildRoot, { recursive: true, force: true });
57
+ fs.rmSync(hostedSessionBuildRoot, { recursive: true, force: true });
@@ -16,16 +16,25 @@ import {
16
16
  WatchSubscriptionStream,
17
17
  } from './subscriptions.js';
18
18
 
19
+ export interface WebSocketFactoryOptions {
20
+ readonly headers?: Readonly<Record<string, string>>;
21
+ }
19
22
  export interface ConnectOptions {
20
23
  readonly protocols?: string | readonly string[];
21
- readonly webSocketFactory?: (url: string, protocols?: string | readonly string[]) => WebSocketLike | Promise<WebSocketLike>;
24
+ readonly webSocketFactory?: (url: string, protocols?: string | readonly string[], options?: WebSocketFactoryOptions) => WebSocketLike | Promise<WebSocketLike>;
22
25
  readonly signal?: AbortSignal;
23
26
  readonly initialize?: InitializeParams;
27
+ readonly headers?: Readonly<Record<string, string>>;
24
28
  }
25
29
  export interface WatchSubscription { readonly result: WatchResult; readonly stream: WatchSubscriptionStream; }
26
30
  export interface LogsSubscription { readonly result: LogsResult; readonly stream: LogsSubscriptionStream; }
27
31
  export interface AgentAttachSubscription { readonly result: AgentAttachResult; readonly stream: AgentAttachSubscriptionStream; }
28
32
  export interface CoherentWatchSubscription extends WatchSubscription { readonly snapshot: GetResult; }
33
+ export interface ConnectInitializedResult {
34
+ readonly connection: Connection;
35
+ readonly client: ClusterClient;
36
+ readonly initializeResult: InitializeResult;
37
+ }
29
38
 
30
39
  export class ClusterClient {
31
40
  constructor(readonly connection: Connection) {}
@@ -114,6 +123,7 @@ export class ClusterClient {
114
123
  async function defaultWebSocketFactory(
115
124
  url: string,
116
125
  protocols?: string | readonly string[],
126
+ options?: WebSocketFactoryOptions,
117
127
  ): Promise<WebSocketLike> {
118
128
  const globalWebSocket = (globalThis as {
119
129
  readonly WebSocket?: new (
@@ -121,9 +131,16 @@ async function defaultWebSocketFactory(
121
131
  protocols?: string | readonly string[],
122
132
  ) => WebSocketLike;
123
133
  }).WebSocket;
124
- if (globalWebSocket) return new globalWebSocket(url, protocols);
134
+ if (globalWebSocket) {
135
+ if (options?.headers && Object.keys(options.headers).length > 0) {
136
+ throw new ClusterConfigError(
137
+ 'WebSocket upgrade headers require the ws library; the browser WebSocket API cannot carry request headers',
138
+ 'HEADERS_UNSUPPORTED',
139
+ );
140
+ }
141
+ return new globalWebSocket(url, protocols);
142
+ }
125
143
  try {
126
- // Dynamic loading keeps the optional Node runtime off the browser/global-WebSocket path.
127
144
  const imported: unknown = await import('ws');
128
145
  const candidate = imported !== null && typeof imported === 'object' && 'default' in imported
129
146
  ? imported.default
@@ -134,8 +151,9 @@ async function defaultWebSocketFactory(
134
151
  const Constructor = candidate as new (
135
152
  url: string,
136
153
  protocols?: string | readonly string[],
154
+ options?: { readonly headers?: Readonly<Record<string, string>> },
137
155
  ) => WebSocketLike;
138
- return new Constructor(url, protocols);
156
+ return options?.headers ? new Constructor(url, protocols, { headers: options.headers }) : new Constructor(url, protocols);
139
157
  } catch (cause) {
140
158
  throw new ClusterConfigError(
141
159
  "No WebSocket runtime is available; install 'ws' or pass webSocketFactory",
@@ -181,7 +199,7 @@ export async function connect(url: string, options: ConnectOptions = {}): Promis
181
199
  const factory = options.webSocketFactory ?? defaultWebSocketFactory;
182
200
  let socket: WebSocketLike | undefined;
183
201
  try {
184
- socket = await factory(url, options.protocols);
202
+ socket = await factory(url, options.protocols, options.headers ? { headers: options.headers } : undefined);
185
203
  await waitForOpen(socket, options.signal);
186
204
  const connection = new Connection(socket);
187
205
  await new ClusterClient(connection).initialize(
@@ -197,3 +215,25 @@ export async function connect(url: string, options: ConnectOptions = {}): Promis
197
215
  throw error;
198
216
  }
199
217
  }
218
+
219
+ export async function connectInitialized(url: string, options: ConnectOptions = {}): Promise<ConnectInitializedResult> {
220
+ const factory = options.webSocketFactory ?? defaultWebSocketFactory;
221
+ let socket: WebSocketLike | undefined;
222
+ try {
223
+ socket = await factory(url, options.protocols, options.headers ? { headers: options.headers } : undefined);
224
+ await waitForOpen(socket, options.signal);
225
+ const connection = new Connection(socket);
226
+ const client = new ClusterClient(connection);
227
+ const initializeResult = await client.initialize(
228
+ options.initialize,
229
+ options.signal === undefined ? {} : { signal: options.signal },
230
+ );
231
+ return { connection, client, initializeResult };
232
+ } catch (error) {
233
+ if (socket) {
234
+ try { await socket.close(); }
235
+ catch { /* preserve the construction error */ }
236
+ }
237
+ throw error;
238
+ }
239
+ }
@@ -27,6 +27,20 @@ export const CONNECTION_TRANSITIONS: Readonly<Record<ConnectionState, readonly C
27
27
  CLOSED: Object.freeze([] as const),
28
28
  });
29
29
  export const PROTOCOL_DIAGNOSTIC_CAPACITY = 128;
30
+ export const CLOSE_REASON_MAX_BYTES = 123;
31
+ const CLOSE_REASON_ENCODER = new TextEncoder();
32
+ function boundedCloseReason(reason: string): string {
33
+ const retained: string[] = [];
34
+ const scratch = new Uint8Array(4);
35
+ let bytes = 0;
36
+ for (const codePoint of reason) {
37
+ const { written } = CLOSE_REASON_ENCODER.encodeInto(codePoint, scratch);
38
+ if (bytes + written > CLOSE_REASON_MAX_BYTES) break;
39
+ retained.push(codePoint);
40
+ bytes += written;
41
+ }
42
+ return retained.join('');
43
+ }
30
44
  export interface CallOptions { readonly signal?: AbortSignal; readonly requestTimeoutMs?: number; }
31
45
 
32
46
  type Deferred<T> = {
@@ -70,6 +84,8 @@ export class Connection {
70
84
  readonly #removeSocketListeners: Array<() => void> = [];
71
85
  readonly #ownedSubscriptions = new WeakSet<SubscriptionRegistration>();
72
86
  #closePromise?: Promise<void>;
87
+ #closeCode: number | undefined;
88
+ #closeReason: string | undefined;
73
89
  readonly closeDiagnostics: unknown[] = [];
74
90
  readonly protocolDiagnostics: ClusterProtocolError[] = [];
75
91
 
@@ -80,12 +96,14 @@ export class Connection {
80
96
  this.#removeSocketListeners.push(
81
97
  addSocketListener(socket, 'message', (event) => this.#onMessage(event)),
82
98
  addSocketListener(socket, 'error', () => { void this.#startClose(false); }),
83
- addSocketListener(socket, 'close', () => { void this.#startClose(false); }),
99
+ addSocketListener(socket, 'close', (...args: unknown[]) => { this.#captureCloseState(args); void this.#startClose(false); }),
84
100
  );
85
101
  }
86
102
  get state(): ConnectionState { return this.#state; }
87
103
  get pendingSize(): number { return this.#pending.size; }
88
104
  get subscriptionCount(): number { return this.#subscriptions.size; }
105
+ get closeCode(): number | undefined { return this.#closeCode; }
106
+ get closeReason(): string | undefined { return this.#closeReason; }
89
107
  call<M extends UnaryClusterMethod>(method: M, params: ClusterMethodParams[M], options: CallOptions = {}): Promise<ClusterMethodResults[M]> {
90
108
  if (!(UNARY_METHODS as readonly string[]).includes(method)) {
91
109
  throw new ClusterConfigError(`${method} is a subscription method`, 'INVALID_METHOD');
@@ -266,6 +284,19 @@ export class Connection {
266
284
  if (this.protocolDiagnostics.length === PROTOCOL_DIAGNOSTIC_CAPACITY) this.protocolDiagnostics.shift();
267
285
  this.protocolDiagnostics.push(new ClusterProtocolError(message, 'INVALID_PEER_FRAME', cause === undefined ? undefined : { cause }));
268
286
  }
287
+ #captureCloseState(args: unknown[]): void {
288
+ if (args.length === 0) return;
289
+ const first = args[0];
290
+ if (typeof first === 'number') {
291
+ this.#closeCode = first;
292
+ const raw = args.length > 1 ? String(args[1]) : undefined;
293
+ this.#closeReason = raw === undefined ? undefined : boundedCloseReason(raw);
294
+ } else if (first !== null && typeof first === 'object') {
295
+ const event = first as { code?: unknown; reason?: unknown };
296
+ if (typeof event.code === 'number') this.#closeCode = event.code;
297
+ if (typeof event.reason === 'string') this.#closeReason = boundedCloseReason(event.reason);
298
+ }
299
+ }
269
300
  #startClose(sendCancels: boolean): Promise<void> {
270
301
  if (this.#closePromise) return this.#closePromise; if (this.#state === 'CLOSED') return Promise.resolve();
271
302
  this.#transition('CLOSING'); this.#closePromise = Promise.resolve().then(() => this.#finishClose(sendCancels)); return this.#closePromise;
@@ -14,7 +14,7 @@ export {
14
14
  export { assertGraphProfile, assertGraphProfileSupported, assertGraphSpec } from './validators.js';
15
15
  export * from './payload-value.js';
16
16
  export * from './json-source.js';
17
- export { CONNECTION_TRANSITIONS, PROTOCOL_DIAGNOSTIC_CAPACITY, Connection } from './connection.js';
17
+ export { CLOSE_REASON_MAX_BYTES, CONNECTION_TRANSITIONS, PROTOCOL_DIAGNOSTIC_CAPACITY, Connection } from './connection.js';
18
18
  export type {
19
19
  CallOptions,
20
20
  ConnectionState,
@@ -32,11 +32,13 @@ export type {
32
32
  WatchSubscriptionItem,
33
33
  WatchSubscriptionClosedItem,
34
34
  } from './subscriptions.js';
35
- export { ClusterClient, connect } from './client.js';
35
+ export { ClusterClient, connect, connectInitialized } from './client.js';
36
36
  export type {
37
37
  AgentAttachSubscription,
38
38
  CoherentWatchSubscription,
39
+ ConnectInitializedResult,
39
40
  ConnectOptions,
40
41
  LogsSubscription,
41
42
  WatchSubscription,
43
+ WebSocketFactoryOptions,
42
44
  } from './client.js';
@@ -2,6 +2,7 @@ declare module 'ws' {
2
2
  const WebSocket: new (
3
3
  url: string,
4
4
  protocols?: string | readonly string[],
5
+ options?: { readonly headers?: Readonly<Record<string, string>> },
5
6
  ) => import('./index.js').WebSocketLike;
6
7
  export default WebSocket;
7
8
  }
@@ -0,0 +1,110 @@
1
+ import { ClusterConfigError, connectInitialized } from '../cluster/index.js';
2
+ import type { ServerCapabilities, GraphProfile } from '../cluster/index.js';
3
+ import type { ConnectOptions } from '../cluster/index.js';
4
+ import type { AccessResponse, HostedSessionInit, InitializedSession } from './types.js';
5
+
6
+ function combineSignals(signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
7
+ const defined = signals.filter((s): s is AbortSignal => s !== undefined);
8
+ if (defined.length === 0) return undefined;
9
+ if (defined.length === 1) return defined[0];
10
+ return AbortSignal.any(defined);
11
+ }
12
+
13
+ export class HostedSessionCoordinator {
14
+ readonly #getAccess: (signal?: AbortSignal) => Promise<AccessResponse>;
15
+ readonly #connectOptions: Omit<ConnectOptions, 'headers' | 'signal'> | undefined;
16
+ readonly #clock: { now(): number };
17
+ readonly #closeController = new AbortController();
18
+ #referenceCapabilities: ServerCapabilities | undefined;
19
+ #closed = false;
20
+
21
+ constructor(init: HostedSessionInit) {
22
+ this.#getAccess = init.getAccess;
23
+ this.#connectOptions = init.connectOptions;
24
+ this.#clock = init.clock ?? Date;
25
+ }
26
+
27
+ async open(signal?: AbortSignal): Promise<InitializedSession> {
28
+ this.#requireNotClosed();
29
+ const session = await this.#createSession(signal);
30
+ this.#referenceCapabilities = session.initializeResult.capabilities;
31
+ return session;
32
+ }
33
+
34
+ async replace(signal?: AbortSignal): Promise<InitializedSession> {
35
+ this.#requireNotClosed();
36
+ const session = await this.#createSession(signal);
37
+ this.#verifyCapabilities(session.initializeResult.capabilities, session);
38
+ return session;
39
+ }
40
+
41
+ renewalDeadline(access: AccessResponse, receivedAt: number): number {
42
+ const expiresAt = Date.parse(access.expiresAt);
43
+ if (Number.isNaN(expiresAt)) {
44
+ throw new ClusterConfigError(`invalid expiresAt: ${access.expiresAt}`, 'INVALID_EXPIRY');
45
+ }
46
+ const lifetime = expiresAt - receivedAt;
47
+ return Math.min(expiresAt - 30_000, receivedAt + 0.8 * lifetime);
48
+ }
49
+
50
+ async close(): Promise<void> {
51
+ this.#closed = true;
52
+ this.#closeController.abort();
53
+ }
54
+
55
+ async #createSession(signal?: AbortSignal): Promise<InitializedSession> {
56
+ const combined = combineSignals([signal, this.#closeController.signal]);
57
+ const access = await this.#getAccess(combined);
58
+ const expiresAt = Date.parse(access.expiresAt);
59
+ if (Number.isNaN(expiresAt)) {
60
+ throw new ClusterConfigError(`invalid expiresAt: ${access.expiresAt}`, 'INVALID_EXPIRY');
61
+ }
62
+ if (expiresAt <= this.#clock.now()) {
63
+ throw new ClusterConfigError('access token is already expired', 'ACCESS_EXPIRED');
64
+ }
65
+
66
+ let endpoint: URL;
67
+ try {
68
+ endpoint = new URL(access.endpoint);
69
+ } catch {
70
+ throw new ClusterConfigError('hosted access endpoint is invalid', 'INVALID_ENDPOINT');
71
+ }
72
+ if (endpoint.protocol !== 'wss:') {
73
+ throw new ClusterConfigError('hosted access endpoint must use wss', 'INSECURE_ENDPOINT');
74
+ }
75
+
76
+ return connectInitialized(endpoint.href, {
77
+ ...this.#connectOptions,
78
+ headers: { Authorization: `Bearer ${access.token}` },
79
+ ...(combined !== undefined ? { signal: combined } : {}),
80
+ });
81
+ }
82
+
83
+ #verifyCapabilities(incoming: ServerCapabilities, session: InitializedSession): void {
84
+ if (!this.#referenceCapabilities) return;
85
+ const ref = this.#referenceCapabilities;
86
+ const mismatches: string[] = [];
87
+
88
+ if (ref.graphProfiles) {
89
+ const incomingProfiles = new Set<GraphProfile>(incoming.graphProfiles ?? []);
90
+ for (const profile of ref.graphProfiles) {
91
+ if (!incomingProfiles.has(profile)) mismatches.push(`missing graphProfile: ${profile}`);
92
+ }
93
+ }
94
+ if (ref.logs && !incoming.logs) mismatches.push('missing capability: logs');
95
+ if (ref.agentAttach && !incoming.agentAttach)
96
+ mismatches.push('missing capability: agentAttach');
97
+
98
+ if (mismatches.length > 0) {
99
+ void session.connection.close();
100
+ throw new ClusterConfigError(
101
+ `replacement capabilities incompatible: ${mismatches.join(', ')}`,
102
+ 'INCOMPATIBLE_CAPABILITIES'
103
+ );
104
+ }
105
+ }
106
+
107
+ #requireNotClosed(): void {
108
+ if (this.#closed) throw new ClusterConfigError('coordinator is closed', 'COORDINATOR_CLOSED');
109
+ }
110
+ }
@@ -0,0 +1,2 @@
1
+ export { HostedSessionCoordinator } from './coordinator.js';
2
+ export type { AccessResponse, HostedSessionInit, InitializedSession } from './types.js';
@@ -0,0 +1,21 @@
1
+ import type { Connection } from '../cluster/index.js';
2
+ import type { ClusterClient, ConnectOptions } from '../cluster/index.js';
3
+ import type { InitializeResult } from '../cluster/index.js';
4
+
5
+ export interface AccessResponse {
6
+ readonly endpoint: string;
7
+ readonly token: string;
8
+ readonly expiresAt: string;
9
+ }
10
+
11
+ export interface HostedSessionInit {
12
+ readonly getAccess: (signal?: AbortSignal) => Promise<AccessResponse>;
13
+ readonly connectOptions?: Omit<ConnectOptions, 'headers' | 'signal'>;
14
+ readonly clock?: { now(): number };
15
+ }
16
+
17
+ export interface InitializedSession {
18
+ readonly connection: Connection;
19
+ readonly client: ClusterClient;
20
+ readonly initializeResult: InitializeResult;
21
+ }
@@ -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
+ }
@@ -0,0 +1,157 @@
1
+ export interface DeviceCodeResponse {
2
+ readonly device_code: string;
3
+ readonly user_code: string;
4
+ readonly verification_uri: string;
5
+ readonly verification_uri_complete?: string;
6
+ readonly expires_in: number;
7
+ readonly interval: number;
8
+ }
9
+
10
+ export interface TokenResponse {
11
+ readonly access_token: string;
12
+ readonly refresh_token: string;
13
+ readonly token_type: string;
14
+ readonly expires_in: number;
15
+ readonly organization?: { readonly id: string; readonly name: string };
16
+ }
17
+
18
+ export interface HttpTransport {
19
+ fetch(url: string, init: RequestInit & { redirect: 'error' }): Promise<Response>;
20
+ }
21
+
22
+ export interface Clock {
23
+ now(): number;
24
+ }
25
+
26
+ export class DeviceFlowDeniedError extends Error {
27
+ constructor() {
28
+ super('Device authorization denied by user');
29
+ this.name = 'DeviceFlowDeniedError';
30
+ }
31
+ }
32
+
33
+ export class DeviceFlowExpiredError extends Error {
34
+ constructor() {
35
+ super('Device authorization code expired');
36
+ this.name = 'DeviceFlowExpiredError';
37
+ }
38
+ }
39
+
40
+ export class UnboundSessionError extends Error {
41
+ readonly verificationUri: string;
42
+ constructor(verificationUri: string) {
43
+ super(
44
+ `Session not bound to an organization. Re-approve at ${verificationUri} and select an organization.`,
45
+ );
46
+ this.name = 'UnboundSessionError';
47
+ this.verificationUri = verificationUri;
48
+ }
49
+ }
50
+
51
+ const DEFAULT_CLOCK: Clock = { now: () => Date.now() };
52
+
53
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
54
+ return new Promise((resolve, reject) => {
55
+ if (signal?.aborted) {
56
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
57
+ return;
58
+ }
59
+ const timer = setTimeout(resolve, ms);
60
+ signal?.addEventListener(
61
+ 'abort',
62
+ () => {
63
+ clearTimeout(timer);
64
+ reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
65
+ },
66
+ { once: true },
67
+ );
68
+ });
69
+ }
70
+
71
+ export async function requestDeviceCode(
72
+ deviceAuthorizationEndpoint: string,
73
+ clientId: string,
74
+ http: HttpTransport,
75
+ signal?: AbortSignal,
76
+ ): Promise<DeviceCodeResponse> {
77
+ const body = new URLSearchParams({
78
+ client_id: clientId,
79
+ scope: 'openid',
80
+ });
81
+
82
+ const init: RequestInit & { redirect: 'error' } = {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
85
+ body: body.toString(),
86
+ redirect: 'error',
87
+ };
88
+ if (signal) init.signal = signal;
89
+
90
+ const response = await http.fetch(deviceAuthorizationEndpoint, init);
91
+
92
+ if (!response.ok) {
93
+ const text = await response.text();
94
+ throw new Error(`Device code request failed (${response.status}): ${text}`);
95
+ }
96
+
97
+ return (await response.json()) as DeviceCodeResponse;
98
+ }
99
+
100
+ export async function pollForToken(
101
+ tokenEndpoint: string,
102
+ clientId: string,
103
+ deviceCode: string,
104
+ interval: number,
105
+ expiresIn: number,
106
+ http: HttpTransport,
107
+ clock: Clock = DEFAULT_CLOCK,
108
+ signal?: AbortSignal,
109
+ ): Promise<TokenResponse> {
110
+ const deadline = clock.now() + expiresIn * 1000;
111
+ let currentInterval = interval;
112
+
113
+ while (clock.now() < deadline) {
114
+ if (signal?.aborted) {
115
+ throw signal.reason ?? new DOMException('Aborted', 'AbortError');
116
+ }
117
+
118
+ await sleep(currentInterval * 1000, signal);
119
+
120
+ const body = new URLSearchParams({
121
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
122
+ device_code: deviceCode,
123
+ client_id: clientId,
124
+ });
125
+
126
+ const init: RequestInit & { redirect: 'error' } = {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
129
+ body: body.toString(),
130
+ redirect: 'error',
131
+ };
132
+ if (signal) init.signal = signal;
133
+
134
+ const response = await http.fetch(tokenEndpoint, init);
135
+
136
+ if (response.ok) {
137
+ return (await response.json()) as TokenResponse;
138
+ }
139
+
140
+ const errorBody = (await response.json()) as { error: string };
141
+ switch (errorBody.error) {
142
+ case 'authorization_pending':
143
+ continue;
144
+ case 'slow_down':
145
+ currentInterval += 5;
146
+ continue;
147
+ case 'access_denied':
148
+ throw new DeviceFlowDeniedError();
149
+ case 'expired_token':
150
+ throw new DeviceFlowExpiredError();
151
+ default:
152
+ throw new Error(`Token endpoint error: ${errorBody.error}`);
153
+ }
154
+ }
155
+
156
+ throw new DeviceFlowExpiredError();
157
+ }