@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.
- package/CHANGELOG.md +15 -0
- package/README.md +25 -17
- package/dist/src/a2a-backend.js +225 -0
- package/dist/src/a2a-inbound.js +487 -0
- package/dist/src/a2a-peer.js +370 -0
- package/dist/src/a2a-protocol.js +250 -0
- package/dist/src/agent-profiles.js +289 -0
- package/dist/src/children.js +132 -26
- package/dist/src/claude-provider.js +2 -179
- package/dist/src/cli-help.js +107 -6
- package/dist/src/cli.js +477 -13
- package/dist/src/config.js +26 -3
- package/dist/src/credentials.js +7 -2
- package/dist/src/external-runtime.js +1 -0
- package/dist/src/external-work.js +583 -0
- package/dist/src/harness-backend.js +466 -0
- package/dist/src/harness-container.js +450 -0
- package/dist/src/harness-model-gateway.js +104 -0
- package/dist/src/harness-prepare.js +88 -0
- package/dist/src/harness-probe.js +111 -0
- package/dist/src/harness-protocol.js +325 -0
- package/dist/src/harness-readiness.js +50 -0
- package/dist/src/harness-rpc.js +206 -0
- package/dist/src/harness-tool-identity.js +87 -0
- package/dist/src/harness-wire-bridge.js +387 -0
- package/dist/src/host.js +6 -1
- package/dist/src/house-extension.js +147 -64
- package/dist/src/live-provider.js +301 -0
- package/dist/src/managed.js +33 -3
- package/dist/src/protocol.js +9 -5
- package/dist/src/provider-auth.js +300 -0
- package/dist/src/provider-config.js +65 -17
- package/dist/src/provider-model.js +78 -0
- package/dist/src/provider-payload.js +264 -0
- package/dist/src/provider-transport.js +36 -0
- package/dist/src/provider-usage.js +59 -0
- package/dist/src/repository-snapshots.js +219 -0
- package/dist/src/service.js +274 -9
- package/dist/src/store.js +44 -2
- package/dist/src/supervisor.js +246 -35
- package/dist/src/task-budget.js +17 -8
- package/dist/src/task-context.js +15 -0
- package/dist/src/task-driver.js +38 -14
- package/dist/src/task-policy.js +20 -1
- package/dist/src/task-verification.js +3 -0
- package/dist/src/task-work.js +31 -0
- package/dist/src/team-work.js +378 -0
- package/dist/src/tui-view.js +65 -10
- package/dist/src/tui.js +4 -2
- package/dist/src/usage.js +18 -0
- package/dist/src/worker-boundary.js +298 -0
- package/dist/src/worker-workspaces.js +274 -0
- package/dist/src/workspaces.js +6 -2
- package/harness-runtime/codex-pins.json +10 -0
- package/harness-runtime/package-lock.json +560 -0
- package/harness-runtime/package.json +9 -0
- package/npm-shrinkwrap.json +57 -2
- package/package.json +2 -1
- package/src/a2a-backend.ts +254 -0
- package/src/a2a-inbound.ts +585 -0
- package/src/a2a-peer.ts +441 -0
- package/src/a2a-protocol.ts +286 -0
- package/src/agent-profiles.ts +379 -0
- package/src/children.ts +168 -27
- package/src/claude-provider.ts +2 -189
- package/src/cli-help.ts +107 -6
- package/src/cli.ts +602 -12
- package/src/config.ts +34 -3
- package/src/credentials.ts +9 -2
- package/src/external-runtime.ts +54 -0
- package/src/external-work.ts +663 -0
- package/src/harness-backend.ts +557 -0
- package/src/harness-container.ts +543 -0
- package/src/harness-model-gateway.ts +138 -0
- package/src/harness-prepare.ts +113 -0
- package/src/harness-probe.ts +150 -0
- package/src/harness-protocol.ts +388 -0
- package/src/harness-readiness.ts +71 -0
- package/src/harness-rpc.ts +244 -0
- package/src/harness-tool-identity.ts +91 -0
- package/src/harness-wire-bridge.ts +418 -0
- package/src/host.ts +11 -6
- package/src/house-extension.ts +165 -66
- package/src/live-provider.ts +347 -0
- package/src/managed.ts +43 -4
- package/src/protocol.ts +36 -8
- package/src/provider-auth.ts +336 -0
- package/src/provider-config.ts +89 -18
- package/src/provider-model.ts +86 -0
- package/src/provider-payload.ts +263 -0
- package/src/provider-transport.ts +51 -0
- package/src/provider-usage.ts +64 -0
- package/src/repository-snapshots.ts +259 -0
- package/src/service.ts +446 -11
- package/src/store.ts +60 -4
- package/src/supervisor.ts +294 -41
- package/src/task-budget.ts +24 -11
- package/src/task-context.ts +17 -0
- package/src/task-driver.ts +51 -15
- package/src/task-policy.ts +23 -3
- package/src/task-verification.ts +2 -0
- package/src/task-work.ts +46 -1
- package/src/team-work.ts +554 -0
- package/src/tui-client.ts +5 -0
- package/src/tui-view.ts +164 -12
- package/src/tui.ts +5 -2
- package/src/usage.ts +17 -0
- package/src/worker-boundary.ts +395 -0
- package/src/worker-workspaces.ts +390 -0
- 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 === '
|
|
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:
|
|
208
|
-
|
|
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:
|
|
28
|
+
files: boolean;
|
|
28
29
|
commands: boolean;
|
|
29
30
|
profile_digest: string | null;
|
|
30
31
|
}
|
|
31
|
-
export function workerCapabilities(
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/provider-config.ts
CHANGED
|
@@ -1,16 +1,41 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|