@inneranimalmedia/agentsam-sdk 2.6.0 → 2.6.2

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 (145) hide show
  1. package/docs/AUTH_IDENTITY_CONTRACT.md +25 -27
  2. package/docs/PLATFORM_RUNTIME_EVENTS.md +48 -0
  3. package/docs/RELEASES.md +7 -7
  4. package/docs/SDK_WORKER.md +4 -3
  5. package/docs/SOURCE_ARCHITECTURE.md +58 -0
  6. package/docs/TEST_TIERS.md +26 -0
  7. package/migrations/runtime/0001_cli_runtime.sql +298 -0
  8. package/package.json +28 -7
  9. package/packages/agentsam-repository/README.md +15 -0
  10. package/packages/agentsam-repository/package.json +25 -0
  11. package/packages/agentsam-repository/src/contracts.js +113 -0
  12. package/packages/agentsam-repository/src/index.js +3 -0
  13. package/{src/lib → packages/agentsam-repository/src}/merkle/cloudflare-persistence.js +14 -24
  14. package/{src/lib → packages/agentsam-repository/src}/merkle/index.js +1 -0
  15. package/{src/lib → packages/agentsam-repository/src}/merkle/persistence.js +6 -4
  16. package/{src/lib → packages/agentsam-repository/src}/merkle/policy.js +1 -0
  17. package/packages/agentsam-repository/test/contracts.test.mjs +40 -0
  18. package/packages/agentsam-repository/test/git-context.test.mjs +24 -0
  19. package/{test/merkle.test.mjs → packages/agentsam-repository/test/merkle-core.test.mjs} +2 -32
  20. package/{test → packages/agentsam-repository/test}/merkle-persistence.test.mjs +11 -6
  21. package/packages/identity/package.json +2 -2
  22. package/packages/identity/src/contracts/auth-config.js +23 -40
  23. package/packages/identity/tests/auth-config.test.mjs +14 -23
  24. package/protocol/COMPANY_REPOSITORY_GRAPH_V1.md +91 -0
  25. package/protocol/MERKLE_PERSISTENCE_V1.md +2 -0
  26. package/protocol/MERKLE_PERSISTENCE_V2.md +40 -0
  27. package/protocol/repository/repository-contract.schema.json +24 -0
  28. package/protocol/repository/repository-dependency.schema.json +24 -0
  29. package/protocol/repository/repository-identity.schema.json +17 -0
  30. package/protocol/rpc/v1/common.proto +16 -0
  31. package/protocol/rpc/v1/errors.proto +35 -0
  32. package/protocol/rpc/v1/knowledge.proto +77 -0
  33. package/services/knowledge/package-lock.json +333 -0
  34. package/services/knowledge/package.json +5 -1
  35. package/src/agent/responses-runner.js +63 -35
  36. package/src/capabilities/repository-snapshot.js +3 -3
  37. package/src/cli.js +36 -29
  38. package/src/commands/account-auth.js +1 -1
  39. package/src/commands/context-economics.js +17 -2
  40. package/src/commands/context.js +1 -1
  41. package/src/commands/db.js +20 -3
  42. package/src/commands/deploy.js +2 -2
  43. package/src/commands/env.js +90 -0
  44. package/src/commands/knowledge.js +12 -4
  45. package/src/commands/merkle-persist.js +30 -11
  46. package/src/commands/merkle.js +1 -1
  47. package/src/commands/models.js +123 -65
  48. package/src/commands/ollama.js +26 -0
  49. package/src/commands/preferences.js +53 -26
  50. package/src/commands/providers.js +308 -0
  51. package/src/commands/shell.js +240 -48
  52. package/src/commands/tunnel.js +8 -8
  53. package/src/commands/whoami.js +42 -21
  54. package/src/errors/contract.js +236 -0
  55. package/src/errors/diagnostic.js +1 -0
  56. package/src/errors/index.js +14 -0
  57. package/src/index.js +13 -1
  58. package/src/knowledge/service/auth.js +13 -0
  59. package/src/knowledge/service/grpc-client.js +115 -0
  60. package/src/knowledge/service/grpc-codec.js +237 -0
  61. package/src/knowledge/service/grpc-server.js +83 -0
  62. package/src/knowledge/service/job-engine.js +248 -0
  63. package/src/knowledge/service/server.js +87 -135
  64. package/src/knowledge/source.js +1 -1
  65. package/src/lib/account-session.js +94 -25
  66. package/src/lib/auth.js +314 -51
  67. package/src/lib/cli-preferences.js +31 -4
  68. package/src/lib/core-client.js +75 -30
  69. package/src/lib/deploy-receipt/index.js +2 -2
  70. package/src/lib/detect-context.js +14 -16
  71. package/src/lib/knowledge-docker.js +6 -3
  72. package/src/lib/local-sessions.js +23 -2
  73. package/src/lib/local-status.js +1 -1
  74. package/src/lib/project-config.js +1 -1
  75. package/src/lib/provider-credentials.js +230 -14
  76. package/src/lib/slash-commands.js +5 -3
  77. package/src/local/migrations.js +93 -0
  78. package/src/local/runtime-store.js +141 -0
  79. package/src/local/sqlite.js +2 -0
  80. package/src/local-pty/server.js +113 -51
  81. package/src/models/discovery.js +327 -0
  82. package/src/providers/anthropic-messages.js +192 -0
  83. package/src/providers/cloudflare-chat.js +183 -0
  84. package/src/providers/factory.js +69 -0
  85. package/src/providers/gemini-generate-content.js +208 -0
  86. package/src/providers/index.js +5 -0
  87. package/src/providers/ollama-chat.js +148 -0
  88. package/src/providers/openai-responses.js +226 -75
  89. package/src/repository/index.js +14 -2
  90. package/src/rpc/generated/common_grpc_pb.js +1 -0
  91. package/src/rpc/generated/common_pb.js +536 -0
  92. package/src/rpc/generated/errors_grpc_pb.js +1 -0
  93. package/src/rpc/generated/errors_pb.js +482 -0
  94. package/src/rpc/generated/knowledge_grpc_pb.js +135 -0
  95. package/src/rpc/generated/knowledge_pb.js +2168 -0
  96. package/src/rpc/generated/package.json +3 -0
  97. package/src/security/trust-boundary.js +2 -2
  98. package/src/telemetry/events.js +4 -1
  99. package/src/ui/cli/activity.js +76 -0
  100. package/src/ui/cli/compaction.js +15 -0
  101. package/src/ui/cli/footer.js +39 -0
  102. package/src/ui/cli/help.js +194 -0
  103. package/src/ui/cli/plan.js +20 -0
  104. package/src/ui/cli/runtime-events.js +110 -0
  105. package/src/ui/cli/waiting.js +16 -0
  106. package/src/ui/merkle/render.js +1 -1
  107. package/test/account-session.test.mjs +48 -11
  108. package/test/apps-scaffold-contract.test.mjs +43 -0
  109. package/test/cli/preferences-runtime.test.mjs +11 -0
  110. package/test/cli/runtime-ui.test.mjs +74 -0
  111. package/test/error-diagnostics.test.mjs +57 -1
  112. package/test/fixtures/knowledge-rpc-worker.mjs +16 -0
  113. package/test/integration/cli-help.test.mjs +37 -0
  114. package/test/integration/knowledge-rpc.test.mjs +112 -0
  115. package/test/integration/merkle-cli.test.mjs +61 -0
  116. package/test/integration/merkle-persistence-identity.test.mjs +48 -0
  117. package/test/integration/provider-env-cli.test.mjs +49 -0
  118. package/test/integration/provider-factory.test.mjs +197 -0
  119. package/test/integration/repository-company-graph.test.mjs +90 -0
  120. package/test/integration/runtime-migrations.test.mjs +82 -0
  121. package/test/knowledge-service.test.mjs +5 -0
  122. package/test/knowledge.test.mjs +16 -0
  123. package/test/live/terminal-transport.live.test.mjs +24 -0
  124. package/test/local-sessions.test.mjs +7 -1
  125. package/test/models.test.mjs +101 -4
  126. package/test/ollama.test.mjs +21 -0
  127. package/test/portable-context.test.mjs +1 -1
  128. package/test/provider-credentials.test.mjs +45 -1
  129. package/test/release-hygiene.test.mjs +13 -5
  130. package/test/responses-runner.test.mjs +3 -1
  131. package/test/sdk-worker-contract.test.mjs +5 -1
  132. package/test/shell.test.mjs +50 -8
  133. package/test/smoke.mjs +2 -1
  134. package/test/terminal/local-pty.mock.test.mjs +151 -0
  135. package/test/whoami-resume.test.mjs +6 -5
  136. package/src/lib/prompt-byok.js +0 -57
  137. package/src/lib/save-sdk-token.js +0 -19
  138. /package/{src/lib → packages/agentsam-repository/src}/git-context.js +0 -0
  139. /package/{src/lib → packages/agentsam-repository/src}/merkle/diff.js +0 -0
  140. /package/{src/lib → packages/agentsam-repository/src}/merkle/filemeta.js +0 -0
  141. /package/{src/lib → packages/agentsam-repository/src}/merkle/git-ignore.js +0 -0
  142. /package/{src/lib → packages/agentsam-repository/src}/merkle/hash.js +0 -0
  143. /package/{src/lib → packages/agentsam-repository/src}/merkle/semantic.js +0 -0
  144. /package/{src/lib → packages/agentsam-repository/src}/merkle/snapshot.js +0 -0
  145. /package/{src/lib → packages/agentsam-repository/src}/merkle/tree.js +0 -0
