@abot-ai/socket 0.1.0 → 0.2.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/CHANGELOG.md +38 -0
- package/README.md +27 -14
- package/docs/INSTALLATION.md +13 -2
- package/docs/MIGRATION.md +38 -2
- package/docs/PROTOCOL.md +111 -18
- package/docs/SECURITY.md +18 -0
- package/docs/SELF-HOSTING.md +9 -2
- package/package.json +2 -1
- package/src/auth/auth-database.mjs +6 -3
- package/src/cli/client-capabilities.mjs +12 -2
- package/src/protocol/approval-protocol.mjs +39 -0
- package/src/protocol/protocol.mjs +11 -1
- package/src/protocol/spark-chat-protocol.mjs +51 -0
- package/src/relay/relay-approvals.mjs +86 -0
- package/src/relay/relay-correlations.mjs +8 -3
- package/src/relay/relay-request-adoption.mjs +97 -0
- package/src/relay/relay-requests.mjs +69 -13
- package/src/relay/relay-session-mutations.mjs +39 -5
- package/src/relay/relay.mjs +25 -5
- package/src/relay/request-ownership-schema.mjs +18 -0
- package/src/relay/request-ownership-store.mjs +115 -0
- package/src/transport/http-api.mjs +3 -2
- package/src/transport/server.mjs +13 -3
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { expectedResponseType } from '../protocol/protocol.mjs';
|
|
2
|
+
import { isApprovalCommand } from '../protocol/approval-protocol.mjs';
|
|
3
|
+
import { isRecord, isRelayId, relayError } from './relay-runtime-registration.mjs';
|
|
4
|
+
import { RelayRequestAdoption } from './relay-request-adoption.mjs';
|
|
5
|
+
|
|
6
|
+
function validWait(wait) {
|
|
7
|
+
return isRecord(wait) && isRelayId(wait.generation) && isRelayId(wait.waitId)
|
|
8
|
+
&& Number.isSafeInteger(wait.revision) && wait.revision >= 0;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function validResponse(command, body) {
|
|
12
|
+
if (!body.ok) return typeof body.error === 'string' && body.error.length <= 256;
|
|
13
|
+
if (command.type === 'request.approval.decide.request') {
|
|
14
|
+
return isRecord(body.result) && typeof body.result.accepted === 'boolean'
|
|
15
|
+
&& (body.result.duplicate === undefined || typeof body.result.duplicate === 'boolean')
|
|
16
|
+
&& (body.result.reason === undefined || typeof body.result.reason === 'string');
|
|
17
|
+
}
|
|
18
|
+
if (!Array.isArray(body.approvals) || body.approvals.length > 64) return false;
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
for (const approval of body.approvals) {
|
|
21
|
+
const request = approval?.request;
|
|
22
|
+
if (!isRecord(approval) || !isRecord(request) || request.requestId !== command.requestId) return false;
|
|
23
|
+
if (typeof request.approvalId !== 'string' || !request.approvalId || request.approvalId.length > 512) return false;
|
|
24
|
+
if (seen.has(request.approvalId)) return false;
|
|
25
|
+
seen.add(request.approvalId);
|
|
26
|
+
if (approval.wait !== undefined && !validWait(approval.wait)) return false;
|
|
27
|
+
}
|
|
28
|
+
if (body.lifecycle !== null && !isRecord(body.lifecycle)) return false;
|
|
29
|
+
if (body.lifecycle?.sessionId !== undefined && body.lifecycle.sessionId !== command.sessionId) return false;
|
|
30
|
+
if (body.lifecycle?.requestId !== undefined && body.lifecycle.requestId !== command.requestId) return false;
|
|
31
|
+
if (body.finalState === null) return true;
|
|
32
|
+
return isRecord(body.finalState) && ['completed', 'failed'].includes(body.finalState.status)
|
|
33
|
+
&& body.approvals.length === 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** An owner-scoped transport view. The runtime alone stores and decides approvals. */
|
|
37
|
+
export class RelayApprovals {
|
|
38
|
+
constructor({ ownership, requests, correlations, transmit, respond, error,
|
|
39
|
+
connected, currentRuntime, assertSessionAvailable }) {
|
|
40
|
+
Object.assign(this, { ownership, requests, correlations, transmit, respond, error });
|
|
41
|
+
this.adoption = new RelayRequestAdoption({ ownership, requests, correlations, transmit, error,
|
|
42
|
+
connected, currentRuntime, assertSessionAvailable,
|
|
43
|
+
recover: (client, runtime, id, command) => this.dispatch(client, runtime, id, command) });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
owned(client, runtime, command) {
|
|
47
|
+
if (!this.ownership) throw relayError('approval_access_unavailable');
|
|
48
|
+
if (!runtime.features.includes('request_approvals_v1')) throw relayError('approvals_unsupported');
|
|
49
|
+
const owner = this.ownership.get(runtime.runtimeId, command.requestId);
|
|
50
|
+
if (!owner || owner.subject !== client.subject || owner.sessionId !== command.sessionId) {
|
|
51
|
+
throw relayError('request_not_owned');
|
|
52
|
+
}
|
|
53
|
+
return owner;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
dispatch(client, runtime, id, command) {
|
|
57
|
+
if (this.adoption.beginIfUnowned(client, runtime, id, command)) return;
|
|
58
|
+
this.adoption.assertNoPendingRequest(runtime, command.requestId);
|
|
59
|
+
const owner = this.owned(client, runtime, command);
|
|
60
|
+
for (const entry of this.correlations.pending.values()) {
|
|
61
|
+
if (entry.runtime === runtime && entry.command.requestId === command.requestId
|
|
62
|
+
&& isApprovalCommand(entry.command.type)) throw relayError('approval_request_pending');
|
|
63
|
+
}
|
|
64
|
+
let view;
|
|
65
|
+
const entry = this.correlations.create(client, runtime, id, command, expectedResponseType(command.type), {
|
|
66
|
+
matches: body => validResponse(command, body),
|
|
67
|
+
respond: (_entry, body) => {
|
|
68
|
+
if (!body.ok || body.result?.accepted === false) view.rollback();
|
|
69
|
+
else if (body.finalState) this.requests.markTerminal(view.record);
|
|
70
|
+
this.respond(client, runtime, id, body);
|
|
71
|
+
},
|
|
72
|
+
unknown: () => {
|
|
73
|
+
view.rollback();
|
|
74
|
+
this.error(client, runtime, id, 'outcome_unknown');
|
|
75
|
+
},
|
|
76
|
+
// A sent decision can still take effect after its client closes. Keep the
|
|
77
|
+
// mutation guard until its reply/deadline, but disconnect removes the view.
|
|
78
|
+
retainOnClientDisconnect: true,
|
|
79
|
+
});
|
|
80
|
+
try { view = this.requests.restore(client, runtime, owner); }
|
|
81
|
+
catch (error) { this.correlations.remove(entry); throw error; }
|
|
82
|
+
if (!this.transmit(runtime.peer, { ...command, correlationId: entry.correlationId })) {
|
|
83
|
+
this.correlations.fail(entry);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { isRecord, relayError } from './relay-runtime-registration.mjs';
|
|
3
|
+
import { isApprovalCommand } from '../protocol/approval-protocol.mjs';
|
|
4
|
+
import { SPARK_OPEN_REQUEST, projectSparkOpenResponse } from '../protocol/spark-chat-protocol.mjs';
|
|
3
5
|
|
|
4
6
|
const MAX_PENDING = 128;
|
|
5
7
|
const RPC_TIMEOUT_MS = 30_000;
|
|
@@ -10,12 +12,14 @@ function steeringKey(runtime, body) {
|
|
|
10
12
|
|
|
11
13
|
function matchesResponseIdentity(entry, body) {
|
|
12
14
|
const command = entry.command;
|
|
15
|
+
if (command.type === SPARK_OPEN_REQUEST) return projectSparkOpenResponse(body) !== null;
|
|
13
16
|
if (command.type === 'steer_request') {
|
|
14
17
|
return body.requestId === command.requestId && body.steerId === command.steerId
|
|
15
18
|
&& typeof body.accepted === 'boolean';
|
|
16
19
|
}
|
|
17
20
|
if (typeof body.ok !== 'boolean') return false;
|
|
18
|
-
if (command.type === 'request.events.request'
|
|
21
|
+
if ((command.type === 'request.events.request' || isApprovalCommand(command.type))
|
|
22
|
+
&& body.requestId !== command.requestId) return false;
|
|
19
23
|
// Upload responses intentionally do not echo requestId in the existing runtime.
|
|
20
24
|
if (body.requestId !== undefined && body.requestId !== command.requestId) return false;
|
|
21
25
|
if (command.sessionId !== undefined && body.sessionId !== command.sessionId) return false;
|
|
@@ -28,7 +32,7 @@ function matchesResponseIdentity(entry, body) {
|
|
|
28
32
|
if (!Array.isArray(body.events) || body.events.length > 8192) return false;
|
|
29
33
|
if (body.events.some(event => !isRecord(event) || (event.requestId !== undefined && event.requestId !== command.requestId))) return false;
|
|
30
34
|
}
|
|
31
|
-
return true;
|
|
35
|
+
return entry.lifecycle.matches?.(body) ?? true;
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
export class RelayCorrelations {
|
|
@@ -69,7 +73,8 @@ export class RelayCorrelations {
|
|
|
69
73
|
if (entry.expiresAt <= this.now() || !matchesResponseIdentity(entry, body)) return false;
|
|
70
74
|
// An invalid reply must never consume the genuine request's pending record.
|
|
71
75
|
this.remove(entry);
|
|
72
|
-
|
|
76
|
+
const response = entry.command.type === SPARK_OPEN_REQUEST ? projectSparkOpenResponse(body) : body;
|
|
77
|
+
(entry.lifecycle.respond ?? this.respond)(entry, response);
|
|
73
78
|
return true;
|
|
74
79
|
}
|
|
75
80
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { isRecord, relayError } from './relay-runtime-registration.mjs';
|
|
2
|
+
|
|
3
|
+
const REQUEST_STATUSES = new Set(['streaming', 'awaiting_approval', 'completed', 'failed']);
|
|
4
|
+
const requestKey = (runtime, requestId) => JSON.stringify([runtime.generation, requestId]);
|
|
5
|
+
const isTerminalRequest = request => ['completed', 'failed'].includes(request.status);
|
|
6
|
+
|
|
7
|
+
function confirmedSnapshotRequest(command, body) {
|
|
8
|
+
if (!body.ok) throw relayError('request_scope_unverified');
|
|
9
|
+
if (!Array.isArray(body.requests) || body.nextCursor !== null) throw relayError('request_scope_unverified');
|
|
10
|
+
const matches = body.requests.filter(request => isRecord(request) && request.requestId === command.requestId);
|
|
11
|
+
if (matches.length !== 1) throw relayError('request_scope_unverified');
|
|
12
|
+
const request = matches[0];
|
|
13
|
+
if (request.sessionId !== command.sessionId) throw relayError('request_scope_unverified');
|
|
14
|
+
if (!REQUEST_STATUSES.has(request.status)) throw relayError('request_scope_unverified');
|
|
15
|
+
return request;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** First contact with existing computer work needs canonical proof, never a client ownership claim. */
|
|
19
|
+
export class RelayRequestAdoption {
|
|
20
|
+
constructor({ ownership, requests, correlations, transmit, connected, currentRuntime,
|
|
21
|
+
assertSessionAvailable, recover, error }) {
|
|
22
|
+
Object.assign(this, { ownership, requests, correlations, transmit, connected, currentRuntime,
|
|
23
|
+
assertSessionAvailable, recover, error });
|
|
24
|
+
this.pending = new Map();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
assertNoPendingRequest(runtime, requestId) {
|
|
28
|
+
if (this.pending.has(requestKey(runtime, requestId))) throw relayError('approval_request_pending');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
assertNoPendingSession(runtime, sessionId) {
|
|
32
|
+
for (const operation of this.pending.values()) {
|
|
33
|
+
if (operation.runtime === runtime && operation.command.sessionId === sessionId) {
|
|
34
|
+
throw relayError('session_busy');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
assertCurrentPrincipal(client, runtime) {
|
|
40
|
+
if (!this.connected(client)) throw relayError('approval_access_unavailable');
|
|
41
|
+
if (!this.currentRuntime(runtime)) throw relayError('runtime_offline');
|
|
42
|
+
if (!client.allowedRuntimeIds.has(runtime.runtimeId)) throw relayError('runtime_forbidden');
|
|
43
|
+
if (this.ownership?.isOwnerSubject?.(client.subject) !== true) throw relayError('request_not_owned');
|
|
44
|
+
if (typeof this.ownership.adopt !== 'function') throw relayError('approval_access_unavailable');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
assertCompatibleBinding(client, runtime, command) {
|
|
48
|
+
const binding = this.requests.bindings.get(JSON.stringify([runtime.runtimeId, command.requestId]));
|
|
49
|
+
if (!binding) return;
|
|
50
|
+
if (!binding.durable || binding.subject !== client.subject || binding.sessionId !== command.sessionId) {
|
|
51
|
+
throw relayError('request_not_owned');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beginIfUnowned(client, runtime, id, command) {
|
|
56
|
+
if (command.type !== 'request.approvals.request') return false;
|
|
57
|
+
if (!this.ownership || !runtime.features.includes('request_approvals_v1')) return false;
|
|
58
|
+
if (this.ownership.get(runtime.runtimeId, command.requestId)) return false;
|
|
59
|
+
this.assertCurrentPrincipal(client, runtime);
|
|
60
|
+
this.assertNoPendingRequest(runtime, command.requestId);
|
|
61
|
+
this.assertCompatibleBinding(client, runtime, command);
|
|
62
|
+
this.assertSessionAvailable(runtime, command.sessionId);
|
|
63
|
+
const operation = { client, runtime, id, command, key: requestKey(runtime, command.requestId) };
|
|
64
|
+
const snapshot = { type: 'session.snapshot.request', sessionId: command.sessionId, includeRequests: true };
|
|
65
|
+
const entry = this.correlations.create(client, runtime, id, snapshot, 'session.snapshot.response', {
|
|
66
|
+
respond: (_entry, body) => this.afterSnapshot(operation, body),
|
|
67
|
+
unknown: () => this.error(client, runtime, id, 'outcome_unknown'),
|
|
68
|
+
removed: () => {
|
|
69
|
+
if (this.pending.get(operation.key) === operation) this.pending.delete(operation.key);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
this.pending.set(operation.key, operation);
|
|
73
|
+
if (!this.transmit(runtime.peer, { ...snapshot, correlationId: entry.correlationId })) {
|
|
74
|
+
this.correlations.fail(entry);
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
afterSnapshot(operation, body) {
|
|
80
|
+
const { client, runtime, id, command } = operation;
|
|
81
|
+
try {
|
|
82
|
+
this.assertCurrentPrincipal(client, runtime);
|
|
83
|
+
this.assertSessionAvailable(runtime, command.sessionId);
|
|
84
|
+
this.assertCompatibleBinding(client, runtime, command);
|
|
85
|
+
const canonical = confirmedSnapshotRequest(command, body);
|
|
86
|
+
const terminal = isTerminalRequest(canonical);
|
|
87
|
+
const binding = this.requests.bindings.get(JSON.stringify([runtime.runtimeId, command.requestId]));
|
|
88
|
+
this.requests.assertCapacity(binding, !terminal);
|
|
89
|
+
this.ownership.adopt({ subject: client.subject, deviceId: client.sessionId,
|
|
90
|
+
runtimeId: runtime.runtimeId, sessionId: command.sessionId, requestId: command.requestId }, terminal);
|
|
91
|
+
// Normal recovery binds before receiving approval events and retains the runtime's decision checks.
|
|
92
|
+
this.recover(client, runtime, id, command);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
this.error(client, runtime, id, error.code ?? 'approval_access_unavailable');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -15,27 +15,83 @@ function canConsumeObservedApproval(approval, approved, now) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export class RelayRequests {
|
|
18
|
-
constructor({ now, event, unknown, observe = () => {} }) {
|
|
18
|
+
constructor({ now, event, unknown, ownership, observe = () => {} }) {
|
|
19
19
|
this.now = now;
|
|
20
20
|
this.event = event;
|
|
21
21
|
this.unknown = unknown;
|
|
22
22
|
this.observe = observe;
|
|
23
|
+
this.ownership = ownership;
|
|
23
24
|
this.bindings = new Map();
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
create(client, runtime, command) {
|
|
27
28
|
const key = keyFor(runtime.runtimeId, command.requestId);
|
|
28
|
-
if (this.bindings.has(key)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
if (this.bindings.has(key) || this.ownership?.get(runtime.runtimeId, command.requestId)) {
|
|
30
|
+
throw relayError('request_id_conflict');
|
|
31
|
+
}
|
|
32
|
+
this.assertCapacity();
|
|
33
|
+
const durable = command.approvalMode === 'durable_v1';
|
|
34
|
+
if (durable) {
|
|
35
|
+
if (!this.ownership) throw relayError('approval_access_unavailable');
|
|
36
|
+
if (!runtime.features.includes('request_approvals_v1')) throw relayError('approvals_unsupported');
|
|
37
|
+
// This commit precedes transport: an uncertain send must never permit a replayed run.
|
|
38
|
+
this.ownership.create({ subject: client.subject, deviceId: client.sessionId,
|
|
39
|
+
runtimeId: runtime.runtimeId, sessionId: command.sessionId, requestId: command.requestId });
|
|
40
|
+
}
|
|
32
41
|
const record = { key, subject: client.subject, deviceId: client.sessionId, runtime, requestId: command.requestId,
|
|
33
|
-
sessionId: command.sessionId, state: 'active', subscribers: new Set([client]),
|
|
34
|
-
expiresAt: this.now() + BINDING_TTL_MS,
|
|
42
|
+
sessionId: command.sessionId, state: 'active', subscribers: new Set([client]), durable,
|
|
43
|
+
expiresAt: durable ? Infinity : this.now() + BINDING_TTL_MS,
|
|
44
|
+
version: 0, approvals: new Map(), steering: new Set() };
|
|
35
45
|
this.bindings.set(key, record);
|
|
36
46
|
return record;
|
|
37
47
|
}
|
|
38
48
|
|
|
49
|
+
assertCapacity(existing, active = true) {
|
|
50
|
+
if (!existing && this.bindings.size >= MAX_BINDINGS) throw relayError('request_capacity');
|
|
51
|
+
const activeCount = [...this.bindings.values()].filter(record => record.state === 'active').length;
|
|
52
|
+
if (active && existing?.state !== 'active' && activeCount >= MAX_ACTIVE) {
|
|
53
|
+
throw relayError('active_request_capacity');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Restore a delivery view, not authority to execute or approve the saved request. */
|
|
58
|
+
restore(client, runtime, owner) {
|
|
59
|
+
const key = keyFor(runtime.runtimeId, owner.requestId);
|
|
60
|
+
const previous = this.bindings.get(key);
|
|
61
|
+
if (previous && (!previous.durable || previous.subject !== owner.subject
|
|
62
|
+
|| previous.sessionId !== owner.sessionId)) throw relayError('request_not_owned');
|
|
63
|
+
const active = owner.status !== 'terminal';
|
|
64
|
+
const reusable = previous?.runtime === runtime && previous.state === (active ? 'active' : 'terminal');
|
|
65
|
+
this.assertCapacity(previous, active);
|
|
66
|
+
const record = reusable ? previous : { key, subject: owner.subject, deviceId: owner.deviceId,
|
|
67
|
+
runtime, sessionId: owner.sessionId, requestId: owner.requestId, durable: true,
|
|
68
|
+
state: active ? 'active' : 'terminal', expiresAt: active ? Infinity : owner.terminalAt + BINDING_TTL_MS,
|
|
69
|
+
version: 0, subscribers: new Set(previous?.subscribers), approvals: new Map(), steering: new Set() };
|
|
70
|
+
const wasSubscribed = record.subscribers.has(client);
|
|
71
|
+
const version = record.version;
|
|
72
|
+
record.subscribers.add(client);
|
|
73
|
+
this.bindings.set(key, record);
|
|
74
|
+
return { record, rollback: () => {
|
|
75
|
+
if (this.bindings.get(key) !== record || record.version !== version) return;
|
|
76
|
+
if (!reusable) {
|
|
77
|
+
if (!wasSubscribed) record.subscribers.delete(client);
|
|
78
|
+
if (previous) {
|
|
79
|
+
previous.subscribers = new Set(record.subscribers);
|
|
80
|
+
this.bindings.set(key, previous);
|
|
81
|
+
}
|
|
82
|
+
else this.bindings.delete(key);
|
|
83
|
+
} else if (!wasSubscribed) record.subscribers.delete(client);
|
|
84
|
+
} };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
markTerminal(record) {
|
|
88
|
+
if (record.durable) this.ownership.markTerminal(record.runtime.runtimeId, record.requestId);
|
|
89
|
+
record.state = 'terminal';
|
|
90
|
+
record.expiresAt = this.now() + BINDING_TTL_MS;
|
|
91
|
+
record.version += 1;
|
|
92
|
+
this.invalidateApprovals(record);
|
|
93
|
+
}
|
|
94
|
+
|
|
39
95
|
owned(client, runtime, requestId, requireActive = false) {
|
|
40
96
|
const record = this.bindings.get(keyFor(runtime.runtimeId, requestId));
|
|
41
97
|
if (!record || record.subject !== client.subject) throw relayError('request_not_owned');
|
|
@@ -61,7 +117,7 @@ export class RelayRequests {
|
|
|
61
117
|
|
|
62
118
|
consumeApproval(client, runtime, body) {
|
|
63
119
|
for (const record of this.bindings.values()) {
|
|
64
|
-
if (record.runtime !== runtime || record.subject !== client.subject || record.state !== 'active') continue;
|
|
120
|
+
if (record.durable || record.runtime !== runtime || record.subject !== client.subject || record.state !== 'active') continue;
|
|
65
121
|
const approval = record.approvals.get(body.approvalId);
|
|
66
122
|
if (!approval) continue;
|
|
67
123
|
if (!canConsumeObservedApproval(approval, body.approved, this.now())) throw relayError('approval_not_pending');
|
|
@@ -78,7 +134,7 @@ export class RelayRequests {
|
|
|
78
134
|
if (!record || record.runtime !== runtime || record.state !== 'active') return false;
|
|
79
135
|
if (!this.acceptSessionIdentity(record, body)) return false;
|
|
80
136
|
if (body.type === 'event' && (typeof body.name !== 'string' || body.name.length > 256)) return false;
|
|
81
|
-
if (body.type === 'event' && body.name === 'tool.approval.required') {
|
|
137
|
+
if (!record.durable && body.type === 'event' && body.name === 'tool.approval.required') {
|
|
82
138
|
if (!this.observeApproval(record, body.approvalId)) return false;
|
|
83
139
|
}
|
|
84
140
|
if (body.type === 'event' && ['tool.approval.granted', 'tool.approval.rejected'].includes(body.name)) {
|
|
@@ -86,10 +142,9 @@ export class RelayRequests {
|
|
|
86
142
|
if (approval) approval.state = 'closed';
|
|
87
143
|
}
|
|
88
144
|
if (body.type === 'completed' || body.type === 'failed') {
|
|
89
|
-
record
|
|
90
|
-
record.expiresAt = this.now() + BINDING_TTL_MS;
|
|
91
|
-
this.invalidateApprovals(record);
|
|
145
|
+
this.markTerminal(record);
|
|
92
146
|
}
|
|
147
|
+
record.version += 1;
|
|
93
148
|
try {
|
|
94
149
|
this.observe({ deviceId: record.deviceId, runtimeId: runtime.runtimeId,
|
|
95
150
|
sessionId: record.sessionId, requestId: record.requestId }, body);
|
|
@@ -99,6 +154,7 @@ export class RelayRequests {
|
|
|
99
154
|
}
|
|
100
155
|
|
|
101
156
|
acceptSessionIdentity(record, body) {
|
|
157
|
+
if (record.durable) return body.sessionId === record.sessionId;
|
|
102
158
|
if (body.sessionId === undefined || body.sessionId === null) return true;
|
|
103
159
|
if (!isRelayId(body.sessionId)) return false;
|
|
104
160
|
if (record.sessionId && record.sessionId !== body.sessionId) return false;
|
|
@@ -126,7 +182,7 @@ export class RelayRequests {
|
|
|
126
182
|
markUnknown(record, notify = true) {
|
|
127
183
|
if (record.state !== 'active') return;
|
|
128
184
|
record.state = 'unknown';
|
|
129
|
-
record.expiresAt = this.now() + BINDING_TTL_MS;
|
|
185
|
+
record.expiresAt = record.durable ? Infinity : this.now() + BINDING_TTL_MS;
|
|
130
186
|
this.invalidateApprovals(record);
|
|
131
187
|
if (notify) for (const client of record.subscribers) this.unknown(client, record);
|
|
132
188
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expectedResponseType } from '../protocol/protocol.mjs';
|
|
2
2
|
import { relayError } from './relay-runtime-registration.mjs';
|
|
3
|
+
import { isApprovalCommand } from '../protocol/approval-protocol.mjs';
|
|
3
4
|
|
|
4
5
|
const MUTATIONS = new Set([
|
|
5
6
|
'session.delete.request', 'session.messages.clear.request', 'session.message.delete.request',
|
|
@@ -14,8 +15,8 @@ const sessionKey = (runtime, sessionId) => JSON.stringify([runtime.generation, s
|
|
|
14
15
|
|
|
15
16
|
/** A bounded read-before-delete guard, not an atomic lock over independent clients. */
|
|
16
17
|
export class RelaySessionMutations {
|
|
17
|
-
constructor({ now, correlations, requests, transmit, connected, error, respond }) {
|
|
18
|
-
Object.assign(this, { now, correlations, requests, transmit, connected, error, respond });
|
|
18
|
+
constructor({ now, correlations, requests, ownership, transmit, connected, error, respond }) {
|
|
19
|
+
Object.assign(this, { now, correlations, requests, ownership, transmit, connected, error, respond });
|
|
19
20
|
this.pending = new Map();
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -25,12 +26,20 @@ export class RelaySessionMutations {
|
|
|
25
26
|
|
|
26
27
|
assertNoObservedWork(runtime, sessionId) {
|
|
27
28
|
for (const record of this.requests.bindings.values()) {
|
|
28
|
-
if (record.
|
|
29
|
+
if (record.sessionId !== sessionId) continue;
|
|
30
|
+
if (record.durable) {
|
|
31
|
+
if (record.runtime.runtimeId !== runtime.runtimeId) continue;
|
|
32
|
+
// Unknown durable work needs individual canonical proof below; it is
|
|
33
|
+
// never treated as completed merely because this relay restarted.
|
|
34
|
+
if (record.state === 'unknown') continue;
|
|
35
|
+
} else if (record.runtime !== runtime) continue;
|
|
29
36
|
if (record.state !== 'terminal') throw relayError('session_busy');
|
|
30
37
|
}
|
|
31
38
|
for (const entry of this.correlations.pending.values()) {
|
|
32
39
|
if (entry.runtime === runtime && entry.command.sessionId === sessionId
|
|
33
|
-
&& entry.command.type === 'attachment.upload.request'
|
|
40
|
+
&& (entry.command.type === 'attachment.upload.request' || isApprovalCommand(entry.command.type))) {
|
|
41
|
+
throw relayError('session_busy');
|
|
42
|
+
}
|
|
34
43
|
}
|
|
35
44
|
}
|
|
36
45
|
|
|
@@ -62,12 +71,37 @@ export class RelaySessionMutations {
|
|
|
62
71
|
if (request.sessionId !== operation.command.sessionId) throw relayError('session_state_unavailable');
|
|
63
72
|
if (!['completed', 'failed'].includes(request.status)) throw relayError('session_busy');
|
|
64
73
|
}
|
|
65
|
-
this.
|
|
74
|
+
this.reconcileOwnership(operation, body.requests);
|
|
75
|
+
this.send(operation, operation.command, result => {
|
|
76
|
+
const wholeSessionRemoved = result.ok && (
|
|
77
|
+
(operation.command.type === 'session.delete.request' && result.deleted === true)
|
|
78
|
+
|| (operation.command.type === 'session.messages.clear.request' && result.cleared === true));
|
|
79
|
+
if (wholeSessionRemoved) this.ownership?.retireSession(operation.runtime.runtimeId, operation.command.sessionId);
|
|
80
|
+
this.respond(operation, result);
|
|
81
|
+
});
|
|
66
82
|
} catch (error) {
|
|
67
83
|
this.error(operation, error.code ?? 'session_state_unavailable');
|
|
68
84
|
}
|
|
69
85
|
}
|
|
70
86
|
|
|
87
|
+
reconcileOwnership(operation, canonical) {
|
|
88
|
+
const runtimeId = operation.runtime.runtimeId;
|
|
89
|
+
const owners = this.ownership?.listSession(runtimeId, operation.command.sessionId) ?? [];
|
|
90
|
+
const open = owners.filter(owner => owner.status === 'open');
|
|
91
|
+
// Missing requests and partial histories are not evidence of completion.
|
|
92
|
+
for (const owner of open) {
|
|
93
|
+
if (!canonical.some(request => request.requestId === owner.requestId
|
|
94
|
+
&& request.sessionId === owner.sessionId && ['completed', 'failed'].includes(request.status))) {
|
|
95
|
+
throw relayError('session_busy');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const owner of open) {
|
|
99
|
+
this.ownership.markTerminal(runtimeId, owner.requestId);
|
|
100
|
+
const view = this.requests.bindings.get(JSON.stringify([runtimeId, owner.requestId]));
|
|
101
|
+
if (view?.durable && view.sessionId === owner.sessionId) this.requests.markTerminal(view);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
71
105
|
send(operation, command, respond) {
|
|
72
106
|
const entry = this.correlations.create(operation.client, operation.runtime, operation.id,
|
|
73
107
|
command, expectedResponseType(command.type), {
|
package/src/relay/relay.mjs
CHANGED
|
@@ -3,19 +3,23 @@ import { normalizeClientCommand, expectedResponseType } from '../protocol/protoc
|
|
|
3
3
|
import { createRelayClient, admitRelayCommand } from './relay-client-admission.mjs';
|
|
4
4
|
import { RelayCorrelations } from './relay-correlations.mjs';
|
|
5
5
|
import { RelayRequests } from './relay-requests.mjs';
|
|
6
|
+
import { RelayApprovals } from './relay-approvals.mjs';
|
|
7
|
+
import { isApprovalCommand } from '../protocol/approval-protocol.mjs';
|
|
8
|
+
import { assertSparkChatSupported } from '../protocol/spark-chat-protocol.mjs';
|
|
6
9
|
import { RelaySessionMutations, isSessionMutation } from './relay-session-mutations.mjs';
|
|
7
10
|
import { isBoundedRuntimeFrame, isRelayId, parseRuntimeRegistration,
|
|
8
11
|
validateRuntimeGrant, relayError } from './relay-runtime-registration.mjs';
|
|
9
12
|
|
|
10
13
|
export class Relay {
|
|
11
|
-
constructor({ now = Date.now, send, close, observeRequest }) {
|
|
14
|
+
constructor({ now = Date.now, send, close, observeRequest, ownership, isClientAuthorized = () => true }) {
|
|
12
15
|
this.now = now;
|
|
13
16
|
this.send = send;
|
|
14
17
|
this.close = close;
|
|
18
|
+
this.ownership = ownership;
|
|
15
19
|
this.clients = new Map();
|
|
16
20
|
this.runtimes = new Map();
|
|
17
21
|
this.runtimePeers = new Map();
|
|
18
|
-
this.requests = new RelayRequests({ now, observe: observeRequest,
|
|
22
|
+
this.requests = new RelayRequests({ now, ownership, observe: observeRequest,
|
|
19
23
|
event: (client, runtimeId, body) => this.deliver(client, { type: 'relay.event', runtimeId, body }),
|
|
20
24
|
unknown: (client, record) => this.error(client.peer, undefined, record.runtime.runtimeId,
|
|
21
25
|
'outcome_unknown', { requestId: record.requestId }),
|
|
@@ -25,8 +29,17 @@ export class Relay {
|
|
|
25
29
|
runtimeId: entry.runtime.runtimeId, body }),
|
|
26
30
|
unknown: entry => this.error(entry.client.peer, entry.id, entry.runtime.runtimeId, 'outcome_unknown'),
|
|
27
31
|
});
|
|
32
|
+
this.approvals = new RelayApprovals({ ownership, requests: this.requests, correlations: this.correlations,
|
|
33
|
+
transmit: (peer, body) => this.transmit(peer, body),
|
|
34
|
+
connected: client => this.clients.get(client.peer) === client && isClientAuthorized(client),
|
|
35
|
+
currentRuntime: runtime => this.runtimes.get(runtime.runtimeId) === runtime && runtime.registered,
|
|
36
|
+
assertSessionAvailable: (runtime, sessionId) => this.sessionMutations.assertNoPending(runtime, sessionId),
|
|
37
|
+
respond: (client, runtime, id, body) => this.deliver(client, { type: 'relay.response', id,
|
|
38
|
+
runtimeId: runtime.runtimeId, body }),
|
|
39
|
+
error: (client, runtime, id, code) => this.error(client.peer, id, runtime.runtimeId, code),
|
|
40
|
+
});
|
|
28
41
|
this.sessionMutations = new RelaySessionMutations({ now, correlations: this.correlations,
|
|
29
|
-
requests: this.requests, transmit: (peer, body) => this.transmit(peer, body),
|
|
42
|
+
requests: this.requests, ownership, transmit: (peer, body) => this.transmit(peer, body),
|
|
30
43
|
connected: client => this.clients.get(client.peer) === client,
|
|
31
44
|
error: (operation, code) => this.error(operation.client.peer, operation.id, operation.runtime.runtimeId, code),
|
|
32
45
|
respond: (operation, body) => this.deliver(operation.client, { type: 'relay.response',
|
|
@@ -136,11 +149,16 @@ export class Relay {
|
|
|
136
149
|
}
|
|
137
150
|
|
|
138
151
|
dispatch(client, runtime, id, command) {
|
|
139
|
-
|
|
140
|
-
if (
|
|
152
|
+
assertSparkChatSupported(command, runtime);
|
|
153
|
+
if (isSessionMutation(command.type)) {
|
|
154
|
+
this.approvals.adoption.assertNoPendingSession(runtime, command.sessionId);
|
|
155
|
+
return this.sessionMutations.dispatch(client, runtime, id, command);
|
|
156
|
+
}
|
|
157
|
+
if (['run_request', 'attachment.upload.request'].includes(command.type) || isApprovalCommand(command.type)) {
|
|
141
158
|
this.sessionMutations.assertNoPending(runtime, command.sessionId);
|
|
142
159
|
}
|
|
143
160
|
if (command.type === 'run_request') return this.dispatchRun(client, runtime, id, command);
|
|
161
|
+
if (isApprovalCommand(command.type)) return this.approvals.dispatch(client, runtime, id, command);
|
|
144
162
|
if (command.type === 'subscribe_request') {
|
|
145
163
|
this.requests.subscribe(client, runtime, command.requestId);
|
|
146
164
|
return this.acknowledge(client, runtime, id, { requestId: command.requestId, status: 'subscribed' });
|
|
@@ -155,6 +173,7 @@ export class Relay {
|
|
|
155
173
|
}
|
|
156
174
|
|
|
157
175
|
dispatchRun(client, runtime, id, command) {
|
|
176
|
+
this.approvals.adoption.assertNoPendingRequest(runtime, command.requestId);
|
|
158
177
|
const record = this.requests.create(client, runtime, command);
|
|
159
178
|
if (!this.transmit(runtime.peer, command)) {
|
|
160
179
|
this.requests.markUnknown(record, false);
|
|
@@ -198,5 +217,6 @@ export class Relay {
|
|
|
198
217
|
}
|
|
199
218
|
this.correlations.prune();
|
|
200
219
|
this.requests.prune();
|
|
220
|
+
this.ownership?.prune();
|
|
201
221
|
}
|
|
202
222
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Routing identity only. Approval authority and execution state belong to the runtime. */
|
|
2
|
+
export const REQUEST_OWNERSHIP_SCHEMA = `
|
|
3
|
+
CREATE TABLE request_ownership (
|
|
4
|
+
runtime_id TEXT NOT NULL CHECK(length(runtime_id) BETWEEN 1 AND 128),
|
|
5
|
+
request_id TEXT NOT NULL CHECK(length(request_id) BETWEEN 1 AND 128),
|
|
6
|
+
session_id TEXT NOT NULL CHECK(length(session_id) BETWEEN 1 AND 128),
|
|
7
|
+
subject TEXT NOT NULL CHECK(length(subject) BETWEEN 1 AND 320),
|
|
8
|
+
device_id TEXT NOT NULL CHECK(length(device_id) BETWEEN 1 AND 256),
|
|
9
|
+
status TEXT NOT NULL CHECK(status IN ('open', 'terminal')),
|
|
10
|
+
created_at INTEGER NOT NULL CHECK(created_at >= 0),
|
|
11
|
+
terminal_at INTEGER,
|
|
12
|
+
PRIMARY KEY(runtime_id, request_id),
|
|
13
|
+
CHECK((status = 'open' AND terminal_at IS NULL)
|
|
14
|
+
OR (status = 'terminal' AND terminal_at IS NOT NULL AND terminal_at >= 0))
|
|
15
|
+
) STRICT;
|
|
16
|
+
CREATE INDEX request_ownership_session ON request_ownership(runtime_id, session_id);
|
|
17
|
+
CREATE INDEX request_ownership_terminal ON request_ownership(terminal_at) WHERE status = 'terminal';
|
|
18
|
+
`;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { isRelayId, relayError } from './relay-runtime-registration.mjs';
|
|
2
|
+
|
|
3
|
+
const MAX_OWNERSHIP_RECORDS = 512;
|
|
4
|
+
const TERMINAL_RETENTION_MS = 12 * 60 * 60 * 1000;
|
|
5
|
+
const SELECT_RECORD = `SELECT runtime_id AS runtimeId, session_id AS sessionId,
|
|
6
|
+
request_id AS requestId, subject, device_id AS deviceId, status,
|
|
7
|
+
created_at AS createdAt, terminal_at AS terminalAt FROM request_ownership`;
|
|
8
|
+
|
|
9
|
+
function assertRequestIdentity(runtimeId, requestId) {
|
|
10
|
+
if (!isRelayId(runtimeId)) throw relayError('invalid_request_identity');
|
|
11
|
+
if (!isRelayId(requestId)) throw relayError('invalid_request_identity');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function assertOwnershipIdentity(identity, ownerId) {
|
|
15
|
+
if (!identity || typeof identity !== 'object') throw relayError('invalid_request_identity');
|
|
16
|
+
assertRequestIdentity(identity.runtimeId, identity.requestId);
|
|
17
|
+
if (!isRelayId(identity.sessionId)) throw relayError('invalid_request_identity');
|
|
18
|
+
if (identity.subject !== ownerId) throw relayError('invalid_request_identity');
|
|
19
|
+
if (typeof identity.deviceId !== 'string') throw relayError('invalid_request_identity');
|
|
20
|
+
if (!identity.deviceId.trim() || identity.deviceId.length > 256) throw relayError('invalid_request_identity');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Durable owner binding, never an approval cache or a queue for replaying runs. */
|
|
24
|
+
export class RequestOwnershipStore {
|
|
25
|
+
constructor({ database, now = Date.now }) {
|
|
26
|
+
if (typeof database?.prepare !== 'function' || typeof database?.transaction !== 'function') {
|
|
27
|
+
throw new TypeError('An authentication database is required');
|
|
28
|
+
}
|
|
29
|
+
if (typeof now !== 'function') throw new TypeError('An ownership clock is required');
|
|
30
|
+
this.database = database;
|
|
31
|
+
this.now = now;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
create(identity) {
|
|
35
|
+
assertOwnershipIdentity(identity, this.database.ownerId);
|
|
36
|
+
const time = this.#time();
|
|
37
|
+
return this.database.transaction(() => {
|
|
38
|
+
if (this.get(identity.runtimeId, identity.requestId)) throw relayError('request_id_conflict');
|
|
39
|
+
return this.#insert(identity, time, false);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
isOwnerSubject(subject) {
|
|
44
|
+
return subject === this.database.ownerId;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The relay must first verify the exact canonical request on the authorized runtime. */
|
|
48
|
+
adopt(identity, terminal = false) {
|
|
49
|
+
assertOwnershipIdentity(identity, this.database.ownerId);
|
|
50
|
+
if (typeof terminal !== 'boolean') throw relayError('invalid_request_identity');
|
|
51
|
+
const time = this.#time();
|
|
52
|
+
return this.database.transaction(() => {
|
|
53
|
+
const existing = this.get(identity.runtimeId, identity.requestId);
|
|
54
|
+
if (!existing) return this.#insert(identity, time, terminal);
|
|
55
|
+
if (existing.subject !== identity.subject) throw relayError('request_not_owned');
|
|
56
|
+
if (existing.sessionId !== identity.sessionId) throw relayError('request_not_owned');
|
|
57
|
+
return existing;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get(runtimeId, requestId) {
|
|
62
|
+
assertRequestIdentity(runtimeId, requestId);
|
|
63
|
+
const record = this.database.prepare(`${SELECT_RECORD} WHERE runtime_id = ? AND request_id = ?`)
|
|
64
|
+
.get(runtimeId, requestId);
|
|
65
|
+
return record ? { ...record } : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
listSession(runtimeId, sessionId) {
|
|
69
|
+
if (!isRelayId(runtimeId) || !isRelayId(sessionId)) throw relayError('invalid_request_identity');
|
|
70
|
+
return this.database.prepare(`${SELECT_RECORD} WHERE runtime_id = ? AND session_id = ?
|
|
71
|
+
ORDER BY created_at, request_id LIMIT ?`)
|
|
72
|
+
.all(runtimeId, sessionId, MAX_OWNERSHIP_RECORDS).map(record => ({ ...record }));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
markTerminal(runtimeId, requestId) {
|
|
76
|
+
assertRequestIdentity(runtimeId, requestId);
|
|
77
|
+
return this.database.prepare(`UPDATE request_ownership SET status = 'terminal', terminal_at = ?
|
|
78
|
+
WHERE runtime_id = ? AND request_id = ? AND status = 'open'`)
|
|
79
|
+
.run(this.#time(), runtimeId, requestId).changes > 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The caller must have confirmed deletion of the complete canonical session. */
|
|
83
|
+
retireSession(runtimeId, sessionId) {
|
|
84
|
+
if (!isRelayId(runtimeId) || !isRelayId(sessionId)) throw relayError('invalid_request_identity');
|
|
85
|
+
return this.database.prepare('DELETE FROM request_ownership WHERE runtime_id = ? AND session_id = ?')
|
|
86
|
+
.run(runtimeId, sessionId).changes;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
prune() {
|
|
90
|
+
return this.#deleteExpired(this.#time());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#insert(identity, time, terminal) {
|
|
94
|
+
this.#deleteExpired(time);
|
|
95
|
+
const count = this.database.prepare('SELECT count(*) AS count FROM request_ownership').get().count;
|
|
96
|
+
if (count >= MAX_OWNERSHIP_RECORDS) throw relayError('request_capacity');
|
|
97
|
+
this.database.prepare(`INSERT INTO request_ownership
|
|
98
|
+
(runtime_id, request_id, session_id, subject, device_id, status, created_at, terminal_at)
|
|
99
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
100
|
+
.run(identity.runtimeId, identity.requestId, identity.sessionId, identity.subject, identity.deviceId,
|
|
101
|
+
terminal ? 'terminal' : 'open', time, terminal ? time : null);
|
|
102
|
+
return this.get(identity.runtimeId, identity.requestId);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#deleteExpired(time) {
|
|
106
|
+
return this.database.prepare("DELETE FROM request_ownership WHERE status = 'terminal' AND terminal_at <= ?")
|
|
107
|
+
.run(time - TERMINAL_RETENTION_MS).changes;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#time() {
|
|
111
|
+
const time = this.now();
|
|
112
|
+
if (!Number.isSafeInteger(time) || time < 0) throw new TypeError('Invalid ownership clock');
|
|
113
|
+
return time;
|
|
114
|
+
}
|
|
115
|
+
}
|