@shardflux/sdk 0.13.0 → 0.14.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.
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Codex identity proof (0.14.0+, Node): the OpenAI ID token of the Codex CLI signed in on this machine, for
3
+ * `ShardfluxAccount.signup({ codexIdToken })` and `auth.stepUp({ codexIdToken })`.
4
+ *
5
+ * Codex keeps the token in `$CODEX_HOME/auth.json` (default `~/.codex/auth.json`) and refreshes it only every few
6
+ * days, while the API accepts one issued in the last 15 minutes. So this first asks Codex to refresh its own login
7
+ * (`codex app-server`, JSON-RPC `account/read {"refreshToken": true}`: Codex runs its normal refresh flow and writes
8
+ * its own file), then reads the token. Only the ID token is returned: Codex's access and refresh tokens are never read
9
+ * into memory beyond parsing the file, and never leave the machine. The ID token proves the ChatGPT account's verified
10
+ * email; it is not a credential to OpenAI.
11
+ *
12
+ * const proof = await codexIdentityProof();
13
+ * if (proof.ok) {
14
+ * const { account, result } = await ShardfluxAccount.signup({ codexIdToken: proof.idToken });
15
+ * }
16
+ */
17
+ function claimsOf(token) {
18
+ const part = token.split('.')[1];
19
+ if (!part)
20
+ return null;
21
+ try {
22
+ return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ function fresh(token, maxAgeSeconds) {
29
+ const c = claimsOf(token);
30
+ const now = Date.now() / 1000;
31
+ return c !== null && typeof c.iat === 'number' && typeof c.exp === 'number' && now - c.iat <= maxAgeSeconds && c.exp - now > 60;
32
+ }
33
+ async function codexHome(env) {
34
+ const path = await import('node:path');
35
+ const os = await import('node:os');
36
+ const set = env.CODEX_HOME?.trim();
37
+ return set ? path.resolve(set) : path.join(env.HOME?.trim() || os.homedir(), '.codex');
38
+ }
39
+ /** `tokens.id_token` of auth.json, or null (missing file, other shape, API-key login). */
40
+ async function readIdToken(home) {
41
+ const fs = await import('node:fs/promises');
42
+ const path = await import('node:path');
43
+ let raw;
44
+ try {
45
+ raw = await fs.readFile(path.join(home, 'auth.json'), 'utf8');
46
+ }
47
+ catch {
48
+ return { token: null, apiKeyOnly: false, exists: false };
49
+ }
50
+ try {
51
+ const doc = JSON.parse(raw);
52
+ const token = typeof doc.tokens?.id_token === 'string' && doc.tokens.id_token.length > 0 ? doc.tokens.id_token : null;
53
+ const apiKeyOnly = token === null && (doc.auth_mode === 'apikey' || (typeof doc.OPENAI_API_KEY === 'string' && doc.OPENAI_API_KEY.length > 0));
54
+ return { token, apiKeyOnly, exists: true };
55
+ }
56
+ catch {
57
+ return { token: null, apiKeyOnly: false, exists: true };
58
+ }
59
+ }
60
+ /**
61
+ * The `codex` executables to try, in PATH order (CODEX_BIN alone when set). Every match is kept, not only the first:
62
+ * npx puts ancestor node_modules/.bin directories first on PATH, where an unrelated npm package named `codex` can
63
+ * shadow the Codex CLI. Distinct by real path.
64
+ */
65
+ async function codexCandidates(env) {
66
+ const set = env.CODEX_BIN?.trim();
67
+ if (set)
68
+ return [set];
69
+ const fs = await import('node:fs/promises');
70
+ const path = await import('node:path');
71
+ const names = process.platform === 'win32' ? ['codex.exe', 'codex.cmd', 'codex'] : ['codex'];
72
+ const out = [];
73
+ const seen = new Set();
74
+ for (const dir of (env.PATH ?? '').split(path.delimiter)) {
75
+ if (!dir)
76
+ continue;
77
+ for (const name of names) {
78
+ const file = path.join(dir, name);
79
+ try {
80
+ await fs.access(file, fs.constants.X_OK);
81
+ const real = await fs.realpath(file);
82
+ if (seen.has(real))
83
+ continue;
84
+ seen.add(real);
85
+ out.push(file);
86
+ }
87
+ catch {
88
+ // not there, or not executable
89
+ }
90
+ }
91
+ }
92
+ return out;
93
+ }
94
+ /** Asks Codex to refresh its own login through the app server of the first candidate that speaks its protocol. */
95
+ async function refreshViaAppServer(env, opts) {
96
+ const candidates = await codexCandidates(env);
97
+ if (candidates.length === 0)
98
+ return 'not_found';
99
+ let outcome = 'not_found';
100
+ for (const bin of candidates.slice(0, 4)) {
101
+ outcome = await appServerRefresh(bin, env, opts);
102
+ if (outcome !== 'failed' && outcome !== 'not_found')
103
+ return outcome;
104
+ }
105
+ return outcome;
106
+ }
107
+ async function appServerRefresh(bin, env, opts) {
108
+ const { spawn } = await import('node:child_process');
109
+ return new Promise((resolve) => {
110
+ let settled = false;
111
+ let buffer = '';
112
+ const child = spawn(bin, ['app-server'], { stdio: ['pipe', 'pipe', 'ignore'], env, windowsHide: true });
113
+ const done = (o) => {
114
+ if (settled)
115
+ return;
116
+ settled = true;
117
+ clearTimeout(timer);
118
+ child.stdin.end();
119
+ child.kill();
120
+ resolve(o);
121
+ };
122
+ const timer = setTimeout(() => done('failed'), opts.timeoutMs);
123
+ const send = (msg) => child.stdin.write(`${JSON.stringify(msg)}\n`);
124
+ child.on('error', (err) => done(err.code === 'ENOENT' ? 'not_found' : 'failed'));
125
+ child.on('exit', () => done('failed'));
126
+ child.stdin.on('error', () => done('failed'));
127
+ child.stdout.setEncoding('utf8');
128
+ child.stdout.on('data', (chunk) => {
129
+ buffer += chunk;
130
+ let nl;
131
+ while ((nl = buffer.indexOf('\n')) >= 0) {
132
+ const line = buffer.slice(0, nl).trim();
133
+ buffer = buffer.slice(nl + 1);
134
+ if (line.length === 0)
135
+ continue;
136
+ let msg;
137
+ try {
138
+ msg = JSON.parse(line);
139
+ }
140
+ catch {
141
+ continue;
142
+ }
143
+ if (msg.id === 1) {
144
+ if (msg.error !== undefined)
145
+ return done('failed');
146
+ send({ jsonrpc: '2.0', method: 'initialized' });
147
+ send({ jsonrpc: '2.0', id: 2, method: 'account/read', params: { refreshToken: true } });
148
+ }
149
+ else if (msg.id === 2) {
150
+ if (msg.error !== undefined)
151
+ return done('failed');
152
+ const type = msg.result?.account?.type;
153
+ return done(type === 'chatgpt' ? 'refreshed' : type === 'apiKey' ? 'api_key_login' : type === undefined || type === null ? 'not_signed_in' : 'failed');
154
+ }
155
+ }
156
+ });
157
+ send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: { name: opts.clientName, version: opts.clientVersion } } });
158
+ });
159
+ }
160
+ /**
161
+ * The ID token of this machine's Codex login, refreshed by Codex when needed. Never throws: `{ ok: false, reason }`
162
+ * says why there is none (sign up with an email address instead).
163
+ */
164
+ export async function codexIdentityProof(opts = {}) {
165
+ const env = opts.env ?? process.env;
166
+ const maxAge = opts.maxAgeSeconds ?? 600;
167
+ const home = await codexHome(env);
168
+ let outcome = 'skipped';
169
+ // Always refresh first: each refresh mints a token with a new jti, and the API accepts each jti once, so a token
170
+ // still fresh on disk may already have been used. The file alone is the fallback when Codex cannot refresh.
171
+ let file = await readIdToken(home);
172
+ let renewed = false;
173
+ if (opts.refresh !== false) {
174
+ const appServer = { timeoutMs: opts.timeoutMs ?? 15_000, clientName: opts.clientName ?? 'shardflux', clientVersion: opts.clientVersion ?? '0' };
175
+ const before = file.token;
176
+ // A refresh counts only when the token on disk changed. One retry: a refresh can fail on a transient network
177
+ // error, answer without renewing, or race another Codex process refreshing the same login.
178
+ for (let attempt = 0; attempt < 2 && !renewed; attempt++) {
179
+ if (attempt > 0)
180
+ await new Promise((r) => setTimeout(r, 500));
181
+ outcome = await refreshViaAppServer(env, appServer);
182
+ file = await readIdToken(home);
183
+ renewed = file.token !== null && file.token !== before && fresh(file.token, maxAge);
184
+ if (outcome === 'api_key_login' || outcome === 'not_signed_in' || outcome === 'not_found')
185
+ break;
186
+ }
187
+ }
188
+ if (file.token !== null && fresh(file.token, maxAge)) {
189
+ const email = claimsOf(file.token)?.email;
190
+ return { ok: true, idToken: file.token, email: typeof email === 'string' ? email : null, refreshed: renewed };
191
+ }
192
+ if (outcome === 'api_key_login' || file.apiKeyOnly)
193
+ return { ok: false, reason: 'api_key_login', message: 'Codex is signed in with an API key, which carries no identity. Sign in with ChatGPT (codex login), or sign up with an email address.' };
194
+ if (outcome === 'not_signed_in' || (file.token === null && outcome !== 'not_found')) {
195
+ return { ok: false, reason: 'not_signed_in', message: 'Codex is not signed in with ChatGPT on this machine (codex login), or keeps its login in the OS keyring. Sign up with an email address instead.' };
196
+ }
197
+ if (outcome === 'not_found' && !file.exists)
198
+ return { ok: false, reason: 'codex_not_found', message: 'The Codex CLI is not installed (or not on PATH). Sign up with an email address instead.' };
199
+ return { ok: false, reason: 'stale', message: 'The Codex login on this machine could not be refreshed. Run any codex command (or codex login), then try again.' };
200
+ }
package/dist/errors.d.ts CHANGED
@@ -70,8 +70,19 @@ export type ErrorCode = AppErrorCode | CellErrorCode;
70
70
  * 409 conflict read_only_path (a files write under an immutable path, from the cell gateway). The reason
