@superjackfruitlabs/salaam 0.0.1 → 0.0.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 (110) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +25 -17
  3. package/dist/src/a2a-backend.js +225 -0
  4. package/dist/src/a2a-inbound.js +487 -0
  5. package/dist/src/a2a-peer.js +370 -0
  6. package/dist/src/a2a-protocol.js +250 -0
  7. package/dist/src/agent-profiles.js +289 -0
  8. package/dist/src/children.js +132 -26
  9. package/dist/src/claude-provider.js +2 -179
  10. package/dist/src/cli-help.js +107 -6
  11. package/dist/src/cli.js +477 -13
  12. package/dist/src/config.js +26 -3
  13. package/dist/src/credentials.js +7 -2
  14. package/dist/src/external-runtime.js +1 -0
  15. package/dist/src/external-work.js +583 -0
  16. package/dist/src/harness-backend.js +466 -0
  17. package/dist/src/harness-container.js +450 -0
  18. package/dist/src/harness-model-gateway.js +104 -0
  19. package/dist/src/harness-prepare.js +88 -0
  20. package/dist/src/harness-probe.js +111 -0
  21. package/dist/src/harness-protocol.js +325 -0
  22. package/dist/src/harness-readiness.js +50 -0
  23. package/dist/src/harness-rpc.js +206 -0
  24. package/dist/src/harness-tool-identity.js +87 -0
  25. package/dist/src/harness-wire-bridge.js +387 -0
  26. package/dist/src/host.js +6 -1
  27. package/dist/src/house-extension.js +147 -64
  28. package/dist/src/live-provider.js +301 -0
  29. package/dist/src/managed.js +33 -3
  30. package/dist/src/protocol.js +9 -5
  31. package/dist/src/provider-auth.js +300 -0
  32. package/dist/src/provider-config.js +65 -17
  33. package/dist/src/provider-model.js +78 -0
  34. package/dist/src/provider-payload.js +264 -0
  35. package/dist/src/provider-transport.js +36 -0
  36. package/dist/src/provider-usage.js +59 -0
  37. package/dist/src/repository-snapshots.js +219 -0
  38. package/dist/src/service.js +274 -9
  39. package/dist/src/store.js +44 -2
  40. package/dist/src/supervisor.js +246 -35
  41. package/dist/src/task-budget.js +17 -8
  42. package/dist/src/task-context.js +15 -0
  43. package/dist/src/task-driver.js +38 -14
  44. package/dist/src/task-policy.js +20 -1
  45. package/dist/src/task-verification.js +3 -0
  46. package/dist/src/task-work.js +31 -0
  47. package/dist/src/team-work.js +378 -0
  48. package/dist/src/tui-view.js +65 -10
  49. package/dist/src/tui.js +4 -2
  50. package/dist/src/usage.js +18 -0
  51. package/dist/src/worker-boundary.js +298 -0
  52. package/dist/src/worker-workspaces.js +274 -0
  53. package/dist/src/workspaces.js +6 -2
  54. package/harness-runtime/codex-pins.json +10 -0
  55. package/harness-runtime/package-lock.json +560 -0
  56. package/harness-runtime/package.json +9 -0
  57. package/npm-shrinkwrap.json +57 -2
  58. package/package.json +2 -1
  59. package/src/a2a-backend.ts +254 -0
  60. package/src/a2a-inbound.ts +585 -0
  61. package/src/a2a-peer.ts +441 -0
  62. package/src/a2a-protocol.ts +286 -0
  63. package/src/agent-profiles.ts +379 -0
  64. package/src/children.ts +168 -27
  65. package/src/claude-provider.ts +2 -189
  66. package/src/cli-help.ts +107 -6
  67. package/src/cli.ts +602 -12
  68. package/src/config.ts +34 -3
  69. package/src/credentials.ts +9 -2
  70. package/src/external-runtime.ts +54 -0
  71. package/src/external-work.ts +663 -0
  72. package/src/harness-backend.ts +557 -0
  73. package/src/harness-container.ts +543 -0
  74. package/src/harness-model-gateway.ts +138 -0
  75. package/src/harness-prepare.ts +113 -0
  76. package/src/harness-probe.ts +150 -0
  77. package/src/harness-protocol.ts +388 -0
  78. package/src/harness-readiness.ts +71 -0
  79. package/src/harness-rpc.ts +244 -0
  80. package/src/harness-tool-identity.ts +91 -0
  81. package/src/harness-wire-bridge.ts +418 -0
  82. package/src/host.ts +11 -6
  83. package/src/house-extension.ts +165 -66
  84. package/src/live-provider.ts +347 -0
  85. package/src/managed.ts +43 -4
  86. package/src/protocol.ts +36 -8
  87. package/src/provider-auth.ts +336 -0
  88. package/src/provider-config.ts +89 -18
  89. package/src/provider-model.ts +86 -0
  90. package/src/provider-payload.ts +263 -0
  91. package/src/provider-transport.ts +51 -0
  92. package/src/provider-usage.ts +64 -0
  93. package/src/repository-snapshots.ts +259 -0
  94. package/src/service.ts +446 -11
  95. package/src/store.ts +60 -4
  96. package/src/supervisor.ts +294 -41
  97. package/src/task-budget.ts +24 -11
  98. package/src/task-context.ts +17 -0
  99. package/src/task-driver.ts +51 -15
  100. package/src/task-policy.ts +23 -3
  101. package/src/task-verification.ts +2 -0
  102. package/src/task-work.ts +46 -1
  103. package/src/team-work.ts +554 -0
  104. package/src/tui-client.ts +5 -0
  105. package/src/tui-view.ts +164 -12
  106. package/src/tui.ts +5 -2
  107. package/src/usage.ts +17 -0
  108. package/src/worker-boundary.ts +395 -0
  109. package/src/worker-workspaces.ts +390 -0
  110. package/src/workspaces.ts +4 -2