@@ -1,14 +1,28 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { resolveSdkKey } from '../../packages/identity/src/contracts/auth-config.js';
4
+ import { isApiKey, resolveApiKey } from '../../packages/identity/src/contracts/auth-config.js';
5
5
 
6
- export const ACCOUNT_SESSION_SCHEMA = 'agentsam-account-session-v1';
6
+ export const ACCOUNT_SESSION_SCHEMA = 'agentsam-account-session-v3';
7
+ const LEGACY_ACCOUNT_SESSION_SCHEMA = 'agentsam-account-session-v2';
7
8
 
8
9
  function clean(value) { return value == null ? '' : String(value).trim(); }
9
10
  function homeDirectory(options = {}) {
10
11
  return path.resolve(clean(options.home) || clean(options.env?.HOME) || clean(options.env?.USERPROFILE) || os.homedir());
11
12
  }
13
+ function isoOrNull(value) {
14
+ const raw = clean(value);
15
+ if (!raw) return null;
16
+ const ms = Date.parse(raw);
17
+ return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
18
+ }
19
+ function expiresAtFrom(session = {}, nowMs = Date.now()) {
20
+ const explicit = isoOrNull(session.expires_at);
21
+ if (explicit) return explicit;
22
+ const expiresIn = Number(session.expires_in);
23
+ if (!Number.isFinite(expiresIn) || expiresIn <= 0) return null;
24
+ return new Date(nowMs + (expiresIn * 1000)).toISOString();
25
+ }
12
26
 
