@lumenflow/surfaces 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +40 -0
- package/cli/__tests__/gates.test.ts +97 -0
- package/cli/__tests__/inspect.test.ts +184 -0
- package/cli/__tests__/task-lifecycle.test.ts +203 -0
- package/cli/gates.ts +46 -0
- package/cli/index.ts +6 -0
- package/cli/inspect.ts +138 -0
- package/cli/task-lifecycle.ts +46 -0
- package/http/__tests__/agent-runtime-remote-controls.test.ts +249 -0
- package/http/__tests__/auth-boundary.test.ts +57 -0
- package/http/__tests__/channel-send-governance.test.ts +158 -0
- package/http/__tests__/event-stream.test.ts +340 -0
- package/http/__tests__/phone-device-tool-api.test.ts +177 -0
- package/http/__tests__/remote-exposure.test.ts +212 -0
- package/http/__tests__/run-agent.test.ts +447 -0
- package/http/__tests__/scope-enforcement.test.ts +349 -0
- package/http/__tests__/sidecar-entry.test.ts +158 -0
- package/http/__tests__/tool-api-schema-validation.test.ts +213 -0
- package/http/__tests__/tool-api.test.ts +491 -0
- package/http/__tests__/tool-discovery.test.ts +384 -0
- package/http/ag-ui-adapter.ts +352 -0
- package/http/auth.ts +294 -0
- package/http/control-plane-event-subscriber.ts +233 -0
- package/http/event-stream.ts +216 -0
- package/http/index.ts +10 -0
- package/http/run-agent.ts +416 -0
- package/http/server.ts +329 -0
- package/http/sidecar-entry.ts +218 -0
- package/http/task-api.ts +307 -0
- package/http/tool-api.ts +373 -0
- package/http/tool-discovery.ts +159 -0
- package/mcp/__tests__/server.test.ts +554 -0
- package/mcp/index.ts +4 -0
- package/mcp/server.ts +250 -0
- package/package.json +51 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hellmai Ltd
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
KERNEL_EVENT_KINDS,
|
|
6
|
+
TOOL_TRACE_KINDS,
|
|
7
|
+
type KernelEvent,
|
|
8
|
+
type PolicyDecision,
|
|
9
|
+
type TaskState,
|
|
10
|
+
type ToolTraceEntry,
|
|
11
|
+
} from '@lumenflow/kernel';
|
|
12
|
+
|
|
13
|
+
const SOURCE = {
|
|
14
|
+
KERNEL_EVENT: 'kernel_event',
|
|
15
|
+
POLICY: 'policy',
|
|
16
|
+
STATE_SYNC: 'state_sync',
|
|
17
|
+
TOOL_TRACE: 'tool_trace',
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
const JSON_POINTER_PREFIX = '/';
|
|
21
|
+
|
|
22
|
+
export const AG_UI_EVENT_TYPES = {
|
|
23
|
+
RUN_STARTED: 'RUN_STARTED',
|
|
24
|
+
STEP_STARTED: 'STEP_STARTED',
|
|
25
|
+
STEP_BLOCKED: 'STEP_BLOCKED',
|
|
26
|
+
STEP_UNBLOCKED: 'STEP_UNBLOCKED',
|
|
27
|
+
STEP_WAITING: 'STEP_WAITING',
|
|
28
|
+
STEP_RESUMED: 'STEP_RESUMED',
|
|
29
|
+
RUN_COMPLETED: 'RUN_COMPLETED',
|
|
30
|
+
RUN_RELEASED: 'RUN_RELEASED',
|
|
31
|
+
RUN_DELEGATED: 'RUN_DELEGATED',
|
|
32
|
+
STEP_PAUSED: 'STEP_PAUSED',
|
|
33
|
+
STEP_FAILED: 'STEP_FAILED',
|
|
34
|
+
STEP_SUCCEEDED: 'STEP_SUCCEEDED',
|
|
35
|
+
WORKSPACE_UPDATED: 'WORKSPACE_UPDATED',
|
|
36
|
+
WORKSPACE_WARNING: 'WORKSPACE_WARNING',
|
|
37
|
+
SPEC_TAMPERED: 'SPEC_TAMPERED',
|
|
38
|
+
CHECKPOINT: 'CHECKPOINT',
|
|
39
|
+
TOOL_CALL_START: 'TOOL_CALL_START',
|
|
40
|
+
TOOL_CALL_END: 'TOOL_CALL_END',
|
|
41
|
+
TOOL_CALL_RESULT: 'TOOL_CALL_RESULT',
|
|
42
|
+
GOVERNANCE_DECISION: 'GOVERNANCE_DECISION',
|
|
43
|
+
STATE_SNAPSHOT: 'StateSnapshot',
|
|
44
|
+
STATE_DELTA: 'StateDelta',
|
|
45
|
+
MESSAGES_SNAPSHOT: 'MessagesSnapshot',
|
|
46
|
+
} as const;
|
|
47
|
+
|
|
48
|
+
type AgUiEventType = (typeof AG_UI_EVENT_TYPES)[keyof typeof AG_UI_EVENT_TYPES];
|
|
49
|
+
|
|
50
|
+
interface EventMetadata {
|
|
51
|
+
source: string;
|
|
52
|
+
kernel_kind?: KernelEvent['kind'];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AgUiEvent {
|
|
56
|
+
type: AgUiEventType | string;
|
|
57
|
+
timestamp: string;
|
|
58
|
+
task_id?: string;
|
|
59
|
+
run_id?: string;
|
|
60
|
+
payload: Record<string, unknown>;
|
|
61
|
+
metadata: EventMetadata;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface PolicyEventContext {
|
|
65
|
+
task_id: string;
|
|
66
|
+
run_id?: string;
|
|
67
|
+
timestamp: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface StateDeltaOperation {
|
|
71
|
+
op: 'add' | 'remove' | 'replace';
|
|
72
|
+
path: string;
|
|
73
|
+
value?: unknown;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function hasTaskId(event: KernelEvent): event is Extract<KernelEvent, { task_id: string }> {
|
|
77
|
+
return 'task_id' in event;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasRunId(event: KernelEvent): event is Extract<KernelEvent, { run_id: string }> {
|
|
81
|
+
return 'run_id' in event;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const KERNEL_KIND_TO_AG_UI_TYPE: Record<KernelEvent['kind'], AgUiEventType> = {
|
|
85
|
+
[KERNEL_EVENT_KINDS.TASK_CREATED]: AG_UI_EVENT_TYPES.RUN_STARTED,
|
|
86
|
+
[KERNEL_EVENT_KINDS.TASK_CLAIMED]: AG_UI_EVENT_TYPES.STEP_STARTED,
|
|
87
|
+
[KERNEL_EVENT_KINDS.TASK_BLOCKED]: AG_UI_EVENT_TYPES.STEP_BLOCKED,
|
|
88
|
+
[KERNEL_EVENT_KINDS.TASK_UNBLOCKED]: AG_UI_EVENT_TYPES.STEP_UNBLOCKED,
|
|
89
|
+
[KERNEL_EVENT_KINDS.TASK_WAITING]: AG_UI_EVENT_TYPES.STEP_WAITING,
|
|
90
|
+
[KERNEL_EVENT_KINDS.TASK_RESUMED]: AG_UI_EVENT_TYPES.STEP_RESUMED,
|
|
91
|
+
[KERNEL_EVENT_KINDS.TASK_COMPLETED]: AG_UI_EVENT_TYPES.RUN_COMPLETED,
|
|
92
|
+
[KERNEL_EVENT_KINDS.TASK_RELEASED]: AG_UI_EVENT_TYPES.RUN_RELEASED,
|
|
93
|
+
[KERNEL_EVENT_KINDS.TASK_DELEGATED]: AG_UI_EVENT_TYPES.RUN_DELEGATED,
|
|
94
|
+
[KERNEL_EVENT_KINDS.RUN_STARTED]: AG_UI_EVENT_TYPES.STEP_STARTED,
|
|
95
|
+
[KERNEL_EVENT_KINDS.RUN_PAUSED]: AG_UI_EVENT_TYPES.STEP_PAUSED,
|
|
96
|
+
[KERNEL_EVENT_KINDS.RUN_FAILED]: AG_UI_EVENT_TYPES.STEP_FAILED,
|
|
97
|
+
[KERNEL_EVENT_KINDS.RUN_SUCCEEDED]: AG_UI_EVENT_TYPES.STEP_SUCCEEDED,
|
|
98
|
+
[KERNEL_EVENT_KINDS.WORKSPACE_UPDATED]: AG_UI_EVENT_TYPES.WORKSPACE_UPDATED,
|
|
99
|
+
[KERNEL_EVENT_KINDS.WORKSPACE_WARNING]: AG_UI_EVENT_TYPES.WORKSPACE_WARNING,
|
|
100
|
+
[KERNEL_EVENT_KINDS.SPEC_TAMPERED]: AG_UI_EVENT_TYPES.SPEC_TAMPERED,
|
|
101
|
+
[KERNEL_EVENT_KINDS.CHECKPOINT]: AG_UI_EVENT_TYPES.CHECKPOINT,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
function createEventFromKernel(
|
|
105
|
+
event: KernelEvent,
|
|
106
|
+
type: AgUiEventType,
|
|
107
|
+
payload: Record<string, unknown>,
|
|
108
|
+
): AgUiEvent {
|
|
109
|
+
const mapped: AgUiEvent = {
|
|
110
|
+
type,
|
|
111
|
+
timestamp: event.timestamp,
|
|
112
|
+
payload,
|
|
113
|
+
metadata: {
|
|
114
|
+
source: SOURCE.KERNEL_EVENT,
|
|
115
|
+
kernel_kind: event.kind,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (hasTaskId(event)) {
|
|
120
|
+
mapped.task_id = event.task_id;
|
|
121
|
+
}
|
|
122
|
+
if (hasRunId(event)) {
|
|
123
|
+
mapped.run_id = event.run_id;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return mapped;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function mapKernelEventToAgUiEvent(event: KernelEvent): AgUiEvent {
|
|
130
|
+
const type = KERNEL_KIND_TO_AG_UI_TYPE[event.kind];
|
|
131
|
+
return createEventFromKernel(event, type, {
|
|
132
|
+
event,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function mapToolTraceEntryToAgUiEvents(entry: ToolTraceEntry): AgUiEvent[] {
|
|
137
|
+
if (entry.kind === TOOL_TRACE_KINDS.TOOL_CALL_STARTED) {
|
|
138
|
+
return [
|
|
139
|
+
{
|
|
140
|
+
type: AG_UI_EVENT_TYPES.TOOL_CALL_START,
|
|
141
|
+
timestamp: entry.timestamp,
|
|
142
|
+
task_id: entry.task_id,
|
|
143
|
+
run_id: entry.run_id,
|
|
144
|
+
payload: {
|
|
145
|
+
receipt_id: entry.receipt_id,
|
|
146
|
+
tool_name: entry.tool_name,
|
|
147
|
+
execution_mode: entry.execution_mode,
|
|
148
|
+
input_ref: entry.input_ref,
|
|
149
|
+
input_hash: entry.input_hash,
|
|
150
|
+
scope_requested: entry.scope_requested,
|
|
151
|
+
scope_allowed: entry.scope_allowed,
|
|
152
|
+
scope_enforced: entry.scope_enforced,
|
|
153
|
+
},
|
|
154
|
+
metadata: {
|
|
155
|
+
source: SOURCE.TOOL_TRACE,
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (entry.kind === TOOL_TRACE_KINDS.TOOL_CALL_PROGRESS) {
|
|
162
|
+
return [
|
|
163
|
+
{
|
|
164
|
+
type: AG_UI_EVENT_TYPES.TOOL_CALL_RESULT,
|
|
165
|
+
timestamp: entry.timestamp,
|
|
166
|
+
payload: {
|
|
167
|
+
receipt_id: entry.receipt_id,
|
|
168
|
+
state: entry.state,
|
|
169
|
+
sequence: entry.sequence,
|
|
170
|
+
snapshot_hash: entry.snapshot_hash,
|
|
171
|
+
snapshot_ref: entry.snapshot_ref,
|
|
172
|
+
streaming: true,
|
|
173
|
+
},
|
|
174
|
+
metadata: {
|
|
175
|
+
source: SOURCE.TOOL_TRACE,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const common: AgUiEvent = {
|
|
182
|
+
type: AG_UI_EVENT_TYPES.TOOL_CALL_END,
|
|
183
|
+
timestamp: entry.timestamp,
|
|
184
|
+
payload: {
|
|
185
|
+
receipt_id: entry.receipt_id,
|
|
186
|
+
result: entry.result,
|
|
187
|
+
duration_ms: entry.duration_ms,
|
|
188
|
+
policy_decisions: entry.policy_decisions,
|
|
189
|
+
artifacts_written: entry.artifacts_written,
|
|
190
|
+
},
|
|
191
|
+
metadata: {
|
|
192
|
+
source: SOURCE.TOOL_TRACE,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const resultEvent: AgUiEvent = {
|
|
197
|
+
type: AG_UI_EVENT_TYPES.TOOL_CALL_RESULT,
|
|
198
|
+
timestamp: entry.timestamp,
|
|
199
|
+
payload: {
|
|
200
|
+
receipt_id: entry.receipt_id,
|
|
201
|
+
output_hash: entry.output_hash,
|
|
202
|
+
output_ref: entry.output_ref,
|
|
203
|
+
redaction_summary: entry.redaction_summary,
|
|
204
|
+
scope_enforcement_note: entry.scope_enforcement_note,
|
|
205
|
+
result: entry.result,
|
|
206
|
+
},
|
|
207
|
+
metadata: {
|
|
208
|
+
source: SOURCE.TOOL_TRACE,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return [common, resultEvent];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function mapPolicyDecisionToAgUiEvent(
|
|
216
|
+
decision: PolicyDecision,
|
|
217
|
+
context: PolicyEventContext,
|
|
218
|
+
): AgUiEvent {
|
|
219
|
+
return {
|
|
220
|
+
type: AG_UI_EVENT_TYPES.GOVERNANCE_DECISION,
|
|
221
|
+
timestamp: context.timestamp,
|
|
222
|
+
task_id: context.task_id,
|
|
223
|
+
run_id: context.run_id,
|
|
224
|
+
payload: {
|
|
225
|
+
policy_id: decision.policy_id,
|
|
226
|
+
decision: decision.decision,
|
|
227
|
+
reason: decision.reason,
|
|
228
|
+
governance: true,
|
|
229
|
+
},
|
|
230
|
+
metadata: {
|
|
231
|
+
source: SOURCE.POLICY,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function toStateDeltaOperations(previous: TaskState, next: TaskState): StateDeltaOperation[] {
|
|
237
|
+
const previousRecord = previous as Record<string, unknown>;
|
|
238
|
+
const nextRecord = next as Record<string, unknown>;
|
|
239
|
+
const keys = new Set([...Object.keys(previousRecord), ...Object.keys(nextRecord)]);
|
|
240
|
+
const operations: StateDeltaOperation[] = [];
|
|
241
|
+
|
|
242
|
+
for (const key of keys) {
|
|
243
|
+
const previousValue = previousRecord[key];
|
|
244
|
+
const nextValue = nextRecord[key];
|
|
245
|
+
|
|
246
|
+
const unchanged = JSON.stringify(previousValue) === JSON.stringify(nextValue);
|
|
247
|
+
if (unchanged) {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (previousValue === undefined) {
|
|
252
|
+
operations.push({
|
|
253
|
+
op: 'add',
|
|
254
|
+
path: `${JSON_POINTER_PREFIX}${key}`,
|
|
255
|
+
value: nextValue,
|
|
256
|
+
});
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (nextValue === undefined) {
|
|
261
|
+
operations.push({
|
|
262
|
+
op: 'remove',
|
|
263
|
+
path: `${JSON_POINTER_PREFIX}${key}`,
|
|
264
|
+
});
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
operations.push({
|
|
269
|
+
op: 'replace',
|
|
270
|
+
path: `${JSON_POINTER_PREFIX}${key}`,
|
|
271
|
+
value: nextValue,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return operations;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function createStateSyncEvents(
|
|
279
|
+
previousState: TaskState | undefined,
|
|
280
|
+
nextState: TaskState,
|
|
281
|
+
timestamp: string,
|
|
282
|
+
): AgUiEvent[] {
|
|
283
|
+
const snapshotEvent: AgUiEvent = {
|
|
284
|
+
type: AG_UI_EVENT_TYPES.STATE_SNAPSHOT,
|
|
285
|
+
timestamp,
|
|
286
|
+
task_id: nextState.task_id,
|
|
287
|
+
payload: {
|
|
288
|
+
state: nextState,
|
|
289
|
+
},
|
|
290
|
+
metadata: {
|
|
291
|
+
source: SOURCE.STATE_SYNC,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
if (!previousState) {
|
|
296
|
+
return [snapshotEvent];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const operations = toStateDeltaOperations(previousState, nextState);
|
|
300
|
+
if (operations.length === 0) {
|
|
301
|
+
return [snapshotEvent];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const deltaEvent: AgUiEvent = {
|
|
305
|
+
type: AG_UI_EVENT_TYPES.STATE_DELTA,
|
|
306
|
+
timestamp,
|
|
307
|
+
task_id: nextState.task_id,
|
|
308
|
+
payload: {
|
|
309
|
+
patch: operations,
|
|
310
|
+
from_status: previousState.status,
|
|
311
|
+
to_status: nextState.status,
|
|
312
|
+
},
|
|
313
|
+
metadata: {
|
|
314
|
+
source: SOURCE.STATE_SYNC,
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
return [snapshotEvent, deltaEvent];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function isEventForTask(event: KernelEvent, taskId: string): boolean {
|
|
322
|
+
if (!hasTaskId(event)) {
|
|
323
|
+
// Global events (workspace_updated, workspace_warning, spec_tampered) are
|
|
324
|
+
// included because they affect all tasks.
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
return event.task_id === taskId;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function createMessagesSnapshot(
|
|
331
|
+
taskId: string,
|
|
332
|
+
kernelEvents: KernelEvent[],
|
|
333
|
+
timestamp: string,
|
|
334
|
+
): AgUiEvent {
|
|
335
|
+
const relevantEvents = kernelEvents.filter((event) => isEventForTask(event, taskId));
|
|
336
|
+
|
|
337
|
+
const sorted = [...relevantEvents].sort((left, right) => {
|
|
338
|
+
return left.timestamp < right.timestamp ? -1 : left.timestamp > right.timestamp ? 1 : 0;
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
type: AG_UI_EVENT_TYPES.MESSAGES_SNAPSHOT,
|
|
343
|
+
timestamp,
|
|
344
|
+
task_id: taskId,
|
|
345
|
+
payload: {
|
|
346
|
+
messages: sorted,
|
|
347
|
+
},
|
|
348
|
+
metadata: {
|
|
349
|
+
source: SOURCE.STATE_SYNC,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
package/http/auth.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Copyright (c) 2026 Hellmai Ltd
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
import type { IncomingHttpHeaders } from 'node:http';
|
|
5
|
+
import {
|
|
6
|
+
issueControlPlaneIdentity,
|
|
7
|
+
parseControlPlaneToken,
|
|
8
|
+
type ControlPlaneAuthProfileConfig,
|
|
9
|
+
type ScopedControlPlaneIdentity,
|
|
10
|
+
} from '../../control-plane-sdk/src/authenticate.js';
|
|
11
|
+
import {
|
|
12
|
+
createToolScopeForToolName,
|
|
13
|
+
isToolScopeMatch,
|
|
14
|
+
} from '../../control-plane-sdk/src/scope-grammar.js';
|
|
15
|
+
import { loadWorkspaceSoftwareDeliveryConfig } from '../../host/src/workspace-config/index.js';
|
|
16
|
+
|
|
17
|
+
const HEADER = {
|
|
18
|
+
AUTHORIZATION: 'authorization',
|
|
19
|
+
} as const;
|
|
20
|
+
const BEARER_PREFIX = 'Bearer ';
|
|
21
|
+
const DEFAULT_PROFILE = 'privileged';
|
|
22
|
+
const DEFAULT_ALLOW_LEGACY_UNSCOPED_TOKENS = true;
|
|
23
|
+
|
|
24
|
+
const DEFAULT_PROFILES = {
|
|
25
|
+
mobile: {
|
|
26
|
+
ttl_seconds: 60 * 60 * 24,
|
|
27
|
+
scopes: ['tool:orchestrate:initiative', 'tool:delegation:list'],
|
|
28
|
+
},
|
|
29
|
+
privileged: {
|
|
30
|
+
ttl_seconds: 60 * 60 * 24 * 7,
|
|
31
|
+
scopes: ['tool:*'],
|
|
32
|
+
},
|
|
33
|
+
/**
|
|
34
|
+
* Phone-device profile (WU-2731, ADR-013 §5). Issued to phone endpoints
|
|
35
|
+
* that POST /tools/:name or /sidekick/channel. Scopes deliberately narrow:
|
|
36
|
+
* phone devices invoke tool dispatch through the mobile allowlist; they do
|
|
37
|
+
* NOT hold workspace-wide `tool:*` authority.
|
|
38
|
+
*/
|
|
39
|
+
phone: {
|
|
40
|
+
ttl_seconds: 60 * 60 * 24 * 30,
|
|
41
|
+
scopes: ['tool:orchestrate:initiative', 'tool:delegation:list', 'tool:sidekick:channel:send'],
|
|
42
|
+
},
|
|
43
|
+
} satisfies Record<string, ControlPlaneAuthProfileConfig>;
|
|
44
|
+
|
|
45
|
+
export interface WorkspaceSurfaceAuthConfig {
|
|
46
|
+
allow_legacy_unscoped_tokens: boolean;
|
|
47
|
+
profiles: Record<string, ControlPlaneAuthProfileConfig>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* WU-2779 (P0 security): Discriminator for authorization failures. Callers
|
|
52
|
+
* use this to map deny outcomes to the correct HTTP status:
|
|
53
|
+
* - `missing_authorization` → 401 (no Authorization header at all)
|
|
54
|
+
* - `missing_scope` → 403 (token present but lacks the scope)
|
|
55
|
+
* `allowed` results do not carry a reason.
|
|
56
|
+
*/
|
|
57
|
+
export type ToolAuthorizationDenyReason = 'missing_authorization' | 'missing_scope';
|
|
58
|
+
|
|
59
|
+
export interface ToolAuthorizationResult {
|
|
60
|
+
allowed: boolean;
|
|
61
|
+
required: string[];
|
|
62
|
+
granted: string[];
|
|
63
|
+
identity?: ScopedControlPlaneIdentity;
|
|
64
|
+
reason?: ToolAuthorizationDenyReason;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface WorkspaceSurfaceAuthConfigRecord {
|
|
68
|
+
allow_legacy_unscoped_tokens?: unknown;
|
|
69
|
+
profiles?: Record<string, unknown>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function asBoolean(value: unknown): boolean | undefined {
|
|
73
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function asPositiveInteger(value: unknown): number | undefined {
|
|
77
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function asScopeList(value: unknown): string[] | undefined {
|
|
81
|
+
if (!Array.isArray(value)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const scopes = value.filter((scope): scope is string => typeof scope === 'string');
|
|
86
|
+
return scopes.length === value.length ? scopes : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
90
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return value as Record<string, unknown>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeProfileConfig(
|
|
98
|
+
value: unknown,
|
|
99
|
+
fallback: ControlPlaneAuthProfileConfig,
|
|
100
|
+
): ControlPlaneAuthProfileConfig {
|
|
101
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
102
|
+
return fallback;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const record = value as Record<string, unknown>;
|
|
106
|
+
return {
|
|
107
|
+
scopes: asScopeList(record.scopes) ?? fallback.scopes,
|
|
108
|
+
ttl_seconds: asPositiveInteger(record.ttl_seconds) ?? fallback.ttl_seconds,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readWorkspaceAuthConfigRecord(
|
|
113
|
+
workspaceRoot: string,
|
|
114
|
+
): WorkspaceSurfaceAuthConfigRecord | undefined {
|
|
115
|
+
const softwareDeliveryConfig = loadWorkspaceSoftwareDeliveryConfig(workspaceRoot);
|
|
116
|
+
const surfacesConfig = asRecord(softwareDeliveryConfig?.surfaces);
|
|
117
|
+
const authConfig = asRecord(surfacesConfig?.auth);
|
|
118
|
+
|
|
119
|
+
if (!authConfig) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
allow_legacy_unscoped_tokens: authConfig.allow_legacy_unscoped_tokens,
|
|
125
|
+
profiles: asRecord(authConfig.profiles),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function readWorkspaceSurfaceAuthConfig(
|
|
130
|
+
workspaceRoot: string = process.cwd(),
|
|
131
|
+
): WorkspaceSurfaceAuthConfig {
|
|
132
|
+
const configuredAuth = readWorkspaceAuthConfigRecord(workspaceRoot);
|
|
133
|
+
const configuredProfiles = configuredAuth?.profiles ?? {};
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
allow_legacy_unscoped_tokens:
|
|
137
|
+
asBoolean(configuredAuth?.allow_legacy_unscoped_tokens) ??
|
|
138
|
+
DEFAULT_ALLOW_LEGACY_UNSCOPED_TOKENS,
|
|
139
|
+
profiles: {
|
|
140
|
+
mobile: normalizeProfileConfig(configuredProfiles.mobile, DEFAULT_PROFILES.mobile),
|
|
141
|
+
privileged: normalizeProfileConfig(
|
|
142
|
+
configuredProfiles.privileged,
|
|
143
|
+
DEFAULT_PROFILES.privileged,
|
|
144
|
+
),
|
|
145
|
+
phone: normalizeProfileConfig(configuredProfiles.phone, DEFAULT_PROFILES.phone),
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function readBearerToken(headers: IncomingHttpHeaders): string | undefined {
|
|
151
|
+
const rawHeader = headers[HEADER.AUTHORIZATION];
|
|
152
|
+
const value = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
|
|
153
|
+
|
|
154
|
+
if (typeof value !== 'string' || !value.startsWith(BEARER_PREFIX)) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const token = value.slice(BEARER_PREFIX.length).trim();
|
|
159
|
+
return token.length > 0 ? token : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function toIdentityFromToken(token: string): ScopedControlPlaneIdentity | undefined {
|
|
163
|
+
const payload = parseControlPlaneToken(token);
|
|
164
|
+
if (!payload) {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
workspace_id: payload.workspace_id,
|
|
170
|
+
org_id: payload.org_id,
|
|
171
|
+
agent_id: payload.agent_id,
|
|
172
|
+
token,
|
|
173
|
+
scopes: payload.scopes,
|
|
174
|
+
expires_at: payload.expires_at,
|
|
175
|
+
...(payload.profile ? { profile: payload.profile } : {}),
|
|
176
|
+
...(payload.subject ? { subject: payload.subject } : {}),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function issueWorkspaceProfileIdentity(
|
|
181
|
+
input: {
|
|
182
|
+
workspace_id: string;
|
|
183
|
+
org_id: string;
|
|
184
|
+
agent_id: string;
|
|
185
|
+
},
|
|
186
|
+
options: {
|
|
187
|
+
profile?: string;
|
|
188
|
+
now?: Date | string;
|
|
189
|
+
workspaceRoot?: string;
|
|
190
|
+
} = {},
|
|
191
|
+
): ScopedControlPlaneIdentity {
|
|
192
|
+
const config = readWorkspaceSurfaceAuthConfig(options.workspaceRoot);
|
|
193
|
+
|
|
194
|
+
return issueControlPlaneIdentity(input, {
|
|
195
|
+
profile: options.profile ?? DEFAULT_PROFILE,
|
|
196
|
+
profiles: config.profiles,
|
|
197
|
+
now: options.now,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function createMissingScopesPayload(
|
|
202
|
+
result: ToolAuthorizationResult,
|
|
203
|
+
toolName: string,
|
|
204
|
+
): {
|
|
205
|
+
error: 'missing_scopes';
|
|
206
|
+
required: string[];
|
|
207
|
+
granted: string[];
|
|
208
|
+
requested: string;
|
|
209
|
+
held: string[];
|
|
210
|
+
hint: string;
|
|
211
|
+
} {
|
|
212
|
+
const requestedScope = result.required[0] ?? createToolScopeForToolName(toolName);
|
|
213
|
+
return {
|
|
214
|
+
error: 'missing_scopes',
|
|
215
|
+
required: result.required,
|
|
216
|
+
granted: result.granted,
|
|
217
|
+
requested: requestedScope,
|
|
218
|
+
held: result.granted,
|
|
219
|
+
hint: `Token lacks scope for ${requestedScope}. Re-authenticate with an allowlist that includes it.`,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* WU-2731 (ADR-013 §5 Identity): Resolve the authoritative `from` claim for an
|
|
225
|
+
* inbound HTTP request from the bearer token. Only tokens that carry an
|
|
226
|
+
* explicit `subject` claim participate — phone-device enrollments set
|
|
227
|
+
* `subject = {workspace_id}:phone:{device_id}`, so those requests get a
|
|
228
|
+
* per-device authoritative `from`. Workspace-scoped tokens (no subject) keep
|
|
229
|
+
* their existing `workspace_id`-derived identity at the runtime layer; we do
|
|
230
|
+
* not synthesise a `from` for them because cloud's audit layer already
|
|
231
|
+
* derives workspace attribution from the session record.
|
|
232
|
+
*
|
|
233
|
+
* Returns `undefined` when the request carries no recognisable scoped token
|
|
234
|
+
* OR when the token has no subject claim.
|
|
235
|
+
*/
|
|
236
|
+
export function resolveAuthoritativeFrom(
|
|
237
|
+
headers: IncomingHttpHeaders,
|
|
238
|
+
): { from: string; identity: ScopedControlPlaneIdentity } | undefined {
|
|
239
|
+
const token = readBearerToken(headers);
|
|
240
|
+
if (!token) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const identity = toIdentityFromToken(token);
|
|
245
|
+
if (!identity?.subject) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { from: identity.subject, identity };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function authorizeToolRequest(
|
|
253
|
+
headers: IncomingHttpHeaders,
|
|
254
|
+
toolName: string,
|
|
255
|
+
workspaceRoot: string = process.cwd(),
|
|
256
|
+
): ToolAuthorizationResult {
|
|
257
|
+
const required = [createToolScopeForToolName(toolName)];
|
|
258
|
+
const config = readWorkspaceSurfaceAuthConfig(workspaceRoot);
|
|
259
|
+
const token = readBearerToken(headers);
|
|
260
|
+
|
|
261
|
+
// WU-2779 (P0 security): Reject requests with no bearer credential. Prior
|
|
262
|
+
// behaviour returned `allowed: true` for missing tokens, letting unauth
|
|
263
|
+
// clients dispatch POST /tools/:name. There is no public-allowlist at this
|
|
264
|
+
// layer — /tools/:name is always privileged — so default deny.
|
|
265
|
+
if (!token) {
|
|
266
|
+
return {
|
|
267
|
+
allowed: false,
|
|
268
|
+
required,
|
|
269
|
+
granted: [],
|
|
270
|
+
reason: 'missing_authorization',
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const identity = toIdentityFromToken(token);
|
|
275
|
+
if (!identity) {
|
|
276
|
+
const allowed = config.allow_legacy_unscoped_tokens;
|
|
277
|
+
return {
|
|
278
|
+
allowed,
|
|
279
|
+
required,
|
|
280
|
+
granted: [],
|
|
281
|
+
...(allowed ? {} : { reason: 'missing_scope' }),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const granted = identity.scopes;
|
|
286
|
+
const allowed = granted.some((scope) => isToolScopeMatch(scope, toolName));
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
allowed,
|
|
290
|
+
required,
|
|
291
|
+
granted,
|
|
292
|
+
...(allowed ? { identity } : { reason: 'missing_scope' }),
|
|
293
|
+
};
|
|
294
|
+
}
|