71
71
  * `update_policy_not_available` is gone with the update policy. A build that fails on them carries `failure.code`
72
72
  * immutable_path_missing (details.path) or immutable_image_too_large.
73
+ * Host loss (0.13.1; the machine a workspace ran on failed, and the workspace restores itself on its next use): the
74
+ * operation error `workspace_storage_unavailable` carries details.reason host_lost (and details.workspace_state
75
+ * `suspended`) when neither the workspace's disk nor a checkpoint could be restored (not retryable). A fork or snapshot
76
+ * of such a workspace before its resume fails with the operation error `resume_required` (details.reason host_lost;
77
+ * not retryable): resume the workspace first.
78
+ * Workspaces working at once (0.14.0): the cell gateway answers a tool call that finds every working slot of the plan
79
+ * taken with 429 `quota_exceeded` (retryable, Retry-After; details.limit `concurrent_workspaces`, limit_value, current,
80
+ * retry_after_seconds); the call did not run and the SDK retries it like any transient refusal. The API's 403
81
+ * `quota_exceeded` (not retryable) names details.limit `concurrent_workspaces` on open, resume and fork, and
82
+ * `retained_state` (details.limit_value and details.current in GiB) when opening a new key or forking with the plan's
83
+ * Retained state used up. `details.limit` is not a reason: these are not in this union.
73
84
  */
