@ours.network/fleet 1.2.0-nightly.45 → 1.2.0-nightly.47
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/README.md +52 -0
- package/dist/application/role-removal-service.js +2 -0
- package/dist/application/session-control.d.ts +12 -0
- package/dist/application/session-control.js +19 -1
- package/dist/build-info.json +4 -4
- package/dist/cli.js +73 -12
- package/dist/notifications/fleet-producer.d.ts +1 -0
- package/dist/notifications/fleet-producer.js +82 -3
- package/dist/notifications/outbox.d.ts +4 -0
- package/dist/notifications/outbox.js +9 -0
- package/dist/notifications/target-cleanup.d.ts +8 -0
- package/dist/notifications/target-cleanup.js +41 -0
- package/dist/ops.js +2 -0
- package/dist/session/acp.d.ts +2 -0
- package/dist/session/acp.js +10 -0
- package/dist/session/arbiter.d.ts +2 -0
- package/dist/session/arbiter.js +8 -0
- package/dist/session/codex-app-server.d.ts +2 -0
- package/dist/session/codex-app-server.js +10 -0
- package/dist/session/control.d.ts +3 -1
- package/dist/session/control.js +26 -2
- package/dist/session/conversation-types.d.ts +2 -0
- package/dist/session/types.d.ts +3 -0
- package/dist/tunnel-setup-input.d.ts +11 -0
- package/dist/tunnel-setup-input.js +42 -0
- package/dist/web/server.js +83 -2
- package/dist/web/workspace-devices.d.ts +2 -0
- package/dist/web/workspace-devices.js +13 -0
- package/dist/workspace-enrollment.d.ts +30 -2
- package/dist/workspace-enrollment.js +205 -12
- package/dist/workspace-replacement.d.ts +66 -0
- package/dist/workspace-replacement.js +220 -0
- package/docs/live-voice.md +13 -0
- package/package.json +6 -5
|
@@ -17,7 +17,7 @@ export function readWorkspacePayload(file) {
|
|
|
17
17
|
throw Error('Setup payload requires an owned private regular file (chmod 600)');
|
|
18
18
|
return decodeWorkspacePayload(readFileSync(path, 'utf8'));
|
|
19
19
|
}
|
|
20
|
-
export function decodeWorkspacePayload(input) {
|
|
20
|
+
export function decodeWorkspacePayload(input, options = {}) {
|
|
21
21
|
let p;
|
|
22
22
|
try {
|
|
23
23
|
p = JSON.parse(Buffer.from(input.trim(), 'base64url').toString());
|
|
@@ -25,13 +25,43 @@ export function decodeWorkspacePayload(input) {
|
|
|
25
25
|
catch {
|
|
26
26
|
throw Error('Invalid workspace payload');
|
|
27
27
|
}
|
|
28
|
-
if (p.version !== 1 || !['https://app.ours.network', 'https://app.ours-tunnel.com'].includes(p.appOrigin) || !/^[a-z0-9][a-z0-9-]{2,60}\.ours-tunnel\.com$/.test(p.hostname) || !/^[a-z0-9-]{2,30}@[a-z0-9-]{2,30}$/.test(p.rootName) || !/^[a-f0-9]{64}$/i.test(p.serverCid) || !p.challenge || !['nonce', 'accountId', 'workspaceId'].every(k => /^[\w-]{43}$/.test(String(p.challenge[k]))) || p.challenge.expiresAt <= Date.now() || p.challenge.expiresAt > Date.now() + 16 * 60000)
|
|
28
|
+
if (p.version !== 1 || !['https://app.ours.network', 'https://app.ours-tunnel.com'].includes(p.appOrigin) || !/^[a-z0-9][a-z0-9-]{2,60}\.ours-tunnel\.com$/.test(p.hostname) || !/^[a-z0-9-]{2,30}@[a-z0-9-]{2,30}$/.test(p.rootName) || !/^[a-f0-9]{64}$/i.test(p.serverCid) || !p.challenge || !['nonce', 'accountId', 'workspaceId'].every(k => /^[\w-]{43}$/.test(String(p.challenge[k]))) || (!options.allowExpired && p.challenge.expiresAt <= Date.now()) || !Number.isSafeInteger(p.challenge.expiresAt) || p.challenge.expiresAt > Date.now() + 16 * 60000)
|
|
29
29
|
throw Error('Workspace payload is invalid or expired');
|
|
30
|
-
for (const k of ['connectorToken', 'invitation', 'name', 'surname'])
|
|
30
|
+
for (const k of ['connectorToken', 'invitation', 'name', 'surname']) {
|
|
31
|
+
if (k === 'connectorToken' && p[k] === undefined)
|
|
32
|
+
continue;
|
|
31
33
|
if (typeof p[k] !== 'string' || !p[k] || p[k].length > 8192 || /[\x00-\x1f\x7f]/.test(p[k]))
|
|
32
34
|
throw Error('Invalid workspace payload');
|
|
35
|
+
}
|
|
33
36
|
return p;
|
|
34
37
|
}
|
|
38
|
+
/** Credential-free renewal is allowed only on the host already holding this private connector. */
|
|
39
|
+
export function checkWorkspaceConnector(payload) {
|
|
40
|
+
if (payload.connectorToken !== undefined)
|
|
41
|
+
return;
|
|
42
|
+
const reject = () => { throw Error('This command renews an existing host without a connector credential. Restore its owned private connector file, or remove and re-create the workspace in the App before setting it up again'); };
|
|
43
|
+
const dir = join(stateRoot(), 'workspace');
|
|
44
|
+
let previous;
|
|
45
|
+
try {
|
|
46
|
+
previous = JSON.parse(readFileSync(join(dir, 'binding.json'), 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
reject();
|
|
50
|
+
}
|
|
51
|
+
if (previous.workspaceId !== payload.challenge.workspaceId || validateAccountOrigin(previous.appOrigin ?? 'https://app.ours.network') !== payload.appOrigin || (previous.serverCid && previous.serverCid.toUpperCase() !== payload.serverCid.toUpperCase()))
|
|
52
|
+
reject();
|
|
53
|
+
try {
|
|
54
|
+
const file = join(dir, 'connector'), stat = lstatSync(file);
|
|
55
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0 || stat.size > 8193 || !readFileSync(file, 'utf8').trim())
|
|
56
|
+
reject();
|
|
57
|
+
const tunnel = JSON.parse(readFileSync(join(dir, 'tunnel.json'), 'utf8'));
|
|
58
|
+
if (tunnel.origin !== `https://${payload.hostname}` || tunnel.tokenFile !== file)
|
|
59
|
+
reject();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
reject();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
35
65
|
const GENERATED_VERSION = 'ours.network/fleet/v2';
|
|
36
66
|
/** The rooms section tunnel setup writes for the enrolled Human root. */
|
|
37
67
|
const generatedRooms = (rootCid, inviteFile) => ({ rooms: { owner: { provider: 'messenger-server', expected_cid: rootCid, public_invite_file: inviteFile, role: 'Owner' }, defaults: { attach_owner: true, close_when_task_done: true } } });
|
|
@@ -131,12 +161,17 @@ async function asHumanRoot(attach, root, purpose, use) {
|
|
|
131
161
|
}
|
|
132
162
|
}
|
|
133
163
|
/** The signed workspace binding, sent by the Human root itself: the same command Messenger sends when it runs as the root. */
|
|
134
|
-
async function proveWithHumanRoot(attach, root, payload, hostWorkspaceId, contactWaitMs) {
|
|
164
|
+
async function proveWithHumanRoot(attach, root, payload, hostWorkspaceId, contactWaitMs, reuseContact = false) {
|
|
135
165
|
const serverCid = payload.serverCid.toUpperCase();
|
|
136
166
|
await asHumanRoot(attach, root, 'proof', async (client) => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
167
|
+
// Retirement may already have redeemed this one-time invitation. Its exact signed
|
|
168
|
+
// handoff authorizes the new challenge; do not consume the invitation again.
|
|
169
|
+
const ready = reuseContact && (await client.listContacts()).contacts.some(contact => same(contact.container_id, serverCid));
|
|
170
|
+
if (!ready) {
|
|
171
|
+
const peer = await client.addContact({ invite: payload.invitation });
|
|
172
|
+
if (peer.cid.toUpperCase() !== serverCid)
|
|
173
|
+
throw Error('Enrollment server identity mismatch');
|
|
174
|
+
}
|
|
140
175
|
const deadline = Date.now() + contactWaitMs;
|
|
141
176
|
while (!(await client.listContacts()).contacts.some(contact => contact.container_id.toUpperCase() === serverCid)) {
|
|
142
177
|
if (Date.now() >= deadline)
|
|
@@ -175,6 +210,8 @@ function managedRootOwner(configuration, rooms, root) {
|
|
|
175
210
|
* Reads only; an existing Owner is compared with this host's own Messenger identity.
|
|
176
211
|
*/
|
|
177
212
|
export async function checkWorkspaceConfiguration(configuration = defaultConfigPath(), attach = attachOursClient) {
|
|
213
|
+
if (new WebAccessStore().read().mode === 'none')
|
|
214
|
+
throw Error('Workspace enrollment requires protected web access; enable pairing or password before setup');
|
|
178
215
|
const paths = preflightInitPaths(configuration);
|
|
179
216
|
if (!paths.manifestExisted) {
|
|
180
217
|
if (paths.rootExisted)
|
|
@@ -182,6 +219,12 @@ export async function checkWorkspaceConfiguration(configuration = defaultConfigP
|
|
|
182
219
|
return;
|
|
183
220
|
}
|
|
184
221
|
const loaded = loadConfig(paths.configPath, { yamlMode: 'strict', deferredOwnerInviteFile: workspaceOwnerInvite() });
|
|
222
|
+
const inviteFile = loaded.rooms?.owner.public_invite_file || workspaceOwnerInvite();
|
|
223
|
+
if (existsSync(inviteFile)) {
|
|
224
|
+
const stat = lstatSync(inviteFile);
|
|
225
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0)
|
|
226
|
+
throw Error('Retained Owner invitation file must be owned and private');
|
|
227
|
+
}
|
|
185
228
|
if (!loaded.rooms)
|
|
186
229
|
return;
|
|
187
230
|
if (loaded.rooms.defaults?.attach_owner === false)
|
|
@@ -205,6 +248,7 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
|
|
|
205
248
|
const request = hostMessenger();
|
|
206
249
|
if (new WebAccessStore().read().mode === 'none')
|
|
207
250
|
throw Error('Workspace enrollment requires protected web access; enable pairing or password before setup');
|
|
251
|
+
checkWorkspaceConnector(payload);
|
|
208
252
|
const dir = join(stateRoot(), 'workspace');
|
|
209
253
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
210
254
|
const appOrigin = validateAccountOrigin(payload.appOrigin);
|
|
@@ -212,10 +256,10 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
|
|
|
212
256
|
let migrating = false;
|
|
213
257
|
if (existsSync(previousFile)) {
|
|
214
258
|
const previous = JSON.parse(readFileSync(previousFile, 'utf8'));
|
|
215
|
-
if (previous.workspaceId !== payload.challenge.workspaceId)
|
|
259
|
+
if (previous.workspaceId !== payload.challenge.workspaceId && options.replacingWorkspace !== previous.workspaceId)
|
|
216
260
|
throw Error('This host is already associated with another workspace');
|
|
217
261
|
const priorOrigin = validateAccountOrigin(previous.appOrigin ?? 'https://app.ours.network');
|
|
218
|
-
migrating = priorOrigin !== appOrigin;
|
|
262
|
+
migrating = priorOrigin !== appOrigin && options.replacingWorkspace !== previous.workspaceId;
|
|
219
263
|
if (migrating && !options.migrateAppOrigin)
|
|
220
264
|
throw Error('Account origin change requires explicit --migrate-app-origin and a fresh setup payload');
|
|
221
265
|
if (migrating && previous.serverCid && previous.serverCid.toUpperCase() !== payload.serverCid.toUpperCase())
|
|
@@ -233,7 +277,7 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
|
|
|
233
277
|
throw Error('Bound Messenger identity unavailable');
|
|
234
278
|
// An installation that ran Messenger as the Human root and now runs it as the person's own identity keeps its managed Owner entry, moved to that identity.
|
|
235
279
|
const moved = Boolean(loaded.rooms) && !lost && !!identity.root && !same(loaded.rooms.owner.expected_cid, identity.cid) && managedRootOwner(resolve(configuration), loaded.rooms, identity.root)
|
|
236
|
-
&& JSON.parse(readFileSync(previousFile, 'utf8')).serverCid?.toUpperCase() === payload.serverCid.toUpperCase();
|
|
280
|
+
&& (options.replacingWorkspace !== undefined || JSON.parse(readFileSync(previousFile, 'utf8')).serverCid?.toUpperCase() === payload.serverCid.toUpperCase());
|
|
237
281
|
if (options.preserveProfile && identity.preserveProfile !== true)
|
|
238
282
|
throw Error('Existing-host setup requires Messenger profile-preservation support; update the host Messenger before retrying');
|
|
239
283
|
if (loaded.rooms && !lost && !moved && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
|
|
@@ -249,9 +293,32 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
|
|
|
249
293
|
devices.close();
|
|
250
294
|
const { name, surname, ...proofPayload } = payload;
|
|
251
295
|
let result, ownerCid;
|
|
296
|
+
// A replacement can stop after its one-time proof but before local files commit. Read the
|
|
297
|
+
// account's existing signed receipt first; a verified proof must not be sent a second time.
|
|
298
|
+
let proofAlreadyConfirmed = false;
|
|
299
|
+
if (options.replacingWorkspace) {
|
|
300
|
+
let response;
|
|
301
|
+
try {
|
|
302
|
+
response = await fetch(appOrigin + '/account-api/workspace-proof', { method: 'POST', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { Origin: appOrigin, 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload.challenge, hostWorkspaceId, rootCid: identity.root?.cid ?? identity.cid }) });
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
throw Error('Replacement proof status could not be confirmed. Run setup-tunnel --resume');
|
|
306
|
+
}
|
|
307
|
+
if (!response.ok) {
|
|
308
|
+
await response.body?.cancel();
|
|
309
|
+
throw Error('Replacement proof status was rejected; request a fresh App command with --replace-registration');
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
proofAlreadyConfirmed = (await response.json()).verified === true;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
throw Error('Invalid replacement proof receipt');
|
|
316
|
+
}
|
|
317
|
+
}
|
|
252
318
|
if (identity.root) {
|
|
253
319
|
// The Human root signs the binding; the person's own Messenger identity is the room Owner and issues the Owner invitation.
|
|
254
|
-
|
|
320
|
+
if (!proofAlreadyConfirmed)
|
|
321
|
+
await proveWithHumanRoot(attach, identity.root, payload, hostWorkspaceId, options.contactWaitMs ?? 10000, options.replacingWorkspace !== undefined);
|
|
255
322
|
const invite = await request('invites', { mode: 'public' });
|
|
256
323
|
if (typeof invite.blob !== 'string' || !invite.blob)
|
|
257
324
|
throw Error('Malformed workspace enrollment response');
|
|
@@ -261,6 +328,13 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
|
|
|
261
328
|
result = { submitted: true, rootCid: identity.root.cid, ownerInvite: invite.blob };
|
|
262
329
|
ownerCid = identity.cid;
|
|
263
330
|
}
|
|
331
|
+
else if (proofAlreadyConfirmed) {
|
|
332
|
+
const invite = await request('invites', { mode: 'public' });
|
|
333
|
+
if (typeof invite.blob !== 'string' || !invite.blob)
|
|
334
|
+
throw Error('Malformed workspace enrollment response');
|
|
335
|
+
result = { submitted: true, rootCid: identity.cid, ownerInvite: invite.blob };
|
|
336
|
+
ownerCid = identity.cid;
|
|
337
|
+
}
|
|
264
338
|
else {
|
|
265
339
|
result = await request('workspace/enroll', options.preserveProfile ? { ...proofPayload, hostWorkspaceId, preserveProfile: true } : { ...payload, hostWorkspaceId });
|
|
266
340
|
if (!result.submitted || !/^[a-f0-9]{64}$/i.test(result.rootCid) || typeof result.ownerInvite !== 'string')
|
|
@@ -303,7 +377,8 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
|
|
|
303
377
|
document.setIn(['rooms', 'owner', 'expected_cid'], ownerCid);
|
|
304
378
|
replaceFileAtomically(configuration, document.toString(), 0o600);
|
|
305
379
|
}
|
|
306
|
-
|
|
380
|
+
if (payload.connectorToken !== undefined)
|
|
381
|
+
replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
|
|
307
382
|
const origin = `https://${payload.hostname}`;
|
|
308
383
|
replaceFileAtomically(join(dir, 'tunnel.json'), JSON.stringify({ origin, tokenFile: join(dir, 'connector') }) + '\n', 0o600);
|
|
309
384
|
replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase(), ...(identity.root ? { proofRootCid: identity.root.cid } : {}) }) + '\n', 0o600);
|
|
@@ -371,3 +446,121 @@ export async function confirmWorkspaceTarget(payload, hostWorkspaceId, rootCid,
|
|
|
371
446
|
await configureWorkspacePort(payload, hostWorkspaceId, rootCid, port);
|
|
372
447
|
await removeEnrollmentContact().catch(cleanupFailed);
|
|
373
448
|
}
|
|
449
|
+
/** Retire exactly the original registration using the same signed Human root that bound it. */
|
|
450
|
+
export async function unregisterWorkspace(previous, payload, operationNonce, attach = attachOursClient, options = {}) {
|
|
451
|
+
const appOrigin = validateAccountOrigin(previous.appOrigin);
|
|
452
|
+
const messenger = hostMessenger();
|
|
453
|
+
const identity = await messengerIdentity(messenger, attach);
|
|
454
|
+
if (identity.preserveProfile !== true)
|
|
455
|
+
throw Error('Existing-host replacement requires Messenger profile-preservation support; existing local setup preserved');
|
|
456
|
+
const root = identity.root ?? await humanRoot(attach, identity.cid);
|
|
457
|
+
if (previous.proofRootCid && !same(root.cid, previous.proofRootCid))
|
|
458
|
+
throw Error('Original workspace proof root differs from this installation; existing local setup preserved');
|
|
459
|
+
const devices = new WorkspaceDeviceStore();
|
|
460
|
+
const hostWorkspaceId = devices.workspaceId;
|
|
461
|
+
devices.close();
|
|
462
|
+
if (hostWorkspaceId !== previous.hostWorkspaceId)
|
|
463
|
+
throw Error('Original workspace host ID differs from this installation; existing local setup preserved');
|
|
464
|
+
const deadlineField = options.operationExpiresAt === undefined ? {} : { operationExpiresAt: options.operationExpiresAt };
|
|
465
|
+
const receiptInput = { workspaceId: previous.workspaceId, hostWorkspaceId, operationNonce, rootCid: root.cid, ...deadlineField };
|
|
466
|
+
const request = options.request ?? fetch;
|
|
467
|
+
const receipt = async () => {
|
|
468
|
+
let response;
|
|
469
|
+
try {
|
|
470
|
+
response = await request(appOrigin + '/account-api/workspace-unregister-receipt', { method: 'POST', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { Origin: appOrigin, 'Content-Type': 'application/json' }, body: JSON.stringify(receiptInput) });
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
throw Error('Registration retirement could not be confirmed; local setup preserved. Run setup-tunnel --resume to retry');
|
|
474
|
+
}
|
|
475
|
+
if (response.status === 404) {
|
|
476
|
+
await response.body?.cancel();
|
|
477
|
+
return {};
|
|
478
|
+
}
|
|
479
|
+
if (!response.ok) {
|
|
480
|
+
await response.body?.cancel();
|
|
481
|
+
throw Error(`Registration retirement receipt failed (HTTP ${response.status}); local setup preserved`);
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
return await response.json();
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
throw Error('Invalid registration retirement receipt; local setup preserved');
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
let status = await receipt();
|
|
491
|
+
options.onReceipt?.(status);
|
|
492
|
+
if (options.checkOnly) {
|
|
493
|
+
if (status.deleted !== true && !(status.deleted === false && status.deadlineEnforced === true))
|
|
494
|
+
throw Error('Account service does not support replacing a registration yet; existing setup preserved');
|
|
495
|
+
// No invitation is consumed and no command is sent during preparation.
|
|
496
|
+
// A different server cannot be reconnected with the successor's invitation.
|
|
497
|
+
if (status.deleted !== true && status.rejected !== true && !same(previous.serverCid, payload.serverCid)) {
|
|
498
|
+
const check = async (client) => { if (!(await client.listContacts()).contacts.some(contact => same(contact.container_id, previous.serverCid)))
|
|
499
|
+
throw Error('Original enrollment server contact is unavailable; existing setup preserved'); };
|
|
500
|
+
if (identity.root)
|
|
501
|
+
await asHumanRoot(attach, root, 'retirement-preflight', check);
|
|
502
|
+
else
|
|
503
|
+
await check({ listContacts: () => messenger('contacts') });
|
|
504
|
+
}
|
|
505
|
+
return root.cid;
|
|
506
|
+
}
|
|
507
|
+
const requireRetryable = () => { if (status.rejected === true)
|
|
508
|
+
throw Error('The account service definitively rejected this retirement operation. Run setup-tunnel --abandon-replacement, then obtain a fresh App command and start again'); };
|
|
509
|
+
requireRetryable();
|
|
510
|
+
if (status.deleted !== true) {
|
|
511
|
+
if (payload.challenge.expiresAt <= Date.now())
|
|
512
|
+
throw Error('Replacement command expired before retirement; existing setup preserved. Obtain a fresh App command');
|
|
513
|
+
const retire = async (client) => {
|
|
514
|
+
const serverCid = previous.serverCid.toUpperCase();
|
|
515
|
+
let contacts = await client.listContacts();
|
|
516
|
+
if (!contacts.contacts.some(contact => same(contact.container_id, serverCid))) {
|
|
517
|
+
if (!same(payload.serverCid, serverCid))
|
|
518
|
+
throw Error('Original enrollment server contact is unavailable. Reconnect it or remove the registration in the original account first; existing local setup preserved');
|
|
519
|
+
const peer = await client.addContact({ invite: payload.invitation });
|
|
520
|
+
if (!same(peer.cid, serverCid))
|
|
521
|
+
throw Error('Original enrollment server identity mismatch; existing local setup preserved');
|
|
522
|
+
const deadline = Date.now() + (options.waitMs ?? 10000);
|
|
523
|
+
while (!(contacts = await client.listContacts()).contacts.some(contact => same(contact.container_id, serverCid))) {
|
|
524
|
+
if (Date.now() >= deadline)
|
|
525
|
+
throw Error('Original enrollment contact is not ready; local setup preserved. Run setup-tunnel --resume');
|
|
526
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const replacement = appOrigin === validateAccountOrigin(payload.appOrigin) && same(serverCid, payload.serverCid) && previous.workspaceId !== payload.challenge.workspaceId
|
|
530
|
+
? { workspaceId: payload.challenge.workspaceId, accountId: payload.challenge.accountId, nonce: payload.challenge.nonce } : undefined;
|
|
531
|
+
// Persist uncertainty before invoking the transport: a lost response must
|
|
532
|
+
// never make an in-flight retirement safe to abandon.
|
|
533
|
+
options.beforeSend?.();
|
|
534
|
+
const outcome = await client.sendCommand({ contact: serverCid, command: 'unregister-workspace', arguments: { type: 'ours.app.unregister-workspace.v1', workspaceId: previous.workspaceId, hostWorkspaceId, operationNonce, ...deadlineField, ...(replacement ? { replacement } : {}) } });
|
|
535
|
+
if (!('sent' in outcome) || !outcome.sent)
|
|
536
|
+
throw Error('Registration retirement was not sent; local setup preserved');
|
|
537
|
+
};
|
|
538
|
+
if (identity.root)
|
|
539
|
+
await asHumanRoot(attach, root, 'unregister', retire);
|
|
540
|
+
else {
|
|
541
|
+
// Messenger already holds the root lease. Its first-party endpoint signs
|
|
542
|
+
// as that bound root; acquiring a second daemon lease would fail.
|
|
543
|
+
await retire({
|
|
544
|
+
listContacts: () => messenger('contacts'),
|
|
545
|
+
addContact: args => messenger('contacts/add', args),
|
|
546
|
+
sendCommand: async (args) => {
|
|
547
|
+
const answer = await messenger('workspace/unregister', { serverCid: args.contact, rootCid: root.cid, ...args.arguments });
|
|
548
|
+
if (answer.submitted !== true || typeof answer.rootCid !== 'string' || !same(answer.rootCid, root.cid))
|
|
549
|
+
throw Error('Registration retirement answered for a different root; local setup preserved');
|
|
550
|
+
return { sent: true };
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const deadline = Date.now() + (options.waitMs ?? 30000);
|
|
556
|
+
while (status.deleted !== true || status.retired !== true) {
|
|
557
|
+
if (Date.now() >= deadline)
|
|
558
|
+
throw Error('Registration retirement is not confirmed; local setup preserved. Run setup-tunnel --resume with the same operation, or --abandon-replacement once the server confirms terminal rejection');
|
|
559
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
560
|
+
status = await receipt();
|
|
561
|
+
requireRetryable();
|
|
562
|
+
}
|
|
563
|
+
if (status.absent)
|
|
564
|
+
process.stderr.write('The account service has no record of the old registration. No old provider resources were deleted by this service.\n');
|
|
565
|
+
return root.cid;
|
|
566
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { enrollWorkspace, unregisterWorkspace, type AttachDaemonClient, type WorkspacePayload } from './workspace-enrollment.js';
|
|
2
|
+
export interface WorkspaceBinding {
|
|
3
|
+
workspaceId: string;
|
|
4
|
+
hostWorkspaceId: string;
|
|
5
|
+
appOrigin: string;
|
|
6
|
+
serverCid?: string;
|
|
7
|
+
proofRootCid?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function readWorkspaceBinding(): WorkspaceBinding | undefined;
|
|
10
|
+
export declare function sameWorkspaceBinding(previous: WorkspaceBinding, payload: WorkspacePayload): boolean;
|
|
11
|
+
/** A workspace ID is never retired to rerun or migrate that same workspace. */
|
|
12
|
+
export declare function requiresWorkspaceReplacement(previous: WorkspaceBinding, target: {
|
|
13
|
+
workspaceId: string;
|
|
14
|
+
appOrigin: string;
|
|
15
|
+
serverCid: string;
|
|
16
|
+
}, migrateAppOrigin?: boolean): boolean;
|
|
17
|
+
/** Ask before redeeming the one-use grant, whose destination is deliberately opaque. */
|
|
18
|
+
export declare function confirmWorkspaceReplacement(previous: WorkspaceBinding, explicit?: boolean, effects?: {
|
|
19
|
+
write?: (value: string) => void;
|
|
20
|
+
interactive?: boolean;
|
|
21
|
+
ask?: () => Promise<string>;
|
|
22
|
+
}): Promise<boolean>;
|
|
23
|
+
export interface PendingWorkspaceReplacement {
|
|
24
|
+
version: 1 | 2;
|
|
25
|
+
previous: WorkspaceBinding & {
|
|
26
|
+
serverCid: string;
|
|
27
|
+
};
|
|
28
|
+
payload: WorkspacePayload;
|
|
29
|
+
configuration: string;
|
|
30
|
+
operationNonce: string;
|
|
31
|
+
rootCid?: string;
|
|
32
|
+
retired?: boolean;
|
|
33
|
+
cleaned?: boolean;
|
|
34
|
+
dispatchAttempted?: boolean;
|
|
35
|
+
operationExpiresAt?: number;
|
|
36
|
+
}
|
|
37
|
+
export declare function saveWorkspaceReplacement(record: PendingWorkspaceReplacement): void;
|
|
38
|
+
export declare function readWorkspaceReplacement(): PendingWorkspaceReplacement | undefined;
|
|
39
|
+
export declare function clearWorkspaceReplacement(): void;
|
|
40
|
+
/** Explicit recovery after the user removes an unsuccessful successor in the App. */
|
|
41
|
+
export declare function discardRetiredWorkspaceReplacement(): void;
|
|
42
|
+
export declare function beginWorkspaceReplacement(previous: WorkspaceBinding, payload: WorkspacePayload, configuration: string, effects?: {
|
|
43
|
+
preflight?: typeof unregisterWorkspace;
|
|
44
|
+
attach?: AttachDaemonClient;
|
|
45
|
+
}): Promise<PendingWorkspaceReplacement>;
|
|
46
|
+
/** Abandon only after a private receipt confirms no deletion and dispatch was unsent or terminally rejected. */
|
|
47
|
+
export declare function abandonWorkspaceReplacement(effects?: {
|
|
48
|
+
inspect?: typeof unregisterWorkspace;
|
|
49
|
+
attach?: AttachDaemonClient;
|
|
50
|
+
}): Promise<void>;
|
|
51
|
+
/** A failed/expired one-time command can be renewed only for its pinned successor. */
|
|
52
|
+
export declare function refreshWorkspaceReplacement(record: PendingWorkspaceReplacement, payload: WorkspacePayload, configuration: string): void;
|
|
53
|
+
/** Only tunnel files and account-issued device capabilities belong to this registration. */
|
|
54
|
+
export declare function clearReplacedWorkspaceLocalState(): Promise<void>;
|
|
55
|
+
/** Persist every irreversible boundary; the original binding stays until enrollment writes its successor. */
|
|
56
|
+
export declare function finishWorkspaceReplacement(record: PendingWorkspaceReplacement, effects?: {
|
|
57
|
+
unregister?: typeof unregisterWorkspace;
|
|
58
|
+
inspect?: typeof unregisterWorkspace;
|
|
59
|
+
cleanup?: () => Promise<void>;
|
|
60
|
+
enroll?: typeof enrollWorkspace;
|
|
61
|
+
attach?: AttachDaemonClient;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
origin: string;
|
|
64
|
+
hostWorkspaceId: string;
|
|
65
|
+
rootCid: string;
|
|
66
|
+
}>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import { validateAccountOrigin } from './account-origin.js';
|
|
6
|
+
import { replaceFileAtomically } from './atomic-file.js';
|
|
7
|
+
import { stateRoot } from './paths.js';
|
|
8
|
+
import { decodeWorkspacePayload, enrollWorkspace, unregisterWorkspace, checkWorkspaceConnector } from './workspace-enrollment.js';
|
|
9
|
+
import { WorkspaceDeviceStore } from './web/workspace-devices.js';
|
|
10
|
+
import { webControlPath } from './web/control.js';
|
|
11
|
+
import { WebServiceManager } from './web/service.js';
|
|
12
|
+
const workspaceDir = () => join(stateRoot(), 'workspace');
|
|
13
|
+
const replacementPath = () => join(workspaceDir(), 'replacement.json');
|
|
14
|
+
const opaque = /^[\w-]{43}$/;
|
|
15
|
+
function binding(value) {
|
|
16
|
+
if (!value || !opaque.test(value.workspaceId) || !opaque.test(value.hostWorkspaceId) || (value.serverCid !== undefined && !/^[a-f0-9]{64}$/i.test(value.serverCid))
|
|
17
|
+
|| (value.proofRootCid !== undefined && !/^[a-f0-9]{64}$/i.test(value.proofRootCid)))
|
|
18
|
+
throw Error('Existing workspace binding is invalid; restore it before tunnel setup');
|
|
19
|
+
return { ...value, appOrigin: validateAccountOrigin(value.appOrigin ?? 'https://app.ours.network') };
|
|
20
|
+
}
|
|
21
|
+
export function readWorkspaceBinding() {
|
|
22
|
+
const file = join(workspaceDir(), 'binding.json');
|
|
23
|
+
if (!existsSync(file))
|
|
24
|
+
return;
|
|
25
|
+
let value;
|
|
26
|
+
try {
|
|
27
|
+
value = JSON.parse(readFileSync(file, 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw Error('Existing workspace binding is invalid');
|
|
31
|
+
}
|
|
32
|
+
return binding(value);
|
|
33
|
+
}
|
|
34
|
+
export function sameWorkspaceBinding(previous, payload) {
|
|
35
|
+
return previous.workspaceId === payload.challenge.workspaceId && previous.appOrigin === payload.appOrigin && (!previous.serverCid || previous.serverCid.toUpperCase() === payload.serverCid.toUpperCase());
|
|
36
|
+
}
|
|
37
|
+
/** A workspace ID is never retired to rerun or migrate that same workspace. */
|
|
38
|
+
export function requiresWorkspaceReplacement(previous, target, migrateAppOrigin = false) {
|
|
39
|
+
if (previous.workspaceId !== target.workspaceId) {
|
|
40
|
+
if (!previous.serverCid)
|
|
41
|
+
throw Error('Legacy registration has no recorded server identity. Rerun its own workspace setup before replacing it');
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
if (previous.serverCid && previous.serverCid.toUpperCase() !== target.serverCid.toUpperCase())
|
|
45
|
+
throw Error('Same-workspace setup must retain the enrollment server identity; existing setup preserved');
|
|
46
|
+
if (previous.appOrigin !== target.appOrigin && !migrateAppOrigin)
|
|
47
|
+
throw Error('Account origin change requires explicit --migrate-app-origin; existing setup preserved');
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
/** Ask before redeeming the one-use grant, whose destination is deliberately opaque. */
|
|
51
|
+
export async function confirmWorkspaceReplacement(previous, explicit = false, effects = {}) {
|
|
52
|
+
const write = effects.write ?? (value => { process.stderr.write(value); });
|
|
53
|
+
let hostname = '';
|
|
54
|
+
try {
|
|
55
|
+
const tunnel = JSON.parse(readFileSync(join(workspaceDir(), 'tunnel.json'), 'utf8'));
|
|
56
|
+
if (typeof tunnel.origin === 'string' && /^https:\/\/[a-z0-9][a-z0-9-]{2,60}\.ours-tunnel\.com$/.test(tunnel.origin))
|
|
57
|
+
hostname = ` (${new URL(tunnel.origin).hostname})`;
|
|
58
|
+
}
|
|
59
|
+
catch { }
|
|
60
|
+
write(`This host is registered to workspace ${previous.workspaceId}${hostname} at ${previous.appOrigin}.\nReplacing it removes the old account registration and revokes its linked devices. The account service retires tunnel resources it knows; a migrated database may have no old resources to delete. Installation identities, agents and data are preserved.\n`);
|
|
61
|
+
if (explicit)
|
|
62
|
+
return true;
|
|
63
|
+
if (!(effects.interactive ?? process.stdin.isTTY)) {
|
|
64
|
+
write('Existing setup preserved. Run in a terminal to confirm, or pass --replace-registration to confirm explicitly.\n');
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const ask = effects.ask ?? (async () => {
|
|
68
|
+
const terminal = createInterface({ input: process.stdin, output: process.stderr });
|
|
69
|
+
try {
|
|
70
|
+
return await terminal.question('Replace the existing registration if this command names another workspace? [y/N] ');
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
terminal.close();
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
let answer = '';
|
|
77
|
+
try {
|
|
78
|
+
answer = await ask();
|
|
79
|
+
}
|
|
80
|
+
catch { /* EOF, cancellation and input failure mean no consent. */ }
|
|
81
|
+
if (/^(y|yes)$/i.test(answer.trim()))
|
|
82
|
+
return true;
|
|
83
|
+
write('Existing setup preserved.\n');
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
export function saveWorkspaceReplacement(record) {
|
|
87
|
+
replaceFileAtomically(replacementPath(), JSON.stringify(record) + '\n', 0o600);
|
|
88
|
+
}
|
|
89
|
+
export function readWorkspaceReplacement() {
|
|
90
|
+
const file = replacementPath();
|
|
91
|
+
if (!existsSync(file))
|
|
92
|
+
return;
|
|
93
|
+
const stat = lstatSync(file);
|
|
94
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0 || stat.size > 65536)
|
|
95
|
+
throw Error('Unfinished replacement must be an owned private regular file');
|
|
96
|
+
let record;
|
|
97
|
+
try {
|
|
98
|
+
record = JSON.parse(readFileSync(file, 'utf8'));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw Error('Unfinished replacement record is invalid');
|
|
102
|
+
}
|
|
103
|
+
if (![1, 2].includes(record.version) || !opaque.test(record.operationNonce) || typeof record.configuration !== 'string' || resolve(record.configuration) !== record.configuration
|
|
104
|
+
|| (record.rootCid !== undefined && !/^[a-f0-9]{64}$/i.test(record.rootCid)) || (record.retired !== undefined && typeof record.retired !== 'boolean')
|
|
105
|
+
|| (record.cleaned !== undefined && typeof record.cleaned !== 'boolean') || (record.dispatchAttempted !== undefined && typeof record.dispatchAttempted !== 'boolean') || (record.version === 2 && (record.dispatchAttempted === undefined || (!Number.isSafeInteger(record.operationExpiresAt) || record.operationExpiresAt <= 0 || record.operationExpiresAt > Date.now() + 86400000 + 60000))) || (record.cleaned && !record.retired) || (record.retired && !record.rootCid))
|
|
106
|
+
throw Error('Unfinished replacement record is invalid');
|
|
107
|
+
record.previous = binding(record.previous);
|
|
108
|
+
if (!record.previous.serverCid)
|
|
109
|
+
throw Error('Unfinished replacement has no pinned server identity');
|
|
110
|
+
record.payload = decodeWorkspacePayload(Buffer.from(JSON.stringify(record.payload)).toString('base64url'), { allowExpired: true });
|
|
111
|
+
return record;
|
|
112
|
+
}
|
|
113
|
+
export function clearWorkspaceReplacement() { rmSync(replacementPath(), { force: true }); }
|
|
114
|
+
/** Explicit recovery after the user removes an unsuccessful successor in the App. */
|
|
115
|
+
export function discardRetiredWorkspaceReplacement() {
|
|
116
|
+
const record = readWorkspaceReplacement(), current = readWorkspaceBinding();
|
|
117
|
+
if (!record?.retired || !record.cleaned)
|
|
118
|
+
throw Error('Only a fully retired and locally cleaned replacement can be discarded; resume or safely abandon this operation');
|
|
119
|
+
if (!current || current.workspaceId !== record.previous.workspaceId || current.hostWorkspaceId !== record.previous.hostWorkspaceId || current.appOrigin !== record.previous.appOrigin || current.serverCid?.toUpperCase() !== record.previous.serverCid.toUpperCase())
|
|
120
|
+
throw Error('The successor binding was committed or the binding changed; resume its setup instead of discarding recovery');
|
|
121
|
+
clearWorkspaceReplacement();
|
|
122
|
+
}
|
|
123
|
+
function checkReplacementService(configuration) {
|
|
124
|
+
const manager = new WebServiceManager(), service = manager.readMetadata();
|
|
125
|
+
if (service?.configuration && resolve(service.configuration) !== resolve(configuration))
|
|
126
|
+
throw Error('Installed web service uses another configuration; existing setup preserved');
|
|
127
|
+
if (!service && existsSync(webControlPath()))
|
|
128
|
+
throw Error('Stop the foreground Fleet web service before replacing its registration; existing setup preserved');
|
|
129
|
+
}
|
|
130
|
+
export async function beginWorkspaceReplacement(previous, payload, configuration, effects = {}) {
|
|
131
|
+
checkWorkspaceConnector(payload);
|
|
132
|
+
if (!requiresWorkspaceReplacement(previous, { workspaceId: payload.challenge.workspaceId, appOrigin: payload.appOrigin, serverCid: payload.serverCid }))
|
|
133
|
+
throw Error('The same workspace must never be retired');
|
|
134
|
+
checkReplacementService(configuration);
|
|
135
|
+
const record = { version: 2, previous: previous, payload, configuration: resolve(configuration), operationNonce: randomBytes(32).toString('base64url'), dispatchAttempted: false, operationExpiresAt: Date.now() + 86400000 - 5 * 60000 };
|
|
136
|
+
record.rootCid = await (effects.preflight ?? unregisterWorkspace)(record.previous, payload, record.operationNonce, effects.attach, { checkOnly: true, operationExpiresAt: record.operationExpiresAt });
|
|
137
|
+
saveWorkspaceReplacement(record);
|
|
138
|
+
return record;
|
|
139
|
+
}
|
|
140
|
+
/** Abandon only after a private receipt confirms no deletion and dispatch was unsent or terminally rejected. */
|
|
141
|
+
export async function abandonWorkspaceReplacement(effects = {}) {
|
|
142
|
+
const record = readWorkspaceReplacement();
|
|
143
|
+
if (!record)
|
|
144
|
+
throw Error('There is no unfinished replacement');
|
|
145
|
+
if (record.retired || record.cleaned || record.version !== 2)
|
|
146
|
+
throw Error('Retirement may already have completed; resume this replacement instead of abandoning it');
|
|
147
|
+
const current = readWorkspaceBinding();
|
|
148
|
+
if (!current || current.workspaceId !== record.previous.workspaceId || current.hostWorkspaceId !== record.previous.hostWorkspaceId || current.appOrigin !== record.previous.appOrigin || current.serverCid?.toUpperCase() !== record.previous.serverCid.toUpperCase())
|
|
149
|
+
throw Error('Workspace binding changed; replacement was not abandoned');
|
|
150
|
+
let deleted, rejected = false, expired = false, deadlineEnforced = false, oldBindingPresent = false;
|
|
151
|
+
const root = await (effects.inspect ?? unregisterWorkspace)(record.previous, record.payload, record.operationNonce, effects.attach, { checkOnly: true, operationExpiresAt: record.operationExpiresAt, onReceipt: status => { deleted = status.deleted; rejected = status.rejected === true; expired = status.expired === true; deadlineEnforced = status.deadlineEnforced === true; oldBindingPresent = status.oldBindingPresent === true; } });
|
|
152
|
+
if (root.toUpperCase() !== record.rootCid?.toUpperCase() || deleted !== false || (record.dispatchAttempted && !(rejected && !expired && Date.now() <= record.operationExpiresAt || oldBindingPresent && deadlineEnforced && Date.now() > record.operationExpiresAt + 5 * 60000)))
|
|
153
|
+
throw Error('Retirement may already have been sent; run --resume or abandon only after the account service confirms terminal rejection');
|
|
154
|
+
clearWorkspaceReplacement();
|
|
155
|
+
}
|
|
156
|
+
/** A failed/expired one-time command can be renewed only for its pinned successor. */
|
|
157
|
+
export function refreshWorkspaceReplacement(record, payload, configuration) {
|
|
158
|
+
if (resolve(configuration) !== record.configuration)
|
|
159
|
+
throw Error('Replacement requires its original configuration');
|
|
160
|
+
if (payload.challenge.workspaceId !== record.payload.challenge.workspaceId || payload.challenge.accountId !== record.payload.challenge.accountId
|
|
161
|
+
|| payload.appOrigin !== record.payload.appOrigin || payload.serverCid.toUpperCase() !== record.payload.serverCid.toUpperCase()
|
|
162
|
+
|| payload.hostname !== record.payload.hostname)
|
|
163
|
+
throw Error('Unfinished replacement requires a fresh command for the same target workspace');
|
|
164
|
+
record.payload = payload.connectorToken === undefined && record.payload.connectorToken !== undefined ? { ...payload, connectorToken: record.payload.connectorToken } : payload;
|
|
165
|
+
saveWorkspaceReplacement(record);
|
|
166
|
+
}
|
|
167
|
+
/** Only tunnel files and account-issued device capabilities belong to this registration. */
|
|
168
|
+
export async function clearReplacedWorkspaceLocalState() {
|
|
169
|
+
const manager = new WebServiceManager();
|
|
170
|
+
if (manager.readMetadata())
|
|
171
|
+
await manager.stop();
|
|
172
|
+
const devices = new WorkspaceDeviceStore();
|
|
173
|
+
try {
|
|
174
|
+
devices.revokeAll();
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
devices.close();
|
|
178
|
+
}
|
|
179
|
+
for (const file of ['tunnel.json', 'connector', 'pending-setup.json'])
|
|
180
|
+
rmSync(join(workspaceDir(), file), { force: true });
|
|
181
|
+
}
|
|
182
|
+
/** Persist every irreversible boundary; the original binding stays until enrollment writes its successor. */
|
|
183
|
+
export async function finishWorkspaceReplacement(record, effects = {}) {
|
|
184
|
+
const current = readWorkspaceBinding();
|
|
185
|
+
if (!current || (current.workspaceId === record.previous.workspaceId ? (current.appOrigin !== record.previous.appOrigin || current.serverCid?.toUpperCase() !== record.previous.serverCid.toUpperCase() || current.hostWorkspaceId !== record.previous.hostWorkspaceId) : !sameWorkspaceBinding(current, record.payload)))
|
|
186
|
+
throw Error('Workspace binding changed during replacement; review it before continuing');
|
|
187
|
+
// A saved successor binding may predate a refreshed challenge. Enrollment
|
|
188
|
+
// checks the exact proof receipt: it reuses confirmed proof, or signs the new
|
|
189
|
+
// challenge, rather than assuming every challenge for this binding is done.
|
|
190
|
+
if (!record.retired) {
|
|
191
|
+
checkReplacementService(record.configuration);
|
|
192
|
+
if (record.version === 2 && Date.now() > record.operationExpiresAt + 5 * 60000) {
|
|
193
|
+
if (record.payload.challenge.expiresAt <= Date.now())
|
|
194
|
+
throw Error('Retirement deadline passed. Obtain a fresh App command for the same target and pass --replace-registration to continue');
|
|
195
|
+
let deleted, enforced = false;
|
|
196
|
+
const root = await (effects.inspect ?? unregisterWorkspace)(record.previous, record.payload, record.operationNonce, effects.attach, { checkOnly: true, operationExpiresAt: record.operationExpiresAt, onReceipt: status => { deleted = status.deleted; enforced = status.deadlineEnforced === true; } });
|
|
197
|
+
if (root.toUpperCase() !== record.rootCid?.toUpperCase())
|
|
198
|
+
throw Error('Original proof root changed; existing recovery preserved');
|
|
199
|
+
if (deleted === false && enforced) {
|
|
200
|
+
// The expired command cannot act. Existing consent covers this exact
|
|
201
|
+
// previous/successor pair, including signed absence after a lost receipt.
|
|
202
|
+
record.operationNonce = randomBytes(32).toString('base64url');
|
|
203
|
+
record.operationExpiresAt = Date.now() + 86400000 - 5 * 60000;
|
|
204
|
+
record.dispatchAttempted = false;
|
|
205
|
+
saveWorkspaceReplacement(record);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
record.rootCid = await (effects.unregister ?? unregisterWorkspace)(record.previous, record.payload, record.operationNonce, effects.attach, { operationExpiresAt: record.operationExpiresAt, beforeSend: () => { record.dispatchAttempted = true; saveWorkspaceReplacement(record); } });
|
|
209
|
+
record.retired = true;
|
|
210
|
+
saveWorkspaceReplacement(record);
|
|
211
|
+
}
|
|
212
|
+
if (!record.cleaned) {
|
|
213
|
+
await (effects.cleanup ?? clearReplacedWorkspaceLocalState)();
|
|
214
|
+
record.cleaned = true;
|
|
215
|
+
saveWorkspaceReplacement(record);
|
|
216
|
+
}
|
|
217
|
+
if (record.payload.challenge.expiresAt <= Date.now())
|
|
218
|
+
throw Error('Old registration was retired, but the replacement setup window expired. Get a fresh App command and pass --replace-registration; the retirement will not be repeated');
|
|
219
|
+
return (effects.enroll ?? enrollWorkspace)(record.payload, record.configuration, { preserveProfile: true, replacingWorkspace: record.previous.workspaceId, attach: effects.attach });
|
|
220
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Live voice managed-session boundary
|
|
2
|
+
|
|
3
|
+
App Live voice sends transcripts through existing ACP managed sessions and reads the actual agent conversation. Updated ACP and native Codex supervisors advertise `generation_bound_prompts`, `idle_bound_prompts` and `targeted_voice_interrupt`; `/api/v1/roles/:id/voice-capabilities` authenticates the browser and reports whether all are present.
|
|
4
|
+
|
|
5
|
+
`submit_voice_prompt` accepts `requireIdle: true`. Receipt replay precedes the idle check so retrying a lost acknowledgment does not create a second prompt or fail just because the first is running. New admission checks generation and idle/queue/tool/permission state inside the existing turn arbiter before queuing. Unrelated work is never interrupted to make room.
|
|
6
|
+
|
|
7
|
+
`interrupt_voice_v2` requires a command ID, expected session generation and exact prompt ID. A matching active prompt uses normal managed-session cancellation; a settled or different prompt is a harmless no-op. A stale generation is rejected. Receipts are idempotent and conflicting reuse of an interrupt command is rejected. The browser's `/interrupt` route selects this path only when the complete target is present, with no legacy unbound fallback.
|
|
8
|
+
|
|
9
|
+
Authenticated CSRF-protected `POST /api/v1/roles/:id/live-presence` takes `{ expectedSessionGeneration }`, validates the current generation before attaching `followConversation`, and streams NDJSON readiness/heartbeats. The HTTP connection owns one attended controller until disconnect. Close, generation change, auth expiry, slow consumer or attach failure detach it. The browser's ordinary workspace fetch supports this stream without provider credentials in a URL. Existing ACP permission expiry and controller detach grace remain in force.
|
|
10
|
+
|
|
11
|
+
The daemon/App changes and configuration instructions live in the corresponding `ours-app/docs/live-voice.md` and `ours-sdk` Live voice documentation. The daemon owns the OpenAI key and 60-second Realtime credential minting; Fleet never receives the provider key.
|
|
12
|
+
|
|
13
|
+
Coverage includes actual ACP/control-socket busy/retry/targeted-cancel tests, native Codex transport tests, authenticated presence-boundary tests and the App native browser/audio integration with separate Codex/Claude ACP protocol fixtures. Provider inference and physical-device acceptance are separate operator checks.
|