termdock 1.4.383 → 1.4.384
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/dist/server/agent/collaborationStore.js +115 -41
- package/dist/server/agent/collaborationTaskStore.js +12 -15
- package/dist/server/agent/integrationCli.js +28 -0
- package/dist/server/agent/integrationJournal.js +273 -0
- package/dist/server/agent/integrationServer.js +91 -7
- package/dist/server/agent/integrationSessions.js +50 -15
- package/dist/server/agent/integrationStore.js +146 -109
- package/dist/server/agent/registry.js +1 -1
- package/dist/server/cli.js +6 -4
- package/dist/server/entry.js +2 -0
- package/dist/server/federation/runtime.js +37 -7
- package/dist/server/federation/sessionAccess.js +171 -0
- package/dist/server/todos/store.js +19 -35
- package/dist/server/utils/jsonStorageBudget.js +105 -0
- package/dist/server/utils/termdockState.js +5 -0
- package/docs/integrations.md +248 -0
- package/package.json +1 -1
- package/runtime-manifest.json +2 -2
|
@@ -1,74 +1,19 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
1
|
import path from 'node:path';
|
|
3
2
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
4
3
|
import { CollaborationError } from './collaborationProtocol.js';
|
|
5
4
|
import { resolveProfileNativeIdentity } from './nativeProcessIdentity.js';
|
|
5
|
+
import { IntegrationJournal } from './integrationJournal.js';
|
|
6
|
+
export { atomicJson } from './integrationJournal.js';
|
|
6
7
|
export const INTEGRATION_PROTOCOL = 1;
|
|
7
|
-
export const INTEGRATION_OPERATIONS = ['message.send', 'message.read', 'task.read', 'task.create', 'task.configure', 'task.assign', 'task.comment', 'task.revise', 'task.answer', 'events.read', 'session.create', 'session.read', 'session.restore', 'session.release', 'session.rename', 'session.close'];
|
|
8
|
+
export const INTEGRATION_OPERATIONS = ['message.send', 'message.read', 'task.read', 'task.create', 'task.configure', 'task.assign', 'task.comment', 'task.revise', 'task.answer', 'events.read', 'session.create', 'session.read', 'session.restore', 'session.release', 'session.rename', 'session.close', 'session.forget', 'session.access'];
|
|
8
9
|
export function integrationError(code, message, status = 400) { throw new CollaborationError(code, message, status); }
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
const EVENT_PREFIX = Buffer.from('{"type":"event",'), NEWLINE = Buffer.from('\n');
|
|
11
|
+
function positiveInteger(value, name, fallback) {
|
|
12
|
+
const raw = value ?? (process.env[name] === undefined ? fallback : Number(process.env[name]));
|
|
13
|
+
if (!Number.isSafeInteger(raw) || raw < 1)
|
|
14
|
+
integrationError('INVALID_EVENT_STORAGE_CONFIG', `${name} must be a positive safe integer`);
|
|
15
|
+
return raw;
|
|
11
16
|
}
|
|
12
|
-
function atomicJsonText(file, serialized) {
|
|
13
|
-
atomicJsonWrite(file, descriptor => fs.writeFileSync(descriptor, serialized));
|
|
14
|
-
}
|
|
15
|
-
function atomicJsonWrite(file, write) {
|
|
16
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
17
|
-
const temporary = `${file}.${process.pid}.tmp`;
|
|
18
|
-
const descriptor = fs.openSync(temporary, 'w', 0o600);
|
|
19
|
-
try {
|
|
20
|
-
fs.fchmodSync(descriptor, 0o600);
|
|
21
|
-
write(descriptor);
|
|
22
|
-
fs.fsyncSync(descriptor);
|
|
23
|
-
}
|
|
24
|
-
finally {
|
|
25
|
-
fs.closeSync(descriptor);
|
|
26
|
-
}
|
|
27
|
-
fs.renameSync(temporary, file);
|
|
28
|
-
const directory = fs.openSync(path.dirname(file), 'r');
|
|
29
|
-
try {
|
|
30
|
-
fs.fsyncSync(directory);
|
|
31
|
-
}
|
|
32
|
-
finally {
|
|
33
|
-
fs.closeSync(directory);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
function atomicJsonBuffers(file, parts) {
|
|
37
|
-
atomicJsonWrite(file, descriptor => {
|
|
38
|
-
let index = 0, offset = 0;
|
|
39
|
-
while (index < parts.length) {
|
|
40
|
-
// Bound vectors below platform IOV_MAX, and handle short writes without
|
|
41
|
-
// replaying or dropping any byte of the checked JSON document.
|
|
42
|
-
const vectors = parts.slice(index, index + 512);
|
|
43
|
-
if (offset)
|
|
44
|
-
vectors[0] = vectors[0].subarray(offset);
|
|
45
|
-
let written;
|
|
46
|
-
try {
|
|
47
|
-
written = fs.writevSync(descriptor, vectors);
|
|
48
|
-
}
|
|
49
|
-
catch (error) {
|
|
50
|
-
if (error.code === 'EINTR')
|
|
51
|
-
continue;
|
|
52
|
-
throw error;
|
|
53
|
-
}
|
|
54
|
-
if (written <= 0)
|
|
55
|
-
throw new Error('Integration journal write made no progress');
|
|
56
|
-
while (index < parts.length) {
|
|
57
|
-
const available = parts[index].length - offset;
|
|
58
|
-
if (written < available) {
|
|
59
|
-
offset += written;
|
|
60
|
-
break;
|
|
61
|
-
}
|
|
62
|
-
written -= available;
|
|
63
|
-
index++;
|
|
64
|
-
offset = 0;
|
|
65
|
-
if (!written)
|
|
66
|
-
break;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
const JSON_OPEN = Buffer.from('{'), JSON_CLOSE = Buffer.from('}'), JSON_COMMA = Buffer.from(','), ARRAY_OPEN = Buffer.from('['), ARRAY_CLOSE = Buffer.from(']'), EVENT_PREFIX = Buffer.from('{"type":"event",'), NEWLINE = Buffer.from('\n');
|
|
72
17
|
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
73
18
|
function identifier(value, label) {
|
|
74
19
|
if (typeof value !== 'string' || !/^[a-zA-Z0-9._-]{1,80}$/.test(value))
|
|
@@ -115,7 +60,6 @@ export function validatePolicy(value) {
|
|
|
115
60
|
return structuredClone({ id: value.id, groupId: value.groupId, permissions: value.permissions, launchProfiles: value.launchProfiles });
|
|
116
61
|
}
|
|
117
62
|
export class IntegrationStore {
|
|
118
|
-
file;
|
|
119
63
|
retention;
|
|
120
64
|
doc = { version: 1, principals: [], events: [], sequences: {}, prunedThrough: {}, sources: {}, consumers: {} };
|
|
121
65
|
listeners = new Set();
|
|
@@ -124,27 +68,41 @@ export class IntegrationStore {
|
|
|
124
68
|
// objects become collectible with their encodings.
|
|
125
69
|
encoded = new WeakMap();
|
|
126
70
|
groupEvents = new Map();
|
|
127
|
-
|
|
128
|
-
|
|
71
|
+
journal;
|
|
72
|
+
maxBytes;
|
|
73
|
+
maxGroupBytes;
|
|
74
|
+
warnPercent;
|
|
75
|
+
metadataWarnBytes;
|
|
76
|
+
eventBytes = 0;
|
|
77
|
+
groupBytes = new Map();
|
|
78
|
+
warning = '';
|
|
79
|
+
rejected = new Map();
|
|
80
|
+
constructor(file, retention = 10000, options = {}) {
|
|
129
81
|
this.retention = retention;
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
82
|
+
this.maxBytes = positiveInteger(options.maxBytes, 'TERMDOCK_INTEGRATION_EVENT_MAX_BYTES', 512 * 1024 * 1024);
|
|
83
|
+
this.maxGroupBytes = positiveInteger(options.maxGroupBytes, 'TERMDOCK_INTEGRATION_EVENT_GROUP_MAX_BYTES', Math.min(this.maxBytes, 128 * 1024 * 1024));
|
|
84
|
+
this.warnPercent = positiveInteger(options.warnPercent, 'TERMDOCK_INTEGRATION_EVENT_WARN_PERCENT', 80);
|
|
85
|
+
this.metadataWarnBytes = positiveInteger(options.metadataWarnBytes, 'TERMDOCK_INTEGRATION_METADATA_WARN_BYTES', 16 * 1024 * 1024);
|
|
86
|
+
const segmentBytes = positiveInteger(options.segmentBytes, 'TERMDOCK_INTEGRATION_EVENT_SEGMENT_BYTES', 1024 * 1024);
|
|
87
|
+
if (this.warnPercent > 100 || !Number.isSafeInteger(retention) || retention < 1)
|
|
88
|
+
integrationError('INVALID_EVENT_STORAGE_CONFIG', 'Invalid warning percentage or event retention');
|
|
89
|
+
this.journal = new IntegrationJournal(file, segmentBytes);
|
|
90
|
+
this.doc = this.journal.read() ?? this.doc;
|
|
91
|
+
// Copy every legacy fact before applying the new retention policy. The
|
|
92
|
+
// original bytes remain in .v1-backup; no source identity/cursor is rebuilt.
|
|
93
|
+
this.journal.migrate(this.doc, event => this.encode(event));
|
|
94
|
+
this.eventBytes = this.doc.events.reduce((bytes, event) => bytes + this.encode(event).length + 1, 0);
|
|
140
95
|
this.indexEvents();
|
|
96
|
+
this.reportCapacity();
|
|
141
97
|
}
|
|
142
98
|
indexEvents() {
|
|
143
99
|
this.groupEvents = new Map();
|
|
100
|
+
this.groupBytes = new Map();
|
|
144
101
|
for (const event of this.doc.events) {
|
|
145
102
|
const events = this.groupEvents.get(event.group_id) ?? [];
|
|
146
103
|
events.push(event);
|
|
147
104
|
this.groupEvents.set(event.group_id, events);
|
|
105
|
+
this.groupBytes.set(event.group_id, (this.groupBytes.get(event.group_id) ?? 0) + this.encode(event).length + 1);
|
|
148
106
|
}
|
|
149
107
|
}
|
|
150
108
|
encode(value) {
|
|
@@ -158,31 +116,14 @@ export class IntegrationStore {
|
|
|
158
116
|
return serialized;
|
|
159
117
|
}
|
|
160
118
|
write(next) {
|
|
161
|
-
|
|
162
|
-
for (const [index, [key, value]] of Object.entries(next).entries()) {
|
|
163
|
-
if (index)
|
|
164
|
-
parts.push(JSON_COMMA);
|
|
165
|
-
parts.push(Buffer.from(`${JSON.stringify(key)}:`));
|
|
166
|
-
if (key === 'events') {
|
|
167
|
-
parts.push(ARRAY_OPEN);
|
|
168
|
-
for (const [index, event] of next.events.entries()) {
|
|
169
|
-
if (index)
|
|
170
|
-
parts.push(JSON_COMMA);
|
|
171
|
-
parts.push(this.encode(event));
|
|
172
|
-
}
|
|
173
|
-
parts.push(ARRAY_CLOSE);
|
|
174
|
-
}
|
|
175
|
-
else
|
|
176
|
-
parts.push(this.encode(value));
|
|
177
|
-
}
|
|
178
|
-
parts.push(JSON_CLOSE);
|
|
179
|
-
if (parts.reduce((bytes, part) => bytes + part.length, 0) > 64 * 1024 * 1024)
|
|
180
|
-
integrationError('EVENT_STORAGE_FULL', 'Integration journal is full; no event was acknowledged', 503);
|
|
181
|
-
atomicJsonBuffers(this.file, parts);
|
|
119
|
+
this.journal.commit(next, event => this.encode(event));
|
|
182
120
|
const eventsChanged = next.events !== this.doc.events;
|
|
183
121
|
this.doc = next;
|
|
184
|
-
if (eventsChanged)
|
|
122
|
+
if (eventsChanged) {
|
|
123
|
+
this.eventBytes = next.events.reduce((bytes, event) => bytes + this.encode(event).length + 1, 0);
|
|
185
124
|
this.indexEvents();
|
|
125
|
+
}
|
|
126
|
+
this.reportCapacity();
|
|
186
127
|
for (const listener of this.listeners) {
|
|
187
128
|
try {
|
|
188
129
|
listener();
|
|
@@ -190,8 +131,103 @@ export class IntegrationStore {
|
|
|
190
131
|
catch { /* Subscription reports its own failure. */ }
|
|
191
132
|
}
|
|
192
133
|
}
|
|
134
|
+
storageStatus() {
|
|
135
|
+
return { format: 'segmented-v2', event_bytes: this.eventBytes, max_event_bytes: this.maxBytes,
|
|
136
|
+
max_group_event_bytes: this.maxGroupBytes,
|
|
137
|
+
group_usage: [...this.groupBytes].map(([group_id, event_bytes]) => ({ group_id, event_bytes, events: this.groupEvents.get(group_id).length })),
|
|
138
|
+
metadata_bytes: this.journal.metadataBytes, metadata_warn_bytes: this.metadataWarnBytes,
|
|
139
|
+
warning_percent: this.warnPercent, segments: this.journal.segmentCount, events: this.doc.events.length,
|
|
140
|
+
groups: this.groupEvents.size, sources: Object.keys(this.doc.sources).length, consumers: Object.keys(this.doc.consumers).length,
|
|
141
|
+
state: this.rejected.size || this.eventBytes >= this.maxBytes || [...this.groupBytes.values()].some(bytes => bytes >= this.maxGroupBytes) ? 'full'
|
|
142
|
+
: this.eventBytes >= this.maxBytes * this.warnPercent / 100 || [...this.groupBytes.values()].some(bytes => bytes >= this.maxGroupBytes * this.warnPercent / 100)
|
|
143
|
+
|| this.journal.metadataBytes >= this.metadataWarnBytes ? 'warning' : 'ok',
|
|
144
|
+
last_rejected_event_bytes: this.rejected.size ? Math.max(...this.rejected.values()) : null,
|
|
145
|
+
blocked_groups: [...this.rejected.keys()],
|
|
146
|
+
ack_available: true, reclamation: 'confirmed_prefix_only' };
|
|
147
|
+
}
|
|
148
|
+
reportCapacity() {
|
|
149
|
+
const status = this.storageStatus(), warning = status.state === 'ok' ? '' : `${status.state}:${status.metadata_bytes >= this.metadataWarnBytes}`;
|
|
150
|
+
if (warning && warning !== this.warning)
|
|
151
|
+
console.warn('[integration-storage]', JSON.stringify(status));
|
|
152
|
+
this.warning = warning;
|
|
153
|
+
}
|
|
154
|
+
reclaim(next) {
|
|
155
|
+
// Every registered consumer of an active principal protects its unconfirmed
|
|
156
|
+
// suffix. A group without a consumer has no durable delivery promise yet.
|
|
157
|
+
const confirmed = new Map();
|
|
158
|
+
for (const principal of next.principals)
|
|
159
|
+
if (!principal.revoked) {
|
|
160
|
+
const prefix = `${principal.id}:${principal.groupId}:`;
|
|
161
|
+
for (const [key, watermark] of Object.entries(next.consumers))
|
|
162
|
+
if (key.startsWith(prefix) && !next.consumerLifecycle?.[key]?.retired_at) {
|
|
163
|
+
confirmed.set(principal.groupId, Math.min(confirmed.get(principal.groupId) ?? Infinity, watermark));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const counts = new Map(), groupBytes = new Map(), changedGroups = new Set();
|
|
167
|
+
for (const event of next.events) {
|
|
168
|
+
counts.set(event.group_id, (counts.get(event.group_id) ?? 0) + 1);
|
|
169
|
+
groupBytes.set(event.group_id, (groupBytes.get(event.group_id) ?? 0) + this.encode(event).length + 1);
|
|
170
|
+
if (event.sequence > (this.doc.sequences[event.group_id] ?? 0))
|
|
171
|
+
changedGroups.add(event.group_id);
|
|
172
|
+
}
|
|
173
|
+
let bytes = next.events.reduce((total, event) => total + this.encode(event).length + 1, 0);
|
|
174
|
+
next.events = next.events.filter(event => {
|
|
175
|
+
const group = event.group_id, count = counts.get(group);
|
|
176
|
+
// Never discard facts from the incoming batch to make that batch fit.
|
|
177
|
+
const safe = Math.min(confirmed.get(group) ?? next.sequences[group], this.doc.sequences[group] ?? 0);
|
|
178
|
+
if (event.sequence > safe || count <= this.retention && bytes <= this.maxBytes && (!changedGroups.has(group) || groupBytes.get(group) <= this.maxGroupBytes))
|
|
179
|
+
return true;
|
|
180
|
+
counts.set(group, count - 1);
|
|
181
|
+
bytes -= this.encode(event).length + 1;
|
|
182
|
+
groupBytes.set(group, groupBytes.get(group) - this.encode(event).length - 1);
|
|
183
|
+
next.prunedThrough[group] = Math.max(next.prunedThrough[group] ?? 0, event.sequence);
|
|
184
|
+
return false;
|
|
185
|
+
});
|
|
186
|
+
if (bytes > this.maxBytes || [...changedGroups].some(group => groupBytes.get(group) > this.maxGroupBytes)) {
|
|
187
|
+
for (const group of changedGroups)
|
|
188
|
+
this.rejected.set(group, bytes);
|
|
189
|
+
this.reportCapacity();
|
|
190
|
+
integrationError('EVENT_STORAGE_FULL', 'Unconfirmed integration events exceed global/group capacity; consume and ACK existing events or increase TERMDOCK_INTEGRATION_EVENT_MAX_BYTES / TERMDOCK_INTEGRATION_EVENT_GROUP_MAX_BYTES. No source update in this group batch was committed', 503);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
consumerStatus() {
|
|
194
|
+
return Object.entries(this.doc.consumers).map(([key, sequence]) => {
|
|
195
|
+
const principal = this.doc.principals.find(p => key.startsWith(`${p.id}:${p.groupId}:`));
|
|
196
|
+
const group = principal?.groupId, lifecycle = this.doc.consumerLifecycle?.[key];
|
|
197
|
+
return { principal_id: principal?.id ?? null, group_id: group ?? null,
|
|
198
|
+
consumer: principal ? key.slice(`${principal.id}:${principal.groupId}:`.length) : key,
|
|
199
|
+
confirmed_sequence: sequence, latest_sequence: group ? this.doc.sequences[group] ?? 0 : null,
|
|
200
|
+
lag: group ? (this.doc.sequences[group] ?? 0) - sequence : null,
|
|
201
|
+
retention_gap: group ? sequence < (this.doc.prunedThrough[group] ?? 0) : null,
|
|
202
|
+
retired_at: lifecycle?.retired_at ?? null, last_ack_at: lifecycle?.last_ack_at ?? null,
|
|
203
|
+
protects_events: !!principal && !principal.revoked && !lifecycle?.retired_at };
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
retireConsumer(principalId, consumer, expectedSequence, idempotencyKey) {
|
|
207
|
+
const principal = this.doc.principals.find(p => p.id === principalId);
|
|
208
|
+
if (!principal)
|
|
209
|
+
integrationError('INTEGRATION_NOT_FOUND', 'Integration does not exist', 404);
|
|
210
|
+
const key = this.key(principal, consumer), sequence = this.doc.consumers[key], lifecycle = this.doc.consumerLifecycle?.[key];
|
|
211
|
+
if (!Number.isSafeInteger(expectedSequence) || expectedSequence < 0 || typeof idempotencyKey !== 'string' || !idempotencyKey.trim() || idempotencyKey.length > 160)
|
|
212
|
+
integrationError('INVALID_CONSUMER_RETIREMENT', 'An expected sequence and idempotency key are required');
|
|
213
|
+
if (sequence === undefined)
|
|
214
|
+
integrationError('EVENT_CONSUMER_NOT_STARTED', 'Consumer has not started');
|
|
215
|
+
const operation = hash(idempotencyKey);
|
|
216
|
+
if (lifecycle?.retired_at) {
|
|
217
|
+
if (lifecycle.retirement_key !== operation || sequence !== expectedSequence)
|
|
218
|
+
integrationError('EVENT_CONSUMER_RETIRED', 'Consumer was already retired by a different decision', 409);
|
|
219
|
+
return { retired_at: lifecycle.retired_at, confirmed_sequence: sequence };
|
|
220
|
+
}
|
|
221
|
+
if (sequence !== expectedSequence)
|
|
222
|
+
integrationError('EVENT_CONSUMER_CHANGED', 'Consumer advanced; reread before retiring', 409);
|
|
223
|
+
const retired_at = Date.now();
|
|
224
|
+
this.write({ ...this.doc, consumerLifecycle: { ...this.doc.consumerLifecycle,
|
|
225
|
+
[key]: { ...lifecycle, retired_at, retirement_key: operation } } });
|
|
226
|
+
return { retired_at, confirmed_sequence: sequence };
|
|
227
|
+
}
|
|
193
228
|
subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }
|
|
194
229
|
policies() { return this.doc.principals.map(({ tokenHash: _, ...principal }) => structuredClone(principal)); }
|
|
230
|
+
permits(id, permission) { return this.doc.principals.some(p => p.id === id && !p.revoked && p.permissions.includes(permission)); }
|
|
195
231
|
provision(policy) {
|
|
196
232
|
const checked = validatePolicy(policy);
|
|
197
233
|
if (this.doc.principals.some(p => p.id === checked.id))
|
|
@@ -254,16 +290,12 @@ export class IntegrationStore {
|
|
|
254
290
|
}
|
|
255
291
|
if (!next)
|
|
256
292
|
return;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const count = (counts.get(event.group_id) ?? 0) + 1;
|
|
260
|
-
counts.set(event.group_id, count);
|
|
261
|
-
if (count <= this.retention)
|
|
262
|
-
return true;
|
|
263
|
-
next.prunedThrough[event.group_id] = Math.max(next.prunedThrough[event.group_id] ?? 0, event.sequence);
|
|
264
|
-
return false;
|
|
265
|
-
}).reverse();
|
|
293
|
+
this.reclaim(next);
|
|
294
|
+
const changedGroups = new Set(updates.filter(update => this.doc.sources[update.sourceKey] !== update.sourceVersion).map(update => update.event.group_id));
|
|
266
295
|
this.write(next);
|
|
296
|
+
for (const group of changedGroups)
|
|
297
|
+
this.rejected.delete(group);
|
|
298
|
+
this.reportCapacity();
|
|
267
299
|
}
|
|
268
300
|
cursor(group, sequence) { return Buffer.from(JSON.stringify({ v: 1, group, sequence })).toString('base64url'); }
|
|
269
301
|
decode(group, cursor) {
|
|
@@ -282,6 +314,8 @@ export class IntegrationStore {
|
|
|
282
314
|
selectPage(principal, consumer, cursor) {
|
|
283
315
|
this.authorize(principal, 'events.read');
|
|
284
316
|
const key = this.key(principal, consumer), group = principal.groupId;
|
|
317
|
+
if (this.doc.consumerLifecycle?.[key]?.retired_at)
|
|
318
|
+
integrationError('EVENT_CONSUMER_RETIRED', 'Administrator retired this consumer; reconcile and explicitly use a new consumer name', 409);
|
|
285
319
|
const floor = this.doc.prunedThrough[group] ?? 0;
|
|
286
320
|
// A new consumer explicitly starts at the earliest retained fact. Existing
|
|
287
321
|
// consumers never silently skip a retention gap.
|
|
@@ -316,6 +350,8 @@ export class IntegrationStore {
|
|
|
316
350
|
ack(principal, consumer, cursor) {
|
|
317
351
|
this.authorize(principal, 'events.read');
|
|
318
352
|
const key = this.key(principal, consumer), sequence = this.decode(principal.groupId, cursor), confirmed = this.doc.consumers[key];
|
|
353
|
+
if (this.doc.consumerLifecycle?.[key]?.retired_at)
|
|
354
|
+
integrationError('EVENT_CONSUMER_RETIRED', 'Retired consumers cannot acknowledge', 409);
|
|
319
355
|
if (confirmed === undefined)
|
|
320
356
|
integrationError('EVENT_CONSUMER_NOT_STARTED', 'Subscribe before acknowledging');
|
|
321
357
|
if (sequence < confirmed)
|
|
@@ -325,7 +361,8 @@ export class IntegrationStore {
|
|
|
325
361
|
// Cursor is a cumulative declaration that every preceding event is safely
|
|
326
362
|
// in the bridge inbox. The subscription server separately checks it was sent.
|
|
327
363
|
if (sequence !== confirmed)
|
|
328
|
-
this.write({ ...this.doc, consumers: { ...this.doc.consumers, [key]: sequence }
|
|
364
|
+
this.write({ ...this.doc, consumers: { ...this.doc.consumers, [key]: sequence },
|
|
365
|
+
consumerLifecycle: { ...this.doc.consumerLifecycle, [key]: { ...this.doc.consumerLifecycle?.[key], last_ack_at: Date.now() } } });
|
|
329
366
|
return { cursor, ack_semantics: 'durable_received' };
|
|
330
367
|
}
|
|
331
368
|
cursorSequence(group, cursor) { return this.decode(group, cursor); }
|
|
@@ -286,7 +286,7 @@ export function inferResumeSessionId(agent, argv) {
|
|
|
286
286
|
}
|
|
287
287
|
const flags = agent.slug === 'opencode' || agent.slug === 'kimi'
|
|
288
288
|
? ['--session', '-s', '-S']
|
|
289
|
-
: ['--resume', '-r'];
|
|
289
|
+
: agent.slug === 'claude' ? ['--resume', '-r', '--session-id'] : ['--resume', '-r'];
|
|
290
290
|
for (let index = 0; index < tail.length; index += 1) {
|
|
291
291
|
const token = tail[index];
|
|
292
292
|
if (flags.includes(token))
|
package/dist/server/cli.js
CHANGED
|
@@ -44,7 +44,7 @@ import { runBootChecks, formatBootCheckReport } from './utils/bootCheck.js';
|
|
|
44
44
|
import { localAccessManager, getLanIPv4Addresses } from './utils/localAccess.js';
|
|
45
45
|
import { OFFICIAL_NPM_REGISTRY, updateTermdockFromOfficialRegistry } from './utils/npmUpdate.js';
|
|
46
46
|
import { DAEMON_START_TIMEOUT_MS, waitForHealth } from './utils/healthProbe.js';
|
|
47
|
-
import { isProcessRunning, STOP_GRACE_MS } from './utils/termdockState.js';
|
|
47
|
+
import { isProcessRunning, readLocalApiCa, STOP_GRACE_MS } from './utils/termdockState.js';
|
|
48
48
|
import { recordSupervisorLost } from './utils/crashForensics.js';
|
|
49
49
|
import { getTermdockVersion } from './utils/version.js';
|
|
50
50
|
import { getSupervisorStatus, signalSupervisorRestart, spawnSupervisor, stopSupervisor, } from './utils/supervisorClient.js';
|
|
@@ -1276,7 +1276,7 @@ async function postLocalJson(baseUrl, token, endpoint, payload, timeoutMs = 30_0
|
|
|
1276
1276
|
const body = JSON.stringify(payload);
|
|
1277
1277
|
const isHttps = url.protocol === 'https:';
|
|
1278
1278
|
const requestImpl = isHttps ? https.request : http.request;
|
|
1279
|
-
const ca = isHttps
|
|
1279
|
+
const ca = isHttps ? readLocalApiCa(getRunningState(), defaultHttpsCaPath) : undefined;
|
|
1280
1280
|
return new Promise((resolve, reject) => {
|
|
1281
1281
|
const req = requestImpl({
|
|
1282
1282
|
protocol: url.protocol,
|
|
@@ -1307,7 +1307,7 @@ async function getLocalJson(baseUrl, token, endpoint, timeoutMs = 30_000, method
|
|
|
1307
1307
|
const url = new URL(endpoint, baseUrl);
|
|
1308
1308
|
const isHttps = url.protocol === 'https:';
|
|
1309
1309
|
const requestImpl = isHttps ? https.request : http.request;
|
|
1310
|
-
const ca = isHttps
|
|
1310
|
+
const ca = isHttps ? readLocalApiCa(getRunningState(), defaultHttpsCaPath) : undefined;
|
|
1311
1311
|
return new Promise((resolve, reject) => {
|
|
1312
1312
|
const req = requestImpl({
|
|
1313
1313
|
protocol: url.protocol,
|
|
@@ -2471,7 +2471,7 @@ async function runPluginRemove(slug) {
|
|
|
2471
2471
|
const url = new URL(`/api/terminal/agent-plugins/${encodeURIComponent(slug)}`, baseUrl);
|
|
2472
2472
|
const isHttps = url.protocol === 'https:';
|
|
2473
2473
|
const requestImpl = isHttps ? https.request : http.request;
|
|
2474
|
-
const ca = isHttps
|
|
2474
|
+
const ca = isHttps ? readLocalApiCa(getRunningState(), defaultHttpsCaPath) : undefined;
|
|
2475
2475
|
const req = requestImpl({
|
|
2476
2476
|
protocol: url.protocol,
|
|
2477
2477
|
hostname: url.hostname,
|
|
@@ -3735,6 +3735,7 @@ async function main() {
|
|
|
3735
3735
|
host: options.host ?? DEFAULT_HOST,
|
|
3736
3736
|
port: options.port ?? PORT.backend,
|
|
3737
3737
|
scheme: result.scheme,
|
|
3738
|
+
httpsCaPath: https.ca,
|
|
3738
3739
|
localUrl: `${result.scheme}://${(options.host ?? DEFAULT_HOST) === '0.0.0.0' ? 'localhost' : (options.host ?? DEFAULT_HOST)}:${options.port ?? PORT.backend}`,
|
|
3739
3740
|
lanUrl: state.status === 'active' ? state.url : state.fallbackUrl,
|
|
3740
3741
|
onboardingUrl: result.getOnboardingUrl(),
|
|
@@ -3763,6 +3764,7 @@ async function main() {
|
|
|
3763
3764
|
host: options.host ?? DEFAULT_HOST,
|
|
3764
3765
|
port: options.port ?? PORT.backend,
|
|
3765
3766
|
scheme: result.scheme,
|
|
3767
|
+
httpsCaPath: https.ca,
|
|
3766
3768
|
localUrl: `${result.scheme}://${(options.host ?? DEFAULT_HOST) === '0.0.0.0' ? 'localhost' : (options.host ?? DEFAULT_HOST)}:${options.port ?? PORT.backend}`,
|
|
3767
3769
|
lanUrl: metadata.lanUrl,
|
|
3768
3770
|
onboardingUrl: metadata.onboardingUrl,
|
package/dist/server/entry.js
CHANGED
|
@@ -522,6 +522,7 @@ export function startServer(options = {}) {
|
|
|
522
522
|
sessionAttachmentDirectory: path.join(homedir(), '.termdock', 'chat-attachments'),
|
|
523
523
|
restoreSharedSession: restoreSharedFederationSession,
|
|
524
524
|
resolveSessionId: resolveStableFederationSessionId,
|
|
525
|
+
sessionAccess: () => app.locals.integrationRuntime?.sessionAccess,
|
|
525
526
|
collaborationConnected: (subjectId, rpc) => {
|
|
526
527
|
const channels = collaborationChannels.get(subjectId) ?? new Set();
|
|
527
528
|
channels.add(rpc);
|
|
@@ -687,6 +688,7 @@ export function startServer(options = {}) {
|
|
|
687
688
|
try {
|
|
688
689
|
const port = options.port ?? DEFAULT_PORT;
|
|
689
690
|
integration = new IntegrationRuntime({ directory: path.join(homedir(), '.termdock', 'integration-state', String(port)), socketPath: integrationSocketPath(port), adminToken: options.localApiToken,
|
|
691
|
+
serviceId: federationServiceId,
|
|
690
692
|
messages: collaborationStore, taskStore: collaborationTaskStore, tasks: collaborationTasks, peers: collaborationService, sessions: integrationSessionAdapter });
|
|
691
693
|
await integration.listen();
|
|
692
694
|
app.locals.integrationRuntime = integration;
|
|
@@ -155,6 +155,7 @@ export async function createFederationRuntime(app, directory, handlers, options
|
|
|
155
155
|
return Array.isArray(inventory.clientSessions) ? inventory.clientSessions : [];
|
|
156
156
|
}
|
|
157
157
|
async function acceptDuplex(duplex, context = {}) {
|
|
158
|
+
let sessionClaims;
|
|
158
159
|
let collaborationRpc;
|
|
159
160
|
let unregisterCollaboration;
|
|
160
161
|
const open = (action) => openAccessAllowed(!context.relayed && context.allowOpenAccess === true, isAuthEnabled(), action);
|
|
@@ -168,8 +169,21 @@ export async function createFederationRuntime(app, directory, handlers, options
|
|
|
168
169
|
}
|
|
169
170
|
};
|
|
170
171
|
const allowed = (subjectId, action, sessionId) => {
|
|
172
|
+
if (sessionClaims) {
|
|
173
|
+
const access = options.sessionAccess?.();
|
|
174
|
+
return !!access && (access.allows(sessionClaims, subjectId, action, sessionId)
|
|
175
|
+
|| !!sessionId && access.allows(sessionClaims, subjectId, action, stableSessionId(sessionId)));
|
|
176
|
+
}
|
|
171
177
|
if (context.relayed && !relayPasswordConfigured())
|
|
172
178
|
return false;
|
|
179
|
+
// Scope-only invitations cannot bypass an integration's exclusive writer.
|
|
180
|
+
// Full-service maintenance access remains independent of delegated tickets.
|
|
181
|
+
if (sessionId !== undefined && !store.hasFullServiceAccess({ subjectId, serviceId })) {
|
|
182
|
+
const access = options.sessionAccess?.(), stable = stableSessionId(sessionId);
|
|
183
|
+
if (access?.restrictsNativeWriter(sessionId, action)
|
|
184
|
+
|| stable !== undefined && access?.restrictsNativeWriter(stable, action))
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
173
187
|
if (open(action) || store.authorize({ subjectId, serviceId, action, sessionId }).allowed)
|
|
174
188
|
return true;
|
|
175
189
|
// The wire may still carry the transient backend PTY id while the grant
|
|
@@ -179,10 +193,13 @@ export async function createFederationRuntime(app, directory, handlers, options
|
|
|
179
193
|
const stable = stableSessionId(sessionId);
|
|
180
194
|
return stable !== undefined && store.authorize({ subjectId, serviceId, action, sessionId: stable }).allowed;
|
|
181
195
|
};
|
|
182
|
-
const
|
|
196
|
+
const grantsFor = (subjectId) => sessionClaims
|
|
197
|
+
? (options.sessionAccess?.()?.grant(sessionClaims, subjectId) || []).map(grant => ({ ...grant, serviceId, createdAt: 0 }))
|
|
198
|
+
: store.listEffective({ subjectId, serviceId });
|
|
199
|
+
const resources = (subjectId) => mergeShareResources(context.relayed && !relayPasswordConfigured() ? [] : grantsFor(subjectId).map(g => g.resources));
|
|
183
200
|
const androidAllowed = (subjectId, serial, control = false) => full(subjectId) || (control ? androidRouteAccess(resources(subjectId).android, serial) === 'control' : !!androidRouteAccess(resources(subjectId).android, serial));
|
|
184
201
|
const computerAllowed = (subjectId, target) => full(subjectId) || !!computerResource(resources(subjectId), target.host, target.protocol, target.port);
|
|
185
|
-
const full = (subjectId) => (!context.relayed || relayPasswordConfigured()) && (open() || store.hasFullServiceAccess({ subjectId, serviceId }));
|
|
202
|
+
const full = (subjectId) => !sessionClaims && (!context.relayed || relayPasswordConfigured()) && (open() || store.hasFullServiceAccess({ subjectId, serviceId }));
|
|
186
203
|
let channel;
|
|
187
204
|
const operations = new Map();
|
|
188
205
|
const sockets = new Map();
|
|
@@ -196,7 +213,7 @@ export async function createFederationRuntime(app, directory, handlers, options
|
|
|
196
213
|
const send = (packet) => channel.send(packet);
|
|
197
214
|
const check = (action, sessionId) => { if (!allowed(subjectId, action, sessionId))
|
|
198
215
|
throw new Error('AUTHORIZATION_DENIED'); };
|
|
199
|
-
const attachmentRoot = (head) => attachmentReadRoot(options.sessionAttachmentDirectory, context.relayed && !relayPasswordConfigured() ? [] :
|
|
216
|
+
const attachmentRoot = (head) => attachmentReadRoot(options.sessionAttachmentDirectory, context.relayed && !relayPasswordConfigured() ? [] : grantsFor(subjectId), id => options.resolveSessionId?.(id) || id, String(head.method), String(head.path));
|
|
200
217
|
const httpAllowed = (head) => {
|
|
201
218
|
if (context.relayed)
|
|
202
219
|
requireRelayPassword();
|
|
@@ -290,6 +307,10 @@ export async function createFederationRuntime(app, directory, handlers, options
|
|
|
290
307
|
}
|
|
291
308
|
}
|
|
292
309
|
revokeTimer = setInterval(() => {
|
|
310
|
+
if (sessionClaims && !options.sessionAccess?.()?.valid(sessionClaims, subjectId)) {
|
|
311
|
+
channel?.close();
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
293
314
|
if (context.relayed && !relayPasswordConfigured()) {
|
|
294
315
|
duplex.close?.(new Error('TARGET_PASSWORD_REQUIRED'));
|
|
295
316
|
return;
|
|
@@ -321,9 +342,18 @@ export async function createFederationRuntime(app, directory, handlers, options
|
|
|
321
342
|
throw new Error('INVALID_ID');
|
|
322
343
|
if (context.relayed)
|
|
323
344
|
requireRelayPassword();
|
|
345
|
+
if (sessionClaims && !['permissions', 'session-list', 'http', 'upload', 'upload-end', 'ack', 'cancel', 'ws-open', 'ws-data', 'ws-close'].includes(packet.type))
|
|
346
|
+
throw new Error('AUTHORIZATION_DENIED');
|
|
324
347
|
if (collaborationRpc?.accept(packet))
|
|
325
348
|
continue;
|
|
326
|
-
if (packet.type === '
|
|
349
|
+
if (packet.type === 'session-auth') {
|
|
350
|
+
const access = options.sessionAccess?.();
|
|
351
|
+
if (!access)
|
|
352
|
+
throw new Error('SESSION_ACCESS_UNAVAILABLE');
|
|
353
|
+
sessionClaims = access.consume(packet.ticket, subjectId);
|
|
354
|
+
send({ type: 'result', id: packet.id, serviceId, sessionId: sessionClaims.sessionId });
|
|
355
|
+
}
|
|
356
|
+
else if (packet.type === 'collaboration-connect') {
|
|
327
357
|
if (!options.collaborationService || !options.collaborationConnected)
|
|
328
358
|
throw new Error('COLLABORATION_UPGRADE_REQUIRED');
|
|
329
359
|
options.collaborationService(subjectId, { type: 'collaboration-service', id: packet.id, action: 'duplex' });
|
|
@@ -355,7 +385,7 @@ export async function createFederationRuntime(app, directory, handlers, options
|
|
|
355
385
|
if (!origin || !packet.node || typeof packet.node !== 'object')
|
|
356
386
|
throw new Error('INVALID_COLLABORATION_PEER');
|
|
357
387
|
// The peer may only touch sessions this device can write to right now.
|
|
358
|
-
const sessions = [...new Set(
|
|
388
|
+
const sessions = [...new Set(grantsFor(subjectId)
|
|
359
389
|
.filter(grant => grant.scope.kind === 'sessions' && grant.actions.includes('session.input'))
|
|
360
390
|
.flatMap(grant => grant.scope.kind === 'sessions' ? grant.scope.sessionIds : []))];
|
|
361
391
|
if (!sessions.length)
|
|
@@ -429,7 +459,7 @@ export async function createFederationRuntime(app, directory, handlers, options
|
|
|
429
459
|
send({ type: 'result', id: packet.id, ok: true });
|
|
430
460
|
}
|
|
431
461
|
else if (packet.type === 'permissions') {
|
|
432
|
-
const grants =
|
|
462
|
+
const grants = grantsFor(subjectId);
|
|
433
463
|
send({ type: 'result', id: packet.id, subjectId, serviceId, fullService: full(subjectId), grants, httpWindow: MAX_TRANSFER_WINDOW, binaryChunks: true, relayAuthorizationErrors: true, relaySelfTargetGuard: true, webTransport: options.webTransport?.() });
|
|
434
464
|
}
|
|
435
465
|
else if (packet.type === 'session-list') {
|
|
@@ -444,7 +474,7 @@ export async function createFederationRuntime(app, directory, handlers, options
|
|
|
444
474
|
if (restored)
|
|
445
475
|
inventory = await sessionInventory();
|
|
446
476
|
}
|
|
447
|
-
const displayGrants =
|
|
477
|
+
const displayGrants = grantsFor(subjectId).filter(grant => grant.actions.some(action => ['session.view', 'session.chat-view', 'session.terminal-view'].includes(action))).reverse().sort((a, b) => b.createdAt - a.createdAt);
|
|
448
478
|
const seen = new Set();
|
|
449
479
|
const items = inventory.flatMap(session => {
|
|
450
480
|
const sessionId = session.backendSessionId;
|