74
- export type KnownErrorReason = 'invalid_recipe' | 'base_not_layered' | 'language_unavailable' | 'language_conflict' | 'invalid_package' | 'too_many_files' | 'platform_owned_path' | 'upload_required' | 'upload_missing' | 'upload_digest_mismatch' | 'upload_too_large' | 'extra_hosts_without_auto' | 'invalid_settings' | 'services_unsupported' | 'input_required' | 'input_unknown' | 'input_invalid' | 'egress_widening' | 'reserved_session_id' | 'env_collision' | 'reserved_template_slug' | 'package_index_unavailable' | 'package_not_found' | 'startup_failed' | 'service_not_ready' | 'secrets_unavailable' | 'workspace_not_running' | 'operation_in_progress' | 'workspace_deleted' | 'secret_not_available' | 'legacy_disk_layout' | 'not_session' | 'session_lifetime' | 'lifetime_mismatch' | 'not_resettable' | 'template_not_layered' | 'draft_exists' | 'draft_stale' | 'build_in_progress' | 'file_list_unavailable' | 'file_list_indexing' | 'guest_feature_unavailable' | 'confirm_destructive_required' | 'reserved_key_prefix' | 'invalid_defaults' | 'invalid_path' | 'too_many_acknowledged_findings' | 'template_dev_mode_role' | 'draft_not_found' | 'version_not_found' | 'path_not_found' | 'revision_mismatch' | 'edit_not_found' | 'edit_ambiguous' | 'edit_not_text' | 'patch_invalid' | 'host_capacity' | 'wake_failed' | 'workspace_fenced' | 'offline_unavailable' | 'offline_budget' | 'offline_changed' | 'host_feature_unavailable' | 'not_supported_for_mode' | 'mode_mismatch' | 'mode_not_available' | 'layout_unsupported' | 'tree_revision_mismatch' | 'outside_tree_root' | 'execution_in_progress' | 'execution_id_reused' | 'operation_id_reused' | 'no_execution_host' | 'lease_expired' | 'host_unreachable' | 'host_restarted' | 'tree_moved' | 'blob_missing' | 'blob_corrupt' | 'exec_failed_to_start' | 'invalid_cwd' | 'allowance_used' | 'overage_paused' | 'spend_cap_reached' | 'overage_unavailable' | 'spend_cap_required' | 'spend_cap_below_minimum' | 'spend_cap_above_plan_price' | 'spend_cap_below_charges' | 'version_mismatch' | 'allocation_mode_not_available' | 'requires_elastic' | 'exceeds_memory_mib' | 'burst_mode_not_supported' | 'burst_not_supported' | 'burst_size_exceeds_plan' | 'not_available' | 'shared_volumes' | 'fence_not_drained' | 'apply_pending' | 'park_failed' | 'workspace_resumed' | 'interrupted' | 'burst_lost' | 'disk_full' | 'apply_failed' | 'reverted' | 'revert_failed' | 'immutable_path_removed' | 'immutable_paths_unsupported_base' | 'read_only_path';
85
+ export type KnownErrorReason = 'invalid_recipe' | 'base_not_layered' | 'language_unavailable' | 'language_conflict' | 'invalid_package' | 'too_many_files' | 'platform_owned_path' | 'upload_required' | 'upload_missing' | 'upload_digest_mismatch' | 'upload_too_large' | 'extra_hosts_without_auto' | 'invalid_settings' | 'services_unsupported' | 'input_required' | 'input_unknown' | 'input_invalid' | 'egress_widening' | 'reserved_session_id' | 'env_collision' | 'reserved_template_slug' | 'package_index_unavailable' | 'package_not_found' | 'startup_failed' | 'service_not_ready' | 'secrets_unavailable' | 'workspace_not_running' | 'operation_in_progress' | 'workspace_deleted' | 'secret_not_available' | 'legacy_disk_layout' | 'not_session' | 'session_lifetime' | 'lifetime_mismatch' | 'not_resettable' | 'template_not_layered' | 'draft_exists' | 'draft_stale' | 'build_in_progress' | 'file_list_unavailable' | 'file_list_indexing' | 'guest_feature_unavailable' | 'confirm_destructive_required' | 'reserved_key_prefix' | 'invalid_defaults' | 'invalid_path' | 'too_many_acknowledged_findings' | 'template_dev_mode_role' | 'draft_not_found' | 'version_not_found' | 'path_not_found' | 'revision_mismatch' | 'edit_not_found' | 'edit_ambiguous' | 'edit_not_text' | 'patch_invalid' | 'host_capacity' | 'wake_failed' | 'workspace_fenced' | 'offline_unavailable' | 'offline_budget' | 'offline_changed' | 'host_feature_unavailable' | 'not_supported_for_mode' | 'mode_mismatch' | 'mode_not_available' | 'layout_unsupported' | 'tree_revision_mismatch' | 'outside_tree_root' | 'execution_in_progress' | 'execution_id_reused' | 'operation_id_reused' | 'no_execution_host' | 'lease_expired' | 'host_unreachable' | 'host_restarted' | 'tree_moved' | 'blob_missing' | 'blob_corrupt' | 'exec_failed_to_start' | 'invalid_cwd' | 'allowance_used' | 'overage_paused' | 'spend_cap_reached' | 'overage_unavailable' | 'spend_cap_required' | 'spend_cap_below_minimum' | 'spend_cap_above_plan_price' | 'spend_cap_below_charges' | 'version_mismatch' | 'allocation_mode_not_available' | 'requires_elastic' | 'exceeds_memory_mib' | 'burst_mode_not_supported' | 'burst_not_supported' | 'burst_size_exceeds_plan' | 'not_available' | 'shared_volumes' | 'fence_not_drained' | 'apply_pending' | 'park_failed' | 'workspace_resumed' | 'interrupted' | 'burst_lost' | 'disk_full' | 'apply_failed' | 'reverted' | 'revert_failed' | 'immutable_path_removed' | 'immutable_paths_unsupported_base' | 'read_only_path' | 'resize_not_available' | 'shrink_not_supported' | 'resize_failed' | 'host_lost' | 'inbound_ports_not_available' | 'port_not_exposed' | 'port_limit';
75
86
  /** A known reason, or any other string the server sends (reasons are open-ended). */
