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.
@@ -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
- export function atomicJson(file, value) {
10
- atomicJsonText(file, JSON.stringify(value));
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
- constructor(file, retention = 10000) {
128
- this.file = file;
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
- try {
131
- const document = JSON.parse(fs.readFileSync(file, 'utf8'));
132
- if (document.version !== 1 || !Array.isArray(document.events) || !Array.isArray(document.principals) || !document.sources || !document.consumers || !document.sequences || !document.prunedThrough)
133
- throw new Error('Invalid integration journal');
134
- this.doc = document;
135
- }
136
- catch (error) {
137
- if (error.code !== 'ENOENT')
138
- throw error;
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
- const parts = [JSON_OPEN];
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
- const counts = new Map();
258
- next.events = next.events.reverse().filter(event => {
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))
@@ -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 && fs.existsSync(defaultHttpsCaPath) ? fs.readFileSync(defaultHttpsCaPath) : undefined;
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 && fs.existsSync(defaultHttpsCaPath) ? fs.readFileSync(defaultHttpsCaPath) : undefined;
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 && fs.existsSync(defaultHttpsCaPath) ? fs.readFileSync(defaultHttpsCaPath) : undefined;
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,
@@ -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 resources = (subjectId) => mergeShareResources(context.relayed && !relayPasswordConfigured() ? [] : store.listEffective({ subjectId, serviceId }).map(g => g.resources));
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() ? [] : store.listEffective({ subjectId, serviceId }), id => options.resolveSessionId?.(id) || id, String(head.method), String(head.path));
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 === 'collaboration-connect') {
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(store.listEffective({ subjectId, serviceId })
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 = store.listEffective({ subjectId, serviceId });
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 = store.listEffective({ subjectId, serviceId }).filter(grant => grant.actions.some(action => ['session.view', 'session.chat-view', 'session.terminal-view'].includes(action))).reverse().sort((a, b) => b.createdAt - a.createdAt);
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;