13
27
  export function accountSessionPath(options = {}) {
14
28
  return path.join(homeDirectory(options), '.agentsam', 'auth', 'session.json');
@@ -22,17 +36,24 @@ export function readAccountSession(options = {}) {
22
36
  if (!stat.isFile()) return null;
23
37
  if (process.platform !== 'win32' && (stat.mode & 0o077) !== 0) return null;
24
38
  const parsed = JSON.parse(fs.readFileSync(filename, 'utf8'));
25
- if (parsed?.schema_version !== ACCOUNT_SESSION_SCHEMA) return null;
26
- const token = clean(parsed.sdk_key);
27
- if (!token.startsWith('sdk_')) return null;
39
+ if (![ACCOUNT_SESSION_SCHEMA, LEGACY_ACCOUNT_SESSION_SCHEMA].includes(parsed?.schema_version)) return null;
40
+ const accessToken = clean(parsed.access_token);
41
+ const refreshToken = clean(parsed.refresh_token);
42
+ if (!accessToken || isApiKey(accessToken) || (refreshToken && isApiKey(refreshToken))) return null;
28
43
  return {
29
44
  schema_version: ACCOUNT_SESSION_SCHEMA,
30
- sdk_key: token,
45
+ access_token: accessToken,
46
+ refresh_token: refreshToken || null,
47
+ token_type: clean(parsed.token_type) || 'Bearer',
48
+ scope: clean(parsed.scope) || null,
49
+ expires_at: isoOrNull(parsed.expires_at),
50
+ client_id: clean(parsed.client_id) || null,
31
51
  user_id: clean(parsed.user_id) || null,
32
52
  account_id: clean(parsed.account_id) || null,
33
53
  email: clean(parsed.email) || null,
34
- created_at: clean(parsed.created_at) || null,
35
- updated_at: clean(parsed.updated_at) || null,
54
+ created_at: isoOrNull(parsed.created_at),
55
+ updated_at: isoOrNull(parsed.updated_at),
56
+ migrated_from: parsed.schema_version === LEGACY_ACCOUNT_SESSION_SCHEMA ? LEGACY_ACCOUNT_SESSION_SCHEMA : null,
36
57
  };
37
58
  } catch {
38
59
  return null;
@@ -40,25 +61,38 @@ export function readAccountSession(options = {}) {
40
61
  }
41
62
 
42
63
  export function saveAccountSession(session = {}, options = {}) {
43
- const token = clean(session.sdk_key || session.access_token);
44
- if (!token.startsWith('sdk_')) throw new Error('account_session_sdk_key_required');
64
+ const accessToken = clean(session.access_token);
65
+ const suppliedRefresh = clean(session.refresh_token);
66
+ if (!accessToken || isApiKey(accessToken) || (suppliedRefresh && isApiKey(suppliedRefresh))) {
67
+ throw new Error('account_browser_oauth_session_required');
68
+ }
69
+
45
70
  const filename = accountSessionPath(options);
46
71
  const dir = path.dirname(filename);
47
72
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
48
73
  if (process.platform !== 'win32') {
49
74
  try { fs.chmodSync(dir, 0o700); } catch { /* best effort */ }
50
75
  }
76
+
51
77
  const previous = readAccountSession(options);
52
- const now = new Date().toISOString();
78
+ const nowMs = Number.isFinite(Number(options.nowMs)) ? Number(options.nowMs) : Date.now();
79
+ const now = new Date(nowMs).toISOString();
80
+ const preserveRefreshToken = options.preserveRefreshToken !== false;
53
81
  const value = {
54
82
  schema_version: ACCOUNT_SESSION_SCHEMA,
55
- sdk_key: token,
83
+ access_token: accessToken,
84
+ refresh_token: suppliedRefresh || (preserveRefreshToken ? previous?.refresh_token : null) || null,
85
+ token_type: clean(session.token_type) || previous?.token_type || 'Bearer',
86
+ scope: clean(session.scope) || previous?.scope || null,
87
+ expires_at: expiresAtFrom(session, nowMs),
88
+ client_id: clean(session.client_id) || previous?.client_id || null,
56
89
  user_id: clean(session.user_id) || previous?.user_id || null,
57
90
  account_id: clean(session.account_id) || previous?.account_id || null,
58
91
  email: clean(session.email) || previous?.email || null,
59
92
  created_at: previous?.created_at || now,
60
93
  updated_at: now,
61
94
  };
95
+
62
96
  const temp = `${filename}.${process.pid}.tmp`;
63
97
  fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
64
98
  if (process.platform !== 'win32') {
@@ -75,24 +109,59 @@ export function clearAccountSession(options = {}) {
75
109
  return true;
76
110
  }
77
111
 
78
- export function resolveAccountSdkKey(options = {}) {
112
+ export function isBrowserSessionExpired(session, options = {}) {
113
+ const expiresAt = Date.parse(clean(session?.expires_at));
114
+ if (!Number.isFinite(expiresAt)) return false;
115
+ const nowMs = Number.isFinite(Number(options.nowMs)) ? Number(options.nowMs) : Date.now();
116
+ const skewMs = Number.isFinite(Number(options.skewMs)) ? Number(options.skewMs) : 60_000;
117
+ return expiresAt <= (nowMs + Math.max(0, skewMs));
118
+ }
119
+
120
+ export function resolveAccountApiKey(options = {}) {
79
121
  const env = options.env || process.env;
80
122
  const explicit = clean(options.explicit);
81
- const fromEnv = resolveSdkKey(env, explicit);
82
- if (fromEnv) return { value: fromEnv, source: explicit ? 'explicit' : 'environment' };
83
- const session = readAccountSession({ ...options, env });
84
- return session?.sdk_key
85
- ? { value: session.sdk_key, source: 'agentsam_account_session', session }
86
- : { value: '', source: null, session: null };
123
+ const value = resolveApiKey(env, explicit);
124
+ if (!value) return { value: '', source: null, kind: null };
125
+ if (!isApiKey(value)) return { value: '', source: explicit ? 'explicit' : 'environment', kind: 'api_key', error: 'invalid_api_key_prefix' };
126
+ return { value, source: explicit ? 'explicit' : 'environment', kind: 'api_key' };
127
+ }
128
+
129
+ export function resolveBrowserSessionCredential(options = {}) {
130
+ const session = readAccountSession(options);
131
+ if (!session?.access_token) return { value: '', source: null, kind: null, session: null, expired: false };
132
+ const expired = isBrowserSessionExpired(session, options);
133
+ return {
134
+ value: expired ? '' : session.access_token,
135
+ source: 'agentsam_browser_oauth',
136
+ kind: 'browser_oauth',
137
+ session,
138
+ expired,
139
+ error: expired ? 'browser_oauth_session_expired' : null,
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Synchronous authority snapshot kept for low-level compatibility.
145
+ * Runtime SDK calls should use resolveAccountAuthority() from auth.js so expired
146
+ * OAuth sessions can refresh before a request is sent.
147
+ */
148
+ export function resolveAccountAuth(options = {}) {
149
+ const apiKey = resolveAccountApiKey(options);
150
+ if (apiKey.value || apiKey.error) return apiKey;
151
+ return resolveBrowserSessionCredential(options);
87
152
  }
88
153
 
89
154
  export function describeAccountSession(options = {}) {
90
- const resolved = resolveAccountSdkKey(options);
155
+ const session = readAccountSession(options);
91
156
  return {
92
- configured: Boolean(resolved.value),
93
- source: resolved.source,
94
- user_id: resolved.session?.user_id || null,
95
- account_id: resolved.session?.account_id || null,
96
- email: resolved.session?.email || null,
157
+ configured: Boolean(session?.access_token),
158
+ source: session?.access_token ? 'agentsam_browser_oauth' : null,
159
+ kind: session?.access_token ? 'browser_oauth' : null,
160
+ refreshable: Boolean(session?.refresh_token),
161
+ expires_at: session?.expires_at || null,
162
+ expired: session ? isBrowserSessionExpired(session, options) : false,
163
+ user_id: session?.user_id || null,
164
+ account_id: session?.account_id || null,
165
+ email: session?.email || null,
97
166
  };
98
167
  }
package/src/lib/auth.js CHANGED
@@ -1,71 +1,334 @@
1
1
  /**
2
- * Browser OAuth for SDK init — one click IAM login + Cloudflare connect.
2
+ * RFC 8252 native-app OAuth for AgentSam CLI.
3
+ *
4
+ * The CLI is a public client: authorization code + PKCE over a loopback
5
+ * redirect. Browser OAuth sessions are machine-local and remain separate from
6
+ * reusable AGENTSAM_API_KEY (aak_*) credentials.
3
7
  */
4
8
  import http from 'node:http';
5
- import { randomBytes } from 'node:crypto';
6
- import { postJson } from './core-client.js';
9
+ import { createHash, randomBytes } from 'node:crypto';
10
+ import { resolveIamIssuer } from '../../packages/identity/src/contracts/auth-config.js';
7
11
  import { promptToOpenUrl } from './open-url.js';
8
- import { saveAccountSession } from './account-session.js';
12
+ import {
13
+ isBrowserSessionExpired,
14
+ readAccountSession,
15
+ resolveAccountApiKey,
16
+ saveAccountSession,
17
+ } from './account-session.js';
9
18
 
10
- function randomState() {
11
- return randomBytes(16).toString('hex');
19
+ export const AGENTSAM_NATIVE_OAUTH_CLIENT_ID = 'iam_cli_agentsam';
20
+ export const AGENTSAM_NATIVE_OAUTH_SCOPE = 'openid profile email offline_access';
21
+ export const AGENTSAM_OAUTH_CALLBACK_PATH = '/callback';
22
+
23
+ function clean(value) { return value == null ? '' : String(value).trim(); }
24
+ function base64url(value) {
25
+ return Buffer.from(value).toString('base64')
26
+ .replace(/=/g, '')
27
+ .replace(/\+/g, '-')
28
+ .replace(/\//g, '_');
29
+ }
30
+ function randomUrlSafe(bytes = 32, randomBytesImpl = randomBytes) {
31
+ return base64url(randomBytesImpl(bytes));
32
+ }
33
+ function oauthErrorMessage(body, status) {
34
+ const code = clean(body?.error);
35
+ const description = clean(body?.error_description || body?.message);
36
+ if (code && description) return `${code}: ${description}`;
37
+ return code || description || `OAuth token HTTP ${status}`;
38
+ }
39
+
40
+ export function createPkcePair(options = {}) {
41
+ const verifier = randomUrlSafe(32, options.randomBytesImpl || randomBytes);
42
+ const challenge = base64url(createHash('sha256').update(verifier, 'ascii').digest());
43
+ return Object.freeze({ verifier, challenge, method: 'S256' });
44
+ }
45
+
46
+ export function buildNativeAuthorizationUrl(options = {}) {
47
+ const env = options.env || process.env;
48
+ const issuer = resolveIamIssuer(env, options.issuer || '');
49
+ const clientId = clean(options.clientId) || AGENTSAM_NATIVE_OAUTH_CLIENT_ID;
50
+ const redirectUri = clean(options.redirectUri);
51
+ const state = clean(options.state);
52
+ const codeChallenge = clean(options.codeChallenge);
53
+ const scope = clean(options.scope ?? AGENTSAM_NATIVE_OAUTH_SCOPE);
54
+ if (!redirectUri || !state || !codeChallenge) throw new Error('oauth_authorization_parameters_required');
55
+
56
+ const url = new URL('/api/oauth/authorize', `${issuer}/`);
57
+ url.searchParams.set('response_type', 'code');
58
+ url.searchParams.set('client_id', clientId);
59
+ url.searchParams.set('redirect_uri', redirectUri);
60
+ url.searchParams.set('code_challenge', codeChallenge);
61
+ url.searchParams.set('code_challenge_method', 'S256');
62
+ url.searchParams.set('state', state);
63
+ if (scope) url.searchParams.set('scope', scope);
64
+ return url.toString();
65
+ }
66
+
67
+ async function oauthTokenRequest(params, options = {}) {
68
+ const env = options.env || process.env;
69
+ const issuer = resolveIamIssuer(env, options.issuer || '');
70
+ const fetchImpl = options.fetchImpl || fetch;
71
+ const body = new URLSearchParams();
72
+ for (const [key, value] of Object.entries(params || {})) {
73
+ const normalized = clean(value);
74
+ if (normalized) body.set(key, normalized);
75
+ }
76
+
77
+ const response = await fetchImpl(new URL('/api/oauth/token', `${issuer}/`).toString(), {
78
+ method: 'POST',
79
+ headers: {
80
+ Accept: 'application/json',
81
+ 'Content-Type': 'application/x-www-form-urlencoded',
82
+ },
83
+ body: body.toString(),
84
+ signal: options.signal || (typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(15_000) : undefined),
85
+ });
86
+ const data = await response.json().catch(() => ({}));
87
+ if (!response.ok) {
88
+ const error = new Error(oauthErrorMessage(data, response.status));
89
+ error.status = response.status;
90
+ error.oauth_error = clean(data?.error) || null;
91
+ throw error;
92
+ }
93
+ if (!clean(data?.access_token)) throw new Error('oauth_token_response_missing_access_token');
94
+ return data;
95
+ }
96
+
97
+ export async function exchangeAuthorizationCode(options = {}) {
98
+ const code = clean(options.code);
99
+ const codeVerifier = clean(options.codeVerifier);
100
+ const redirectUri = clean(options.redirectUri);
101
+ if (!code || !codeVerifier || !redirectUri) throw new Error('oauth_authorization_code_exchange_parameters_required');
102
+ return oauthTokenRequest({
103
+ grant_type: 'authorization_code',
104
+ client_id: clean(options.clientId) || AGENTSAM_NATIVE_OAUTH_CLIENT_ID,
105
+ redirect_uri: redirectUri,
106
+ code,
107
+ code_verifier: codeVerifier,
108
+ }, options);
109
+ }
110
+
111
+ export async function refreshAccountSession(options = {}) {
112
+ const session = options.session || readAccountSession(options);
113
+ if (!session?.refresh_token) throw new Error('browser_oauth_refresh_unavailable');
114
+ const clientId = clean(session.client_id) || clean(options.clientId) || AGENTSAM_NATIVE_OAUTH_CLIENT_ID;
115
+ const refreshed = await oauthTokenRequest({
116
+ grant_type: 'refresh_token',
117
+ client_id: clientId,
118
+ refresh_token: session.refresh_token,
119
+ }, options);
120
+
121
+ return saveAccountSession({
122
+ ...refreshed,
123
+ refresh_token: clean(refreshed.refresh_token) || session.refresh_token,
124
+ client_id: clientId,
125
+ user_id: session.user_id,
126
+ account_id: session.account_id,
127
+ email: session.email,
128
+ }, {
129
+ ...options,
130
+ preserveRefreshToken: true,
131
+ });
12
132
  }
13
133
 
14
134
  /**
15
- * @returns {Promise<{ access_token: string, user_id: string, workspace_id: string, tenant_id: string }>}
135
+ * Canonical SDK account authority resolution.
136
+ * explicit aak_* -> AGENTSAM_API_KEY -> stored browser OAuth -> OAuth refresh.
16
137
  */
17
- export async function authenticateViaBrowser() {
18
- const state = randomState();
19
- const port = 8791 + (randomBytes(1)[0] % 20);
20
- const redirectUri = `http://127.0.0.1:${port}/callback`;
138
+ export async function resolveAccountAuthority(options = {}) {
139
+ const apiKey = resolveAccountApiKey(options);
140
+ if (apiKey.value || apiKey.error) return apiKey;
21
141
 
22
- const { auth_url: authUrl } = await postJson('/api/sdk/auth/start', {
23
- redirect_uri: redirectUri,
24
- state,
142
+ let session = readAccountSession(options);
143
+ if (!session?.access_token) return { value: '', source: null, kind: null, session: null };
144
+
145
+ if (isBrowserSessionExpired(session, options)) {
146
+ if (!session.refresh_token) {
147
+ return {
148
+ value: '',
149
+ source: 'agentsam_browser_oauth',
150
+ kind: 'browser_oauth',
151
+ session,
152
+ error: 'browser_oauth_session_expired',
153
+ };
154
+ }
155
+ try {
156
+ const refreshImpl = options.refreshImpl || refreshAccountSession;
157
+ session = await refreshImpl({ ...options, session });
158
+ } catch (error) {
159
+ return {
160
+ value: '',
161
+ source: 'agentsam_browser_oauth',
162
+ kind: 'browser_oauth',
163
+ session,
164
+ error: `browser_oauth_refresh_failed: ${error?.message || String(error)}`,
165
+ };
166
+ }
167
+ }
168
+
169
+ return {
170
+ value: session.access_token,
171
+ source: 'agentsam_browser_oauth',
172
+ kind: 'browser_oauth',
173
+ session,
174
+ };
175
+ }
176
+
177
+ export async function createLoopbackCallbackListener(options = {}) {
178
+ const host = clean(options.host) || '127.0.0.1';
179
+ const callbackPath = clean(options.callbackPath) || AGENTSAM_OAUTH_CALLBACK_PATH;
180
+ const expectedState = clean(options.state);
181
+ if (!expectedState) throw new Error('oauth_state_required');
182
+ const timeoutMs = Number.isFinite(Number(options.timeoutMs)) ? Number(options.timeoutMs) : 180_000;
183
+ const createServerImpl = options.createServerImpl || http.createServer;
184
+
185
+ let settle;
186
+ let settled = false;
187
+ let timer = null;
188
+ const callbackPromise = new Promise((resolve, reject) => {
189
+ settle = (error, result) => {
190
+ if (settled) return;
191
+ settled = true;
192
+ if (timer) clearTimeout(timer);
193
+ if (error) reject(error);
194
+ else resolve(result);
195
+ };
25
196
  });
26
197
 
27
- if (!authUrl) throw new Error('IAM auth did not return an authorization URL');
198
+ const server = createServerImpl((req, res) => {
199
+ try {
200
+ const requestUrl = new URL(req.url || '/', `http://${host}`);
201
+ if (requestUrl.pathname !== callbackPath) {
202
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
203
+ res.end('Not found');
204
+ return;
205
+ }
28
206
 
29
- const codePromise = new Promise((resolve, reject) => {
30
- const server = http.createServer((req, res) => {
31
- try {
32
- const u = new URL(req.url || '/', `http://127.0.0.1:${port}`);
33
- if (u.pathname !== '/callback') {
34
- res.writeHead(404);
35
- res.end('Not found');
36
- return;
37
- }
38
- const code = u.searchParams.get('code');
39
- const gotState = u.searchParams.get('state');
40
- if (!code || gotState !== state) {
41
- res.writeHead(400);
42
- res.end('Invalid callback');
43
- reject(new Error('auth callback invalid'));
44
- server.close();
45
- return;
46
- }
47
- res.writeHead(200, { 'Content-Type': 'text/html' });
48
- res.end('<html><body style="font-family:system-ui"><h1>Agent Sam</h1><p>Authentication complete. You can close this tab and return to your terminal.</p></body></html>');
49
- resolve(code);
50
- server.close();
51
- } catch (e) {
52
- reject(e);
53
- server.close();
207
+ const returnedState = clean(requestUrl.searchParams.get('state'));
208
+ const oauthError = clean(requestUrl.searchParams.get('error'));
209
+ const oauthDescription = clean(requestUrl.searchParams.get('error_description'));
210
+ const code = clean(requestUrl.searchParams.get('code'));
211
+
212
+ if (!returnedState || returnedState !== expectedState) {
213
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
214
+ res.end('Invalid OAuth state. Return to the terminal and retry.');
215
+ settle(new Error('oauth_state_mismatch'));
216
+ return;
54
217
  }
55
- });
56
- server.on('error', reject);
57
- server.listen(port, '127.0.0.1');
218
+ if (oauthError) {
219
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
220
+ res.end('Authorization was not completed. Return to the terminal.');
221
+ settle(new Error(oauthDescription ? `${oauthError}: ${oauthDescription}` : oauthError));
222
+ return;
223
+ }
224
+ if (!code) {
225
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
226
+ res.end('Authorization code missing. Return to the terminal and retry.');
227
+ settle(new Error('oauth_authorization_code_missing'));
228
+ return;
229
+ }
230
+
231
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
232
+ res.end('<!doctype html><html><body style="font-family:system-ui"><h1>Agent Sam</h1><p>Authentication complete. You can close this tab and return to your terminal.</p></body></html>');
233
+ settle(null, { code, state: returnedState });
234
+ } catch (error) {
235
+ try {
236
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
237
+ res.end('OAuth callback failed. Return to the terminal.');
238
+ } catch { /* response may already be closed */ }
239
+ settle(error);
240
+ }
58
241
  });
59
242
 
60
- await promptToOpenUrl(authUrl, {
61
- heading: 'Authenticate your InnerAnimalMedia account at:',
62
- prompt: 'Press ENTER to open InnerAnimalMedia sign-in in your browser.',
243
+ await new Promise((resolve, reject) => {
244
+ const onError = (error) => {
245
+ server.off('listening', onListening);
246
+ reject(error);
247
+ };
248
+ const onListening = () => {
249
+ server.off('error', onError);
250
+ resolve();
251
+ };
252
+ server.once('error', onError);
253
+ server.once('listening', onListening);
254
+ server.listen({ host, port: Number(options.port) || 0, exclusive: true });
63
255
  });
64
256
 
65
- const code = await codePromise;
66
- const session = await postJson('/api/sdk/auth/exchange', { code, state });
67
- if (String(session?.access_token || '').trim().startsWith('sdk_')) {
68
- saveAccountSession(session);
257
+ const address = server.address();
258
+ if (!address || typeof address === 'string') {
259
+ server.close();
260
+ throw new Error('oauth_loopback_listener_address_unavailable');
261
+ }
262
+ const redirectUri = `http://${host}:${address.port}${callbackPath}`;
263
+ timer = setTimeout(() => settle(new Error('oauth_callback_timeout')), Math.max(1, timeoutMs));
264
+ timer.unref?.();
265
+
266
+ return {
267
+ redirectUri,
268
+ waitForCallback: () => callbackPromise,
269
+ close: () => new Promise((resolve) => {
270
+ if (!server.listening) return resolve();
271
+ server.close(() => resolve());
272
+ }),
273
+ };
274
+ }
275
+
276
+ export async function authenticateViaBrowser(options = {}) {
277
+ const env = options.env || process.env;
278
+ const clientId = clean(options.clientId) || AGENTSAM_NATIVE_OAUTH_CLIENT_ID;
279
+ const state = randomUrlSafe(24, options.randomBytesImpl || randomBytes);
280
+ const pkce = createPkcePair({ randomBytesImpl: options.randomBytesImpl });
281
+ const listener = await createLoopbackCallbackListener({
282
+ state,
283
+ host: options.host,
284
+ port: options.port,
285
+ callbackPath: options.callbackPath,
286
+ timeoutMs: options.timeoutMs,
287
+ createServerImpl: options.createServerImpl,
288
+ });
289
+
290
+ try {
291
+ const authorizationUrl = buildNativeAuthorizationUrl({
292
+ env,
293
+ issuer: options.issuer,
294
+ clientId,
295
+ redirectUri: listener.redirectUri,
296
+ state,
297
+ codeChallenge: pkce.challenge,
298
+ scope: options.scope,
299
+ });
300
+
301
+ const promptImpl = options.promptToOpenUrlImpl || promptToOpenUrl;
302
+ await promptImpl(authorizationUrl, {
303
+ heading: 'Authenticate your InnerAnimalMedia account at:',
304
+ prompt: 'Press ENTER to open InnerAnimalMedia sign-in in your browser.',
305
+ input: options.input,
306
+ output: options.output,
307
+ openImpl: options.openImpl,
308
+ });
309
+
310
+ const callback = await listener.waitForCallback();
311
+ const tokenSet = await exchangeAuthorizationCode({
312
+ env,
313
+ issuer: options.issuer,
314
+ clientId,
315
+ redirectUri: listener.redirectUri,
316
+ code: callback.code,
317
+ codeVerifier: pkce.verifier,
318
+ fetchImpl: options.fetchImpl,
319
+ signal: options.signal,
320
+ });
321
+
322
+ return saveAccountSession({
323
+ ...tokenSet,
324
+ client_id: clientId,
325
+ }, {
326
+ home: options.home,
327
+ env,
328
+ nowMs: options.nowMs,
329
+ preserveRefreshToken: false,
330
+ });
331
+ } finally {
332
+ await listener.close();
69
333
  }
70
- return session;
71
334
  }
@@ -3,8 +3,8 @@ import path from 'node:path';
3
3
  import { spawnSync } from 'node:child_process';
4
4
  import { getProjectName, tryReadProjectConfig } from './project-config.js';
5
5
 
6
- export const CLI_PREFERENCES_SCHEMA = 'agentsam-cli-preferences-v2';
7
- export const LEGACY_CLI_PREFERENCES_SCHEMA = 'agentsam-cli-preferences-v1';
6
+ export const CLI_PREFERENCES_SCHEMA = 'agentsam-cli-preferences-v3';
7
+ export const LEGACY_CLI_PREFERENCES_SCHEMAS = new Set(['agentsam-cli-preferences-v1', 'agentsam-cli-preferences-v2']);
8
8
 
9
9
  function readJson(filename) {
10
10
  try { return JSON.parse(fs.readFileSync(filename, 'utf8')); }
@@ -43,16 +43,43 @@ export function detectCliProject(startDir = process.cwd()) {
43
43
 
44
44
  export function cliPreferencesPath(root) { return path.join(path.resolve(root), '.agentsam', 'cli.json'); }
45
45
 
46
+ function safeModelSnapshot(value) {
47
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
48
+ const provider = String(value.provider || '').trim();
49
+ const providerModelId = String(value.provider_model_id || '').trim();
50
+ if (!provider || !providerModelId) return null;
51
+ return {
52
+ model_key: String(value.model_key || `${provider}:${providerModelId}`),
53
+ provider,
54
+ provider_model_id: providerModelId,
55
+ label: String(value.label || providerModelId),
56
+ availability: value.availability === 'available' ? 'available' : 'unverified',
57
+ availability_source: String(value.availability_source || ''),
58
+ context_window: Number.isFinite(Number(value.context_window)) && Number(value.context_window) > 0 ? Number(value.context_window) : null,
59
+ context_window_source: String(value.context_window_source || 'unknown'),
60
+ max_output_tokens: Number.isFinite(Number(value.max_output_tokens)) && Number(value.max_output_tokens) > 0 ? Number(value.max_output_tokens) : null,
61
+ max_output_tokens_source: String(value.max_output_tokens_source || 'unknown'),
62
+ reasoning_efforts: Array.isArray(value.reasoning_efforts) && value.reasoning_efforts.length ? value.reasoning_efforts.map(String) : ['auto'],
63
+ service_tiers: Array.isArray(value.service_tiers) && value.service_tiers.length ? value.service_tiers.map(String) : ['default'],
64
+ capabilities: value.capabilities && typeof value.capabilities === 'object' ? { ...value.capabilities } : {},
65
+ pricing: value.pricing && typeof value.pricing === 'object' ? { ...value.pricing } : null,
66
+ context_policy: value.context_policy && typeof value.context_policy === 'object' ? { ...value.context_policy } : null,
67
+ source: value.source && typeof value.source === 'object' ? { ...value.source } : null,
68
+ };
69
+ }
70
+
46
71
  function normalizePreferences(value = {}) {
72
+ const modelSnapshot = safeModelSnapshot(value.modelSnapshot);
47
73
  return {
48
74
  schemaVersion: CLI_PREFERENCES_SCHEMA,
49
75
  trustedDirectory: value.trustedDirectory === true,
50
76
  runtime: value.runtime || 'local',
51
77
  terminal: value.terminal || '',
52
78
  modelPreference: value.modelPreference || 'auto',
79
+ modelSnapshot,
53
80
  reasoningEffort: value.reasoningEffort || 'auto',
54
81
  serviceTier: value.serviceTier || 'default',
55
- modelAuthority: 'preference-only',
82
+ modelAuthority: modelSnapshot?.availability === 'available' ? 'provider-verified' : 'preference-only',
56
83
  updatedAt: value.updatedAt || null,
57
84
  };
58
85
  }
@@ -60,7 +87,7 @@ function normalizePreferences(value = {}) {
60
87
  export function readCliPreferences(root) {
61
88
  const value = readJson(cliPreferencesPath(root));
62
89
  if (!value) return null;
63
- if (value.schemaVersion !== CLI_PREFERENCES_SCHEMA && value.schemaVersion !== LEGACY_CLI_PREFERENCES_SCHEMA) return null;
90
+ if (value.schemaVersion !== CLI_PREFERENCES_SCHEMA && !LEGACY_CLI_PREFERENCES_SCHEMAS.has(value.schemaVersion)) return null;
64
91
  return normalizePreferences(value);
65
92
  }
66
93