package/src/managed.ts CHANGED
@@ -139,7 +139,7 @@ export class ManagedWork {
139
139
  epoch: number,
140
140
  attempt: string,
141
141
  call: string,
142
- kind: 'review' | 'effect',
142
+ kind: 'review' | 'effect' | 'harness',
143
143
  target: string,
144
144
  value: string,
145
145
  ): Promise<{ operation: Operation; approval: Approval }> {
@@ -167,7 +167,11 @@ export class ManagedWork {
167
167
  if (kind === 'effect' && !connector)
168
168
  throw new ServiceError('effect_disabled', 'No external effect connector is configured');
169
169
  const version =
170
- kind === 'review' ? 'read-only' : await connector!.version(target, AbortSignal.timeout(2500));
170
+ kind === 'harness'
171
+ ? 'owned-once-only'
172
+ : kind === 'review'
173
+ ? 'read-only'
174
+ : await connector!.version(target, AbortSignal.timeout(2500));
171
175
  return this.store.transaction(() => {
172
176
  this.options.assertActive(session, epoch, attempt);
173
177
  const existing = this.store.db
@@ -204,8 +208,13 @@ export class ManagedWork {
204
208
  value,
205
209
  target_version: version,
206
210
  policy_digest: POLICY_DIGEST,
207
- connector: kind === 'review' ? 'local-review-v1' : connector!.id,
208
- replay: kind === 'review' ? 'read-only' : connector!.replay,
211
+ connector:
212
+ kind === 'harness'
213
+ ? 'external-harness-once-v1'
214
+ : kind === 'review'
215
+ ? 'local-review-v1'
216
+ : connector!.id,
217
+ replay: kind === 'harness' ? 'opaque' : kind === 'review' ? 'read-only' : connector!.replay,
209
218
  revision: 1,
210
219
  state: 'prepared',
211
220
  receipt: null,
@@ -389,6 +398,11 @@ export class ManagedWork {
389
398
  }
390
399
  start(id: string, session: string, epoch: number, attempt: string): Operation {
391
400
  const op = this.operation(id);
401
+ if (op.kind === 'harness')
402
+ throw new ServiceError(
403
+ 'operation_scope',
404
+ 'A harness permission can only be consumed by its owned runtime',
405
+ );
392
406
  if (op.session_id !== session || op.worker_epoch !== epoch || op.attempt_id !== attempt)
393
407
  throw new ServiceError('operation_scope', 'Operation belongs to another invocation', 403);
394
408
  if (op.state !== 'prepared' || this.active.has(id)) return op;
@@ -410,6 +424,31 @@ export class ManagedWork {
410
424
  argument_digest: op.argument_digest,
411
425
  };
412
426
  }
427
+ /** Commit a one-time grant handoff before replying to the owned runtime. This is not a tool receipt. */
428
+ takePermission(id: string): boolean {
429
+ return this.store.transaction(() => {
430
+ const op = this.operation(id);
431
+ if (op.kind !== 'harness' || op.state !== 'prepared') return false;
432
+ this.fresh(op);
433
+ const approval = this.forOperation(id);
434
+ if (approval.state !== 'decided' || approval.decision !== 'approve') return false;
435
+ this.transition(op, 'dispatching');
436
+ return true;
437
+ });
438
+ }
439
+ permissionResult(id: string, success: boolean, receipt: unknown): void {
440
+ const op = this.operation(id);
441
+ if (op.kind !== 'harness' || op.state !== 'dispatching') return;
442
+ this.store.transaction(() =>
443
+ this.transition(
444
+ op,
445
+ success ? 'succeeded' : 'outcome_unknown',
446
+ success ? null : 'Harness tool failed after permission delivery; inspect actual effects',
447
+ JSON.stringify(receipt),
448
+ 'owned-harness-tool-return',
449
+ ),
450
+ );
451
+ }
413
452
  private async dispatch(id: string, signal: AbortSignal): Promise<void> {
414
453
  try {
415
454
  let op = this.operation(id);
package/src/protocol.ts CHANGED
@@ -10,6 +10,7 @@ export const CAPABILITIES = [
10
10
  'durable-approvals',
11
11
  'operation-ledger',
12
12
  'child-work',
13
+ 'worker-boundary-v1',
13
14
  'usage-ledger',
14
15
  'durable-task-context',
15
16
  'managed-compaction',
@@ -24,28 +25,40 @@ export const TOOLS = [
24
25
  ] as const;
25
26
  export interface CodingBinding {
26
27
  workspace_id: string;
27
- files: true;
28
+ files: boolean;
28
29
  commands: boolean;
29
30
  profile_digest: string | null;
30
31
  }
31
- export function workerCapabilities(coding?: CodingBinding): string[] {
32
+ export function workerCapabilities(
33
+ coding?: CodingBinding,
34
+ childTools?: readonly string[],
35
+ ): string[] {
32
36
  return [
33
37
  ...CAPABILITIES,
34
- ...(coding ? ['workspace-files'] : []),
38
+ ...(coding?.files ? ['workspace-files'] : []),
35
39
  ...(coding?.commands ? ['command-execution'] : []),
40
+ ...(childTools?.includes('salaam_repository') ? ['repository-snapshot-read'] : []),
41
+ ...(childTools?.includes('salaam_findings') ? ['structured-findings'] : []),
42
+ ...(childTools?.includes('salaam_team') ? ['durable-team-messages'] : []),
36
43
  ];
37
44
  }
38
- export function workerTools(effects: boolean, child = false, coding?: CodingBinding): string[] {
45
+ export function workerTools(
46
+ effects: boolean,
47
+ child = false,
48
+ coding?: CodingBinding,
49
+ childTools?: readonly string[],
50
+ ): string[] {
39
51
  return child
40
- ? ['salaam_probe']
52
+ ? [...(childTools ?? ['salaam_probe'])].sort()
41
53
  : [
42
54
  ...TOOLS,
43
55
  ...(effects ? ['salaam_effect'] : []),
44
- ...(coding ? ['salaam_files'] : []),
56
+ ...(coding?.files ? ['salaam_files'] : []),
45
57
  ...(coding?.commands ? ['salaam_command'] : []),
46
58
  ].sort();
47
59
  }
48
60
  export interface ChildTask {
61
+ worker?: import('./worker-boundary.js').WorkerBoundaryView;
49
62
  id: string;
50
63
  parent_session: string;
51
64
  parent_attempt: string;
@@ -75,6 +88,11 @@ export interface Session {
75
88
  capabilities: string[];
76
89
  seq: number;
77
90
  provider_route?: ProviderRoute;
91
+ provider_capabilities?: {
92
+ output_token_cap: boolean;
93
+ durable_tasks: boolean;
94
+ warning: string | null;
95
+ };
78
96
  }
79
97
  export interface DurableEvent {
80
98
  schema_version: 1;
@@ -86,7 +104,14 @@ export interface DurableEvent {
86
104
  payload: unknown;
87
105
  }
88
106
  export type AttemptState =
89
- 'accepted' | 'dispatching' | 'running' | 'completed' | 'failed' | 'interrupted' | 'cancelled';
107
+ | 'accepted'
108
+ | 'dispatching'
109
+ | 'running'
110
+ | 'completed'
111
+ | 'failed'
112
+ | 'interrupted'
113
+ | 'cancelled'
114
+ | 'delegated';
90
115
  export interface Attempt {
91
116
  id: string;
92
117
  session_id: string;
@@ -118,6 +143,8 @@ export interface Controller {
118
143
  token: string;
119
144
  }
120
145
  export interface SessionSnapshot extends Session {
146
+ team?: unknown;
147
+ agents?: ReturnType<import('./agent-profiles.js').AgentProfiles['snapshot']>;
121
148
  inbox?: import('./operator-inbox.js').InboxEntry[];
122
149
  recurring_occurrence?: import('./recurring-work.js').RecurringOccurrence | null;
123
150
  work_task?: import('./task-work.js').WorkTask | null;
@@ -150,7 +177,7 @@ export interface Operation {
150
177
  attempt_id: string;
151
178
  worker_epoch: number;
152
179
  tool_call_id: string;
153
- kind: 'review' | 'effect';
180
+ kind: 'review' | 'effect' | 'harness';
154
181
  action_key: string;
155
182
  argument_digest: string;
156
183
  operation_digest: string;
@@ -183,6 +210,7 @@ export interface Approval {
183
210
  decided_by: string | null;
184
211
  }
185
212
  export interface Manifest {
213
+ worker_binding?: import('./worker-boundary.js').WorkerBoundaryView;
186
214
  protocol_version: number;
187
215
  bundle_digest: string;
188
216
  policy_digest: string;
@@ -0,0 +1,336 @@
1
+ import { builtinModels } from '@earendil-works/pi-ai/providers/all';
2
+ import {
3
+ defaultProviderAuthContext,
4
+ type Credential,
5
+ type CredentialStore,
6
+ type CredentialInfo,
7
+ type AuthOperationOptions,
8
+ type AuthResult,
9
+ type AuthInteraction,
10
+ } from '@earendil-works/pi-ai';
11
+ import { loadSharedConfigFiles } from '@smithy/core/config';
12
+ import { DatabaseSync } from 'node:sqlite';
13
+ import {
14
+ existsSync,
15
+ lstatSync,
16
+ mkdirSync,
17
+ chmodSync,
18
+ openSync,
19
+ closeSync,
20
+ constants,
21
+ fstatSync,
22
+ readFileSync,
23
+ writeFileSync,
24
+ fsyncSync,
25
+ renameSync,
26
+ unlinkSync,
27
+ } from 'node:fs';
28
+ import { homedir } from 'node:os';
29
+ import { join } from 'node:path';
30
+ import { randomUUID } from 'node:crypto';
31
+ import { credentialPath, loadApiKey, validateApiKey } from './credentials.js';
32
+ import { getProvider, getProviderModel, type ProviderRoute } from './provider-config.js';
33
+
34
+ export const authDirectory = () => join(homedir(), '.config', 'salaam', 'credentials');
35
+ function providerId(id: string) {
36
+ if (!getProvider(id) || !/^[a-z0-9-]+$/.test(id)) throw new Error('Unknown Pi provider');
37
+ return id;
38
+ }
39
+ function secureDirectory(dir: string) {
40
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
41
+ const stat = lstatSync(dir);
42
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.())
43
+ throw new Error('Unsafe credential directory');
44
+ chmodSync(dir, 0o700);
45
+ }
46
+ function privateRead(path: string): string | undefined {
47
+ let fd: number;
48
+ try {
49
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
50
+ } catch (error) {
51
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
52
+ throw new Error('Unsafe provider credential file');
53
+ }
54
+ try {
55
+ const stat = fstatSync(fd);
56
+ if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.mode & 0o077 || stat.size > 65536)
57
+ throw new Error('Provider credential file must be private (mode 600)');
58
+ return readFileSync(fd, 'utf8');
59
+ } finally {
60
+ closeSync(fd);
61
+ }
62
+ }
63
+ /** Serialize OAuth refresh across daemon and CLI processes; OS locks release on death. */
64
+ export class ProviderCredentialStore implements CredentialStore {
65
+ constructor(readonly dir = authDirectory()) {}
66
+ private path(id: string) {
67
+ return join(this.dir, providerId(id) + '.json');
68
+ }
69
+ async read(id: string, options?: AuthOperationOptions): Promise<Credential | undefined> {
70
+ options?.signal?.throwIfAborted();
71
+ const raw = privateRead(this.path(id));
72
+ if (raw !== undefined) {
73
+ const value = JSON.parse(raw) as Credential;
74
+ if (!['api_key', 'oauth'].includes(value.type))
75
+ throw new Error('Invalid stored provider credential');
76
+ return value;
77
+ }
78
+ // Preserve existing Salaam installs without reading Pi's credential home.
79
+ if (id === 'anthropic' && this.dir === authDirectory() && existsSync(credentialPath()))
80
+ return { type: 'api_key', key: loadApiKey(credentialPath(), '') };
81
+ return undefined;
82
+ }
83
+ async list(options?: AuthOperationOptions): Promise<readonly CredentialInfo[]> {
84
+ const result: CredentialInfo[] = [];
85
+ for (const p of builtinModels().getProviders()) {
86
+ const value = await this.read(p.id, options);
87
+ if (value) result.push({ providerId: p.id, type: value.type });
88
+ }
89
+ return result;
90
+ }
91
+ private async locked<T>(
92
+ id: string,
93
+ fn: () => Promise<T>,
94
+ options?: AuthOperationOptions,
95
+ ): Promise<T> {
96
+ secureDirectory(this.dir);
97
+ const path = join(this.dir, providerId(id) + '.lock.sqlite');
98
+ if (existsSync(path)) privateRead(path);
99
+ const fd = openSync(path, constants.O_RDWR | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
100
+ closeSync(fd);
101
+ const db = new DatabaseSync(path);
102
+ const deadline = Date.now() + 120000;
103
+ try {
104
+ db.exec('PRAGMA busy_timeout=0');
105
+ while (true) {
106
+ options?.signal?.throwIfAborted();
107
+ try {
108
+ db.exec('BEGIN EXCLUSIVE');
109
+ break;
110
+ } catch (error) {
111
+ if (!String(error).includes('locked') || Date.now() > deadline)
112
+ throw new Error('Provider credential lock unavailable');
113
+ await new Promise((resolve) => setTimeout(resolve, 25));
114
+ }
115
+ }
116
+ try {
117
+ return await fn();
118
+ } finally {
119
+ db.exec('ROLLBACK');
120
+ }
121
+ } finally {
122
+ db.close();
123
+ }
124
+ }
125
+ async modify(
126
+ id: string,
127
+ fn: (current: Credential | undefined) => Promise<Credential | undefined>,
128
+ options?: AuthOperationOptions,
129
+ ) {
130
+ return this.locked(
131
+ id,
132
+ async () => {
133
+ const current = await this.read(id, options),
134
+ value = await fn(current);
135
+ options?.signal?.throwIfAborted();
136
+ if (value !== undefined) {
137
+ const temp = join(this.dir, '.' + id + '-' + randomUUID()),
138
+ fd = openSync(temp, 'wx', 0o600);
139
+ try {
140
+ writeFileSync(fd, JSON.stringify(value));
141
+ fsyncSync(fd);
142
+ } finally {
143
+ closeSync(fd);
144
+ }
145
+ renameSync(temp, this.path(id));
146
+ const directory = openSync(this.dir, 'r');
147
+ try {
148
+ fsyncSync(directory);
149
+ } finally {
150
+ closeSync(directory);
151
+ }
152
+ }
153
+ return value ?? current;
154
+ },
155
+ options,
156
+ );
157
+ }
158
+ async delete(id: string, options?: AuthOperationOptions) {
159
+ await this.locked(
160
+ id,
161
+ async () => {
162
+ for (const path of [
163
+ this.path(id),
164
+ ...(id === 'anthropic' && this.dir === authDirectory() ? [credentialPath()] : []),
165
+ ])
166
+ try {
167
+ unlinkSync(path);
168
+ } catch (error) {
169
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
170
+ }
171
+ },
172
+ options,
173
+ );
174
+ }
175
+ }
176
+ export function providerModels(store: CredentialStore = new ProviderCredentialStore()) {
177
+ return builtinModels({ credentials: store });
178
+ }
179
+ export function providerDeviceId(dir = authDirectory()): string {
180
+ secureDirectory(dir);
181
+ const path = join(dir, '.device-id');
182
+ let value = privateRead(path);
183
+ if (value === undefined) {
184
+ const id = randomUUID();
185
+ try {
186
+ const fd = openSync(path, 'wx', 0o600);
187
+ try {
188
+ writeFileSync(fd, id);
189
+ fsyncSync(fd);
190
+ } finally {
191
+ closeSync(fd);
192
+ }
193
+ } catch (error) {
194
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
195
+ }
196
+ value = privateRead(path);
197
+ }
198
+ if (!value || !/^[a-f0-9-]{36}$/.test(value)) throw new Error('Invalid provider device identity');
199
+ return value;
200
+ }
201
+ export async function loginProvider(
202
+ id: string,
203
+ type: 'api_key' | 'oauth',
204
+ interaction: AuthInteraction,
205
+ ) {
206
+ providerId(id);
207
+ try {
208
+ return await providerModels().login(id, type, interaction, {
209
+ agentName: 'Salaam',
210
+ getDeviceId: () => providerDeviceId(),
211
+ });
212
+ } catch {
213
+ throw new Error('Provider login failed or was cancelled; retry salaam auth login ' + id);
214
+ }
215
+ }
216
+ // These are SDK configuration fields, never a general process environment inheritance.
217
+ const sdkEnvironment: Record<string, string[]> = {
218
+ 'amazon-bedrock': [
219
+ 'AWS_REGION',
220
+ 'AWS_DEFAULT_REGION',
221
+ 'AWS_PROFILE',
222
+ 'AWS_ACCESS_KEY_ID',
223
+ 'AWS_SECRET_ACCESS_KEY',
224
+ 'AWS_SESSION_TOKEN',
225
+ 'AWS_BEARER_TOKEN_BEDROCK',
226
+ 'AWS_SHARED_CREDENTIALS_FILE',
227
+ 'AWS_CONFIG_FILE',
228
+ 'AWS_WEB_IDENTITY_TOKEN_FILE',
229
+ 'AWS_ROLE_ARN',
230
+ 'AWS_ROLE_SESSION_NAME',
231
+ 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI',
232
+ 'AWS_CONTAINER_CREDENTIALS_FULL_URI',
233
+ 'AWS_CONTAINER_AUTHORIZATION_TOKEN',
234
+ 'AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE',
235
+ ],
236
+ 'google-vertex': [
237
+ 'GOOGLE_CLOUD_PROJECT',
238
+ 'GCLOUD_PROJECT',
239
+ 'GOOGLE_CLOUD_LOCATION',
240
+ 'GOOGLE_APPLICATION_CREDENTIALS',
241
+ 'GOOGLE_CLOUD_API_KEY',
242
+ ],
243
+ azure: [
244
+ 'AZURE_OPENAI_BASE_URL',
245
+ 'AZURE_OPENAI_RESOURCE_NAME',
246
+ 'AZURE_OPENAI_API_VERSION',
247
+ 'AZURE_OPENAI_DEPLOYMENT_NAME_MAP',
248
+ ],
249
+ };
250
+ export async function resolveProviderAuth(
251
+ route: ProviderRoute,
252
+ store?: CredentialStore,
253
+ environment: NodeJS.ProcessEnv = process.env,
254
+ ): Promise<AuthResult> {
255
+ if (!store && route.provider === 'anthropic' && environment.ANTHROPIC_API_KEY)
256
+ return { auth: { apiKey: validateApiKey(environment.ANTHROPIC_API_KEY) }, env: {} };
257
+ const model = getProviderModel(route);
258
+ if (!model) throw new Error('Pinned provider model missing');
259
+ const baseContext = defaultProviderAuthContext();
260
+ const ambient = {
261
+ ...baseContext,
262
+ env: async (name: string) => environment[name]?.trim() || undefined,
263
+ };
264
+ const context = {
265
+ ...ambient,
266
+ env: async (name: string) =>
267
+ (await ambient.env(name)) ?? route.connection?.configuration[name],
268
+ },
269
+ captured: Record<string, string> = {};
270
+ const models = builtinModels({
271
+ credentials: store ?? new ProviderCredentialStore(),
272
+ authContext: {
273
+ ...context,
274
+ async env(name) {
275
+ const value = await context.env(name);
276
+ if (value !== undefined) captured[name] = value;
277
+ return value;
278
+ },
279
+ },
280
+ });
281
+ let result: AuthResult | undefined;
282
+ try {
283
+ result = await models.getAuth(model);
284
+ } catch {
285
+ throw new Error('Provider authentication failed; run salaam auth login ' + route.provider);
286
+ }
287
+ if (!result)
288
+ throw new Error('Provider credentials missing. Run: salaam auth login ' + route.provider);
289
+ for (const name of sdkEnvironment[route.provider] ?? [])
290
+ if (
291
+ !(
292
+ route.provider === 'amazon-bedrock' &&
293
+ result.auth.apiKey &&
294
+ ![
295
+ 'AWS_REGION',
296
+ 'AWS_DEFAULT_REGION',
297
+ 'AWS_PROFILE',
298
+ 'AWS_CONFIG_FILE',
299
+ 'AWS_SHARED_CREDENTIALS_FILE',
300
+ ].includes(name)
301
+ ) &&
302
+ (await context.env(name))
303
+ )
304
+ captured[name] = (await context.env(name))!;
305
+ if (route.provider === 'amazon-bedrock') {
306
+ captured.AWS_MAX_ATTEMPTS = '1';
307
+ captured.AWS_SHARED_CREDENTIALS_FILE ??= join(homedir(), '.aws', 'credentials');
308
+ captured.AWS_CONFIG_FILE ??= join(homedir(), '.aws', 'config');
309
+ const configured = { ...captured, ...result.env };
310
+ if (!configured.AWS_REGION && !configured.AWS_DEFAULT_REGION) {
311
+ const files = await loadSharedConfigFiles({
312
+ filepath: configured.AWS_SHARED_CREDENTIALS_FILE,
313
+ configFilepath: configured.AWS_CONFIG_FILE,
314
+ });
315
+ const profile = configured.AWS_PROFILE ?? 'default';
316
+ captured.AWS_REGION =
317
+ files.configFile[profile]?.region ?? files.credentialsFile[profile]?.region ?? 'us-east-1';
318
+ }
319
+ }
320
+ if (route.provider === 'google-vertex' && !result.auth.apiKey)
321
+ captured.GOOGLE_APPLICATION_CREDENTIALS ??= join(
322
+ homedir(),
323
+ '.config/gcloud/application_default_credentials.json',
324
+ );
325
+ const allowed = new Set([
326
+ ...Object.keys(captured),
327
+ ...(sdkEnvironment[route.provider] ?? []),
328
+ ...(route.provider.startsWith('cloudflare-')
329
+ ? ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_GATEWAY_ID']
330
+ : []),
331
+ ]);
332
+ const env = Object.fromEntries(
333
+ Object.entries({ ...captured, ...result.env }).filter(([key]) => allowed.has(key)),
334
+ );
335
+ return { ...result, env };
336
+ }
@@ -1,16 +1,41 @@
1
- import { anthropicProvider } from '@earendil-works/pi-ai/providers/anthropic';
1
+ import { builtinProviders } from '@earendil-works/pi-ai/providers/all';
2
+ import type { Api, Model, AuthResult } from '@earendil-works/pi-ai';
2
3
  import { ServiceError, object, canonical } from './protocol.js';
4
+ // Constructing the pinned catalog is offline; never refresh it implicitly during a turn.
5
+ export const PI_PROVIDERS = builtinProviders();
6
+ export function getProvider(id: string) {
7
+ return PI_PROVIDERS.find((provider) => provider.id === id);
8
+ }
9
+ export function getProviderModel(
10
+ route: Pick<ProviderRoute, 'provider' | 'model'>,
11
+ ): Model<Api> | undefined {
12
+ return getProvider(route.provider)
13
+ ?.getModels()
14
+ .find((model) => model.id === route.model);
15
+ }
3
16
  export function getClaudeModel(id: string) {
4
- return anthropicProvider()
5
- .getModels()
6
- .find((model) => model.id === id);
17
+ return getProviderModel({ provider: 'anthropic', model: id });
7
18
  }
8
-
19
+ export const ROUTING_FIELDS = [
20
+ 'AWS_REGION',
21
+ 'AWS_DEFAULT_REGION',
22
+ 'AWS_PROFILE',
23
+ 'GOOGLE_CLOUD_PROJECT',
24
+ 'GCLOUD_PROJECT',
25
+ 'GOOGLE_CLOUD_LOCATION',
26
+ 'AZURE_OPENAI_API_VERSION',
27
+ 'AZURE_OPENAI_BASE_URL',
28
+ 'AZURE_OPENAI_RESOURCE_NAME',
29
+ 'AZURE_OPENAI_DEPLOYMENT_NAME_MAP',
30
+ 'CLOUDFLARE_ACCOUNT_ID',
31
+ 'CLOUDFLARE_GATEWAY_ID',
32
+ ] as const;
9
33
  export interface ProviderRoute {
10
- provider: 'salaam-proof' | 'anthropic';
34
+ provider: string;
11
35
  model: string;
12
36
  max_tokens: number;
13
37
  workspace_id?: string;
38
+ connection?: { endpoint: string; configuration: Record<string, string> };
14
39
  }
15
40
  export const OFFLINE_ROUTE: ProviderRoute = {
16
41
  provider: 'salaam-proof',
@@ -23,30 +48,63 @@ export function providerRoute(
23
48
  model?: string,
24
49
  maxTokens?: number,
25
50
  workspace?: string,
51
+ connection?: ProviderRoute['connection'],
26
52
  ): ProviderRoute {
27
53
  if (provider === 'offline') {
28
- if (model || maxTokens || workspace)
29
- throw new ServiceError('invalid_provider', 'Offline mode does not take Claude options');
54
+ if (model || maxTokens !== undefined || workspace)
55
+ throw new ServiceError('invalid_provider', 'Offline mode does not take provider options');
30
56
  return { ...OFFLINE_ROUTE };
31
57
  }
32
- if (provider !== 'anthropic')
33
- throw new ServiceError('invalid_provider', 'Provider must be offline or anthropic');
34
- const id = model ?? DEFAULT_CLAUDE_MODEL;
35
- if (!['claude-sonnet-5-5', 'claude-sonnet-4-6'].includes(id) || !getClaudeModel(id))
58
+ const catalog = getProvider(provider);
59
+ if (!catalog)
60
+ throw new ServiceError(
61
+ 'invalid_provider',
62
+ 'Provider must be offline or a pinned Pi provider; run salaam providers list',
63
+ );
64
+ const id = model ?? (provider === 'anthropic' ? DEFAULT_CLAUDE_MODEL : undefined);
65
+ if (!id)
66
+ throw new ServiceError('invalid_model', 'Select --model from salaam models list ' + provider);
67
+ const entry = getProviderModel({ provider, model: id });
68
+ if (!entry)
36
69
  throw new ServiceError(
37
70
  'invalid_model',
38
- 'This route supports pinned claude-sonnet-5-5 or claude-sonnet-4-6',
71
+ 'This route supports pinned Pi chat models; run salaam models list ' + provider,
72
+ );
73
+ const cap = maxTokens ?? Math.min(4096, entry.maxTokens);
74
+ if (!Number.isSafeInteger(cap) || cap < 128 || cap > Math.min(8192, entry.maxTokens))
75
+ throw new ServiceError(
76
+ 'invalid_limit',
77
+ 'Max output tokens must be 128–' + Math.min(8192, entry.maxTokens),
39
78
  );
40
- const cap = maxTokens ?? 4096;
41
- if (!Number.isSafeInteger(cap) || cap < 128 || cap > 8192)
42
- throw new ServiceError('invalid_limit', 'Max output tokens must be 128–8192');
43
- if (workspace && !/^[a-zA-Z0-9_-]{1,128}$/.test(workspace))
79
+ if (workspace && (provider !== 'anthropic' || !/^[a-zA-Z0-9_-]{1,128}$/.test(workspace)))
44
80
  throw new ServiceError('invalid_workspace', 'Invalid Anthropic workspace ID');
81
+ if (connection) {
82
+ if (
83
+ Object.keys(connection).some((key) => !['endpoint', 'configuration'].includes(key)) ||
84
+ Object.keys(connection.configuration).some(
85
+ (key) => !ROUTING_FIELDS.includes(key as (typeof ROUTING_FIELDS)[number]),
86
+ )
87
+ )
88
+ throw new ServiceError('invalid_provider', 'Unsupported provider configuration');
89
+ const url = new URL(connection.endpoint);
90
+ if (
91
+ url.protocol !== 'https:' ||
92
+ url.username ||
93
+ url.password ||
94
+ url.hash ||
95
+ url.search ||
96
+ Object.values(connection.configuration).some(
97
+ (value) => typeof value !== 'string' || value.length > 4096 || /[^\x20-\x7e]/.test(value),
98
+ )
99
+ )
100
+ throw new ServiceError('invalid_provider', 'Invalid provider connection');
101
+ }
45
102
  return {
46
- provider: 'anthropic',
103
+ provider,
47
104
  model: id,
48
105
  max_tokens: cap,
49
106
  ...(workspace ? { workspace_id: workspace } : {}),
107
+ ...(connection ? { connection } : {}),
50
108
  };
51
109
  }
52
110
  /** Validate the final serialized HTTP payload, after all Pi payload hooks. */
@@ -102,3 +160,16 @@ export function validateClaudePayload(
102
160
  ])
103
161
  if (payload[key] !== undefined) throw new Error('Unsupported Claude execution feature');
104
162
  }
163
+
164
+ export function providerHasOutputCap(route: ProviderRoute, auth: AuthResult): boolean {
165
+ const model = getProviderModel(route);
166
+ return (
167
+ model?.api !== 'openai-codex-responses' &&
168
+ !(
169
+ model?.compat &&
170
+ 'supportsMaxOutputTokens' in model.compat &&
171
+ model.compat.supportsMaxOutputTokens === false
172
+ ) &&
173
+ !(route.provider === 'openai' && !!auth.auth.apiKey && !auth.auth.apiKey.startsWith('sk-'))
174
+ );
175
+ }