76
87
  export type ErrorReason = KnownErrorReason | (string & {});
77
88
  export interface ErrorBodyLike {
@@ -106,6 +117,13 @@ export declare class ShardfluxApiError extends Error {
106
117
  readonly treeRevision: number | undefined;
107
118
  constructor(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number, treeRevision?: number);
108
119
  }
120
+ /**
121
+ * The cell gateway's working-at-once refusal (0.14.0+): 429 `quota_exceeded`, retryable, with Retry-After. The gateway
122
+ * refuses the call at admission, before the workspace is touched, so nothing ran and any request (exec start, stdin,
123
+ * PTY create, keepalive included) may be sent again. The API's 403 `quota_exceeded` is a different refusal (not
124
+ * retryable) and never matches.
125
+ */
126
+ export declare function isWorkingQuotaRefusal(err: unknown): err is ShardfluxApiError;
109
127
  /** processful (the default: one VM keeps processes, memory and files) or file_first. */
110
128
  export type WorkspaceMode = AppComponents['schemas']['WorkspaceMode'];
111
129
  /**
@@ -197,6 +215,7 @@ export declare class OperationTimeoutError extends Error {
197
215
  * The operation reached `failed` or `canceled`. A suspend-when-idle that found the workspace active (a tool call after
198
216
  * the request, an attached stream) is `canceled` with `errorCode` `workspace_active` (`workspaceActive` true, 0.12.0+):
199
217
  * nothing changed and the workspace keeps running. `waitUntilReady()` and `wake()` treat it as running, not as a failure.
218
+ * `resume_required` (0.13.1+): a fork or snapshot of a workspace whose machine failed; resume it, then call again.
200
219
  */
201
220
  export declare class OperationFailedError extends Error {
202
221
  readonly operation: Operation;
package/dist/errors.js CHANGED
@@ -40,6 +40,15 @@ export class ShardfluxApiError extends Error {
40
40
  this.reason = typeof reason === 'string' ? reason : undefined;
41
41
  }
42
42
  }
43
+ /**
44
+ * The cell gateway's working-at-once refusal (0.14.0+): 429 `quota_exceeded`, retryable, with Retry-After. The gateway
45
+ * refuses the call at admission, before the workspace is touched, so nothing ran and any request (exec start, stdin,
46
+ * PTY create, keepalive included) may be sent again. The API's 403 `quota_exceeded` is a different refusal (not
47
+ * retryable) and never matches.
48
+ */
49
+ export function isWorkingQuotaRefusal(err) {
50
+ return err instanceof ShardfluxApiError && err.source === 'cell' && err.status === 429 && err.code === 'quota_exceeded' && err.retryable;
51
+ }
43
52
  /**
44
53
  * The call does not exist for the workspace's mode: 409 `conflict` with details.reason
45
54
  * `not_supported_for_mode`, `details.mode` (the workspace's mode) and `details.operation`. File-first workspaces have no
@@ -187,6 +196,7 @@ export class OperationTimeoutError extends Error {
187
196
  * The operation reached `failed` or `canceled`. A suspend-when-idle that found the workspace active (a tool call after
188
197
  * the request, an attached stream) is `canceled` with `errorCode` `workspace_active` (`workspaceActive` true, 0.12.0+):
189
198
  * nothing changed and the workspace keeps running. `waitUntilReady()` and `wake()` treat it as running, not as a failure.
199
+ * `resume_required` (0.13.1+): a fork or snapshot of a workspace whose machine failed; resume it, then call again.
190
200
  */
191
201
  export class OperationFailedError extends Error {
192
202
  operation;