@ours.network/fleet 1.2.0-nightly.45 → 1.2.0-nightly.46

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 CHANGED
@@ -1856,6 +1856,58 @@ pipe with a 32KiB limit and refuses a terminal. Legacy v1 payloads, which contai
1856
1856
  long-lived scoped credentials, are accepted only through file/stdin, never as
1857
1857
  command arguments. Use only one payload transport.
1858
1858
 
1859
+ When this host already has a registration, Fleet inspects the one-use grant without
1860
+ consuming it. The same workspace reruns without a deletion prompt or retirement.
1861
+ An account-origin change for that workspace requires `--migrate-app-origin`; a
1862
+ changed server identity is refused. An older App without inspection support falls
1863
+ back to asking before redemption. An unavailable inspection service preserves the
1864
+ grant and setup. Fleet verifies the redeemed destination against the inspection.
1865
+
1866
+ For another workspace, setup describes the existing workspace, tunnel hostname and
1867
+ account origin and asks `[y/N]`. No answer or `no` preserves the setup and grant; a
1868
+ noninteractive caller must pass `--replace-registration`, otherwise it exits with
1869
+ an error. A legacy binding without a server identity must rerun its own workspace
1870
+ first. The original Human root signs retirement. Fleet waits for the account's
1871
+ durable cleanup receipt before stopping the old service and revoking its linked
1872
+ devices. If a migrated database has no old registration, the signed command confirms
1873
+ absence; the service has no old provider resources to delete. Installation identities,
1874
+ profile, agents, history, stable host ID and local pairing are preserved.
1875
+
1876
+ Replacement recovery is saved in an owned private `workspace/replacement.json`
1877
+ after root, host, service and server-contact preconditions pass. The App must
1878
+ explicitly support deadline-enforced retirement; an older App without that
1879
+ capability preserves the existing setup. `setup-tunnel --resume` retries its nonce
1880
+ and saved operation deadline (24 hours less a five-minute clock margin).
1881
+ If the enrollment challenge expires, obtain a fresh App command for the same target
1882
+ and pass `--replace-registration`; completed retirement is retained. The old server
1883
+ identity is pinned: only its own invitation can reconnect it.
1884
+
1885
+ `setup-tunnel --abandon-replacement` removes the private recovery payload only when
1886
+ a fresh private receipt confirms no deletion and the operation was never dispatched
1887
+ or was terminally rejected. A lost transport response remains uncertain. Expired
1888
+ operations require a fresh receipt proving the old binding still exists after the
1889
+ deadline plus five minutes and an explicit server guarantee that the deadline is enforced;
1890
+ older recovery records without that guarantee must be resumed. Abandoning preserves
1891
+ the existing binding, connector and installation data. A terminally rejected operation
1892
+ requires abandonment and a new App command, rather than reusing its nonce.
1893
+ After that deadline, a fresh command for the same target can also resume with a
1894
+ new operation nonce after checking the enforced expiry. This confirms signed
1895
+ absence if an old successful receipt was lost and later pruned.
1896
+
1897
+ If the App accepted the successor proof before Fleet wrote its local binding,
1898
+ a fresh command for that target retains the original private connector from the
1899
+ recovery record. If that successor cannot be recovered, remove it in the App,
1900
+ then use `setup-tunnel --discard-retired-replacement` and a fresh App command.
1901
+ Discard is allowed only after retirement and local cleanup, while the old local
1902
+ binding remains. It removes only the recovery record. This host has no working
1903
+ registration until the new setup completes; the old service was stopped and its
1904
+ linked devices revoked.
1905
+
1906
+ Renewal commands contain no existing live connector credential. Fleet keeps the
1907
+ current private connector byte for byte, and accepts renewal only for its recorded
1908
+ workspace, server, origin and tunnel hostname. Restore a missing or unsafe connector,
1909
+ or remove and re-create that workspace in the App before setting it up again.
1910
+
1859
1911
  After service and signed tunnel-target confirmation, setup prints both a QR and
1860
1912
  ordinary private single-use connection code. The App checks readiness automatically
1861
1913
  and immediately opens the code field. If setup or code output fails after binding,
@@ -1,3 +1,4 @@
1
+ import { retireNotificationTarget } from '../notifications/target-cleanup.js';
1
2
  import { cpSync, existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
2
3
  import { basename, dirname, join } from 'node:path';
3
4
  import { loadConfig, ROLE_NAME_RE } from '../config.js';
@@ -87,6 +88,7 @@ export class RoleRemovalService {
87
88
  await this.options.ops.backend.uninstall(preview.role);
88
89
  if (existsSync(sourceState))
89
90
  renameSync(sourceState, join(recoveryPath, 'orphan-state'));
91
+ await retireNotificationTarget(`/fleet/chats?chat=${encodeURIComponent(preview.role)}`).catch(() => { });
90
92
  this.options.ops.log(`removed orphaned backend/state '${preview.role}'`);
91
93
  }
92
94
  return { ...preview, removed: true, recoveryPath };
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.45",
3
- "buildId": "9af6d0fa4b7e",
4
- "commit": "f59c69b6662ae09b5e72ea2c4c0a958729e1928f",
2
+ "version": "1.2.0-nightly.46",
3
+ "buildId": "a75b12e57308",
4
+ "commit": "f608ced10209f4b9ecf9dc9b573d8cab46136a68",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-04T12:42:35.826Z",
6
+ "builtAt": "2026-10-04T23:11:11.430Z",
7
7
  "capabilities": [
8
8
  "managed-cli.setup-v1",
9
9
  "workspace.enroll.preserve-profile-v1",
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { beginWorkspaceReplacement, abandonWorkspaceReplacement, discardRetiredWorkspaceReplacement, clearWorkspaceReplacement, confirmWorkspaceReplacement, finishWorkspaceReplacement, readWorkspaceBinding, readWorkspaceReplacement, requiresWorkspaceReplacement, refreshWorkspaceReplacement } from './workspace-replacement.js';
2
3
  import { completeTunnelSetup } from './tunnel-setup-completion.js';
3
- import { readTunnelSetupFile, readTunnelSetupStdin, readTunnelSetupArgument, redeemTunnelSetup } from './tunnel-setup-input.js';
4
+ import { readTunnelSetupFile, readTunnelSetupStdin, readTunnelSetupArgument, redeemTunnelSetup, inspectTunnelSetup, verifyTunnelSetupTarget } from './tunnel-setup-input.js';
4
5
  import { ensureMinimalSetup } from './minimal-setup.js';
5
- import { confirmWorkspaceTarget } from './workspace-enrollment.js';
6
+ import { confirmWorkspaceTarget, checkWorkspaceConnector } from './workspace-enrollment.js';
6
7
  import { clearPendingTunnelSetup, readPendingTunnelSetup, requiredPortAfterExpiry, savePendingTunnelSetup, setupTunnelPreflight } from './setup-tunnel-preflight.js';
7
8
  import { availableWebPort } from './web/available-port.js';
8
9
  import QRCode from 'qrcode';
@@ -1609,9 +1610,23 @@ webCommand.command('uninstall').description('stop and uninstall the owner web se
1609
1610
  });
1610
1611
  async function workspaceEnrollmentCommand(opts, setupTunnel = false, argument) {
1611
1612
  try {
1613
+ if (opts.discardRetiredReplacement) {
1614
+ if (!setupTunnel || argument !== undefined || opts.file || opts.stdin || opts.check || opts.resume || opts.migrateAppOrigin || opts.replaceRegistration || opts.configuration || opts.abandonReplacement)
1615
+ throw Error('--discard-retired-replacement takes no payload or other setup options');
1616
+ discardRetiredWorkspaceReplacement();
1617
+ process.stdout.write('Retired replacement recovery discarded. This host has no working registration: the old service was stopped and linked devices revoked. After removing the failed successor in the App, use a fresh command to complete a new registration. Installation data and the old local binding are preserved.\n');
1618
+ return;
1619
+ }
1620
+ if (opts.abandonReplacement) {
1621
+ if (!setupTunnel || argument !== undefined || opts.file || opts.stdin || opts.check || opts.resume || opts.migrateAppOrigin || opts.replaceRegistration || opts.configuration)
1622
+ throw Error('--abandon-replacement takes no payload or other setup options');
1623
+ await abandonWorkspaceReplacement();
1624
+ process.stdout.write('Unsent or rejected replacement abandoned; existing local setup preserved. Obtain a fresh App command for another setup.\n');
1625
+ return;
1626
+ }
1612
1627
  if (setupTunnel && opts.check && opts.resume)
1613
1628
  throw Error('Use either --check or --resume');
1614
- if (setupTunnel && (opts.check || opts.resume) && (argument !== undefined || opts.file || opts.stdin || opts.migrateAppOrigin))
1629
+ if (setupTunnel && (opts.check || opts.resume) && (argument !== undefined || opts.file || opts.stdin || opts.migrateAppOrigin || opts.replaceRegistration))
1615
1630
  throw Error('--check and --resume take no payload or migration flag');
1616
1631
  if (setupTunnel && opts.check) {
1617
1632
  await setupTunnelPreflight();
@@ -1621,10 +1636,22 @@ async function workspaceEnrollmentCommand(opts, setupTunnel = false, argument) {
1621
1636
  }
1622
1637
  let pending;
1623
1638
  if (setupTunnel && opts.resume) {
1624
- pending = readPendingTunnelSetup();
1625
- if (opts.configuration && resolvePath(opts.configuration) !== pending.configuration)
1626
- throw Error(`This setup started with configuration ${pending.configuration}; resume it without --configuration`);
1627
- await setupTunnelPreflight();
1639
+ const replacement = readWorkspaceReplacement();
1640
+ if (replacement) {
1641
+ if (opts.configuration && resolvePath(opts.configuration) !== replacement.configuration)
1642
+ throw Error('Resume replacement with its original configuration');
1643
+ await setupTunnelPreflight();
1644
+ const result = await finishWorkspaceReplacement(replacement);
1645
+ pending = { appOrigin: replacement.payload.appOrigin, origin: result.origin, hostWorkspaceId: result.hostWorkspaceId, rootCid: result.rootCid, challenge: replacement.payload.challenge, configuration: replacement.configuration };
1646
+ savePendingTunnelSetup(pending);
1647
+ clearWorkspaceReplacement();
1648
+ }
1649
+ else {
1650
+ pending = readPendingTunnelSetup();
1651
+ if (opts.configuration && resolvePath(opts.configuration) !== pending.configuration)
1652
+ throw Error(`This setup started with configuration ${pending.configuration}; resume it without --configuration`);
1653
+ await setupTunnelPreflight();
1654
+ }
1628
1655
  }
1629
1656
  else {
1630
1657
  if (argument === undefined && !opts.file && !opts.stdin)
@@ -1634,18 +1661,49 @@ async function workspaceEnrollmentCommand(opts, setupTunnel = false, argument) {
1634
1661
  if (opts.stdin && process.stdin.isTTY)
1635
1662
  throw Error('--stdin requires a pipe; use an owned private --file instead');
1636
1663
  const input = setupTunnel ? (argument !== undefined ? readTunnelSetupArgument(argument) : opts.stdin ? await readTunnelSetupStdin() : readTunnelSetupFile(opts.file)) : undefined;
1664
+ const previous = setupTunnel ? readWorkspaceBinding() : undefined;
1665
+ const replacement = setupTunnel ? readWorkspaceReplacement() : undefined;
1666
+ if (replacement && !opts.replaceRegistration)
1667
+ throw Error('An unfinished replacement exists. Run setup-tunnel --resume, use a fresh command for the same target with --replace-registration, or --abandon-replacement when retirement was never sent or definitively rejected. If retirement and cleanup completed but the successor cannot be recovered, remove the successor in the App and use --discard-retired-replacement');
1668
+ if (replacement && resolvePath(opts.configuration ?? defaultConfigPath()) !== replacement.configuration)
1669
+ throw Error('Replacement requires its original configuration');
1670
+ const target = previous && !replacement ? await inspectTunnelSetup(input) : undefined;
1671
+ const different = previous && !replacement && (!target || requiresWorkspaceReplacement(previous, target, opts.migrateAppOrigin));
1672
+ if (different && !(await confirmWorkspaceReplacement(previous, opts.replaceRegistration))) {
1673
+ if (!process.stdin.isTTY)
1674
+ throw Error('Replacement confirmation required; existing setup and grant preserved');
1675
+ return;
1676
+ }
1637
1677
  if (setupTunnel) {
1638
1678
  await setupTunnelPreflight();
1639
1679
  await checkWorkspaceConfiguration(opts.configuration ?? defaultConfigPath());
1640
1680
  }
1641
- const payload = setupTunnel ? await redeemTunnelSetup(input) : readWorkspacePayload(opts.file);
1681
+ let payload = setupTunnel ? await redeemTunnelSetup(input) : readWorkspacePayload(opts.file);
1682
+ verifyTunnelSetupTarget(target, payload);
1683
+ const replace = previous && !replacement && requiresWorkspaceReplacement(previous, { workspaceId: payload.challenge.workspaceId, serverCid: payload.serverCid, appOrigin: payload.appOrigin }, opts.migrateAppOrigin);
1684
+ if (replacement) {
1685
+ refreshWorkspaceReplacement(replacement, payload, opts.configuration ?? defaultConfigPath());
1686
+ payload = replacement.payload;
1687
+ }
1688
+ checkWorkspaceConnector(payload);
1642
1689
  if (setupTunnel)
1643
1690
  await ensureMinimalSetup(opts.configuration ?? defaultConfigPath());
1644
- const result = await enrollWorkspace(payload, opts.configuration, { migrateAppOrigin: opts.migrateAppOrigin, preserveProfile: setupTunnel || opts.preserveProfile });
1691
+ let result;
1692
+ if (replacement) {
1693
+ result = await finishWorkspaceReplacement(replacement);
1694
+ }
1695
+ else if (replace) {
1696
+ const record = await beginWorkspaceReplacement(previous, payload, opts.configuration ?? defaultConfigPath());
1697
+ result = await finishWorkspaceReplacement(record);
1698
+ }
1699
+ else
1700
+ result = await enrollWorkspace(payload, opts.configuration, { migrateAppOrigin: opts.migrateAppOrigin, preserveProfile: setupTunnel || opts.preserveProfile });
1645
1701
  pending = { appOrigin: payload.appOrigin, origin: result.origin, hostWorkspaceId: result.hostWorkspaceId, rootCid: result.rootCid, challenge: payload.challenge, configuration: resolvePath(opts.configuration ?? defaultConfigPath()) };
1646
- // The proof is one-time: keep what the remaining steps need so they can be finished without a new command.
1647
- if (setupTunnel)
1702
+ // Keep both the replacement capability and the proof until the next recovery record is durable.
1703
+ if (setupTunnel) {
1648
1704
  savePendingTunnelSetup(pending);
1705
+ clearWorkspaceReplacement();
1706
+ }
1649
1707
  }
1650
1708
  try {
1651
1709
  await finishWorkspaceService(pending, opts.resume ? pending.configuration : opts.configuration, setupTunnel);
@@ -1739,7 +1797,10 @@ cOpt(program.command('setup-tunnel').argument('[payload]', 'minimal one-use v2 A
1739
1797
  .option('--file <path>', 'owned private App grant or legacy payload file (chmod 600)')
1740
1798
  .option('--stdin', 'read the private App grant or legacy payload from a pipe')
1741
1799
  .option('--check', 'only verify host prerequisites; changes nothing and needs no payload')
1742
- .option('--resume', 'finish a setup that stopped after this host was bound; needs no new payload')
1800
+ .option('--resume', 'finish an interrupted replacement or setup; needs no new payload')
1801
+ .option('--discard-retired-replacement', 'forget a failed successor after retirement and local cleanup; remove it in the App first')
1802
+ .option('--abandon-replacement', 'discard an unsent or definitively rejected replacement after checking its private receipt')
1803
+ .option('--replace-registration', 'confirm retirement of the existing account registration when the new one differs')
1743
1804
  .option('--migrate-app-origin', 'explicitly migrate the same workspace to its new account origin after verified signed proof')
1744
1805
  .action(async (payload, opts) => workspaceEnrollmentCommand(opts, true, payload));
1745
1806
  program.command('link-device').description('create a single-use workspace device connection code (expires in five minutes)')
@@ -10,6 +10,7 @@ export declare class FleetNotificationProducer {
10
10
  private readonly queues;
11
11
  private readonly timer;
12
12
  private readonly existing;
13
+ private cleanup?;
13
14
  constructor(roots: string[], stateDir: string, config: ProducerConfig, warn?: (line: string) => void);
14
15
  private names;
15
16
  poll(): void;
@@ -1,7 +1,42 @@
1
- import { closeSync, existsSync, lstatSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
1
+ import { roomsDir } from '../rooms-tasks/room-state.js';
2
+ import { drainNotificationTargetCleanup } from './target-cleanup.js';
3
+ import { closeSync, existsSync, lstatSync, openSync, readSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
4
  import { join } from 'node:path';
3
5
  import { createHash } from 'node:crypto';
4
6
  import { NotificationOutbox } from './outbox.js';
7
+ /** Unlike a UI inventory, unread classification must not treat unreadable records as absence. */
8
+ function taskRoleAssociations() {
9
+ const dir = roomsDir(), result = new Map();
10
+ let files;
11
+ try {
12
+ files = readdirSync(dir);
13
+ }
14
+ catch (error) {
15
+ if (error.code === 'ENOENT')
16
+ return result;
17
+ throw error;
18
+ }
19
+ for (const name of files.filter(name => name.endsWith('.json'))) {
20
+ const file = join(dir, name), stat = lstatSync(file);
21
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024)
22
+ throw Error('Unreadable room association');
23
+ const room = JSON.parse(readFileSync(file, 'utf8'));
24
+ if (!room || typeof room.room_id !== 'string' || !Array.isArray(room.member_seats))
25
+ throw Error('Invalid room association');
26
+ if (room.task_id === undefined)
27
+ continue;
28
+ if (typeof room.task_id !== 'string' || !/^[A-Za-z0-9_-]{1,120}$/.test(room.task_id))
29
+ throw Error('Invalid task association');
30
+ for (const seat of room.member_seats) {
31
+ if (typeof seat.role_name !== 'string')
32
+ throw Error('Invalid member association');
33
+ if (result.has(seat.role_name) && result.get(seat.role_name) !== room.task_id)
34
+ throw Error('Ambiguous task association');
35
+ result.set(seat.role_name, room.task_id);
36
+ }
37
+ }
38
+ return result;
39
+ }
5
40
  /** Read durable supervisor ledgers without restarting agents or attaching a
6
41
  * conversation controller. Each tick reads at most 256 KiB per role, from its
7
42
  * checkpoint; it never loads a complete transcript or requests model history. */
@@ -13,6 +48,7 @@ export class FleetNotificationProducer {
13
48
  queues = new Map();
14
49
  timer;
15
50
  existing = new Set();
51
+ cleanup;
16
52
  constructor(roots, stateDir, config, warn = () => { }) {
17
53
  this.roots = roots;
18
54
  this.stateDir = stateDir;
@@ -34,6 +70,49 @@ export class FleetNotificationProducer {
34
70
  }
35
71
  }
36
72
  poll() {
73
+ this.cleanup ??= drainNotificationTargetCleanup(this.config, undefined, undefined, { beforeDelete: async (url) => {
74
+ const name = new URL(url, 'https://ours.invalid').searchParams.get('chat');
75
+ if (!name || !/^[A-Za-z0-9_-]+$/.test(name))
76
+ return false;
77
+ // A recreated or retained role still has a valid target. Filesystem errors cannot prove removal.
78
+ for (const root of this.roots) {
79
+ try {
80
+ lstatSync(join(root, name));
81
+ return false;
82
+ }
83
+ catch (error) {
84
+ if (error.code !== 'ENOENT')
85
+ throw error;
86
+ }
87
+ }
88
+ for (const root of this.roots) {
89
+ const roleDir = join(root, name), queue = this.queues.get(roleDir);
90
+ if (queue) {
91
+ await queue.retire();
92
+ this.queues.delete(roleDir);
93
+ }
94
+ else {
95
+ const file = join(this.stateDir, createHash('sha256').update(roleDir).digest('hex') + '.json');
96
+ if (existsSync(file)) {
97
+ const queue = new NotificationOutbox(file, this.config, async () => { throw Error('retired target'); });
98
+ try {
99
+ await queue.retire();
100
+ }
101
+ finally {
102
+ await queue.close();
103
+ }
104
+ }
105
+ }
106
+ }
107
+ return true;
108
+ } }).catch(() => { }).finally(() => { this.cleanup = undefined; });
109
+ let taskRoles;
110
+ try {
111
+ taskRoles = taskRoleAssociations();
112
+ }
113
+ catch {
114
+ this.warn('Notification task association unavailable; delivering unclassified events');
115
+ }
37
116
  for (const root of this.roots)
38
117
  for (const roleId of this.names(root)) {
39
118
  const roleDir = join(root, roleId), dir = join(roleDir, '.conversation');
@@ -108,7 +187,7 @@ export class FleetNotificationProducer {
108
187
  if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
109
188
  current.messages[prompt] = event.messageId;
110
189
  if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
111
- if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` }))
190
+ if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(taskRoles ? { taskId: taskRoles.get(roleId) ?? null } : {}), url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` }))
112
191
  break;
113
192
  current.prompts = current.prompts.filter(p => p !== prompt);
114
193
  delete current.messages[prompt];
@@ -125,5 +204,5 @@ export class FleetNotificationProducer {
125
204
  }
126
205
  }
127
206
  async drain() { await Promise.all([...this.queues.values()].map(q => q.drain())); }
128
- async close() { clearInterval(this.timer); await Promise.all([...this.queues.values()].map(q => q.close())); }
207
+ async close() { clearInterval(this.timer); await this.cleanup; await Promise.all([...this.queues.values()].map(q => q.close())); }
129
208
  }
@@ -3,6 +3,7 @@ export interface NotificationInput {
3
3
  title: string;
4
4
  body: string;
5
5
  url: string;
6
+ taskId?: string | null;
6
7
  }
7
8
  export interface ProducerConfig {
8
9
  /** Service base URL. With a gateway credential it is the server gateway's notifications route. */
@@ -29,6 +30,7 @@ export declare class NotificationOutbox {
29
30
  cursor?: number;
30
31
  checkpoint?: unknown;
31
32
  private running?;
33
+ private retired;
32
34
  private timer;
33
35
  constructor(file: string, config: ProducerConfig, project: (value: unknown, id: string) => Promise<NotificationInput>, warn?: (message: string) => void);
34
36
  private persist;
@@ -37,6 +39,8 @@ export declare class NotificationOutbox {
37
39
  enqueue(id: string, value: unknown, cursor?: number | undefined): boolean;
38
40
  drain(): Promise<void>;
39
41
  private drainOnce;
42
+ /** Wait for the current send before clearing queued sends and retiring its target. */
43
+ retire(): Promise<void>;
40
44
  close(): Promise<void>;
41
45
  }
42
46
  export {};
@@ -66,6 +66,7 @@ export class NotificationOutbox {
66
66
  cursor;
67
67
  checkpoint;
68
68
  running;
69
+ retired = false;
69
70
  timer;
70
71
  constructor(file, config, project, warn = () => { }) {
71
72
  this.file = file;
@@ -106,6 +107,8 @@ export class NotificationOutbox {
106
107
  advance(cursor) { this.persist(this.entries, cursor); }
107
108
  saveCheckpoint(checkpoint) { this.persist(this.entries, this.cursor, checkpoint); }
108
109
  enqueue(id, value, cursor = this.cursor) {
110
+ if (this.retired)
111
+ return false;
109
112
  if (this.entries.some(e => e.id === id)) {
110
113
  if (cursor !== this.cursor)
111
114
  this.advance(cursor);
@@ -117,10 +120,14 @@ export class NotificationOutbox {
117
120
  return true;
118
121
  }
119
122
  drain() {
123
+ if (this.retired)
124
+ return Promise.resolve();
120
125
  return this.running ??= this.drainOnce().finally(() => { this.running = undefined; });
121
126
  }
122
127
  async drainOnce() {
123
128
  for (const entry of [...this.entries].slice(0, 32)) {
129
+ if (this.retired)
130
+ break;
124
131
  try {
125
132
  const value = entry.payload ?? await this.project(entry.value, entry.id);
126
133
  // Freeze the canonical projection before any network attempt. A crash
@@ -145,5 +152,7 @@ export class NotificationOutbox {
145
152
  }
146
153
  }
147
154
  }
155
+ /** Wait for the current send before clearing queued sends and retiring its target. */
156
+ async retire() { this.retired = true; clearInterval(this.timer); await this.running; this.persist([]); }
148
157
  async close() { clearInterval(this.timer); await this.running; }
149
158
  }
@@ -0,0 +1,8 @@
1
+ import { type ProducerConfig } from './outbox.js';
2
+ /** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
3
+ export declare function retireNotificationTarget(url: string, config?: ProducerConfig | undefined, dir?: string, request?: typeof fetch): Promise<void>;
4
+ /** Persisted retries also run from the ordinary web notification producer after restart. */
5
+ export declare function drainNotificationTargetCleanup(config: ProducerConfig, dir?: string, request?: typeof fetch, options?: {
6
+ retain?: boolean;
7
+ beforeDelete?: (url: string) => Promise<boolean>;
8
+ }): Promise<void>;
@@ -0,0 +1,41 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { replaceFileAtomically } from '../atomic-file.js';
5
+ import { stateRoot } from '../paths.js';
6
+ import { producerConfig } from './outbox.js';
7
+ const cleanupDir = () => join(stateRoot(), 'notifications', 'removed-targets');
8
+ /** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
9
+ export async function retireNotificationTarget(url, config = producerConfig(), dir = cleanupDir(), request = fetch) {
10
+ if (!config)
11
+ return;
12
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
13
+ replaceFileAtomically(join(dir, createHash('sha256').update(url).digest('hex') + '.json'), JSON.stringify({ url }) + '\n', 0o600);
14
+ await drainNotificationTargetCleanup(config, dir, request, { retain: true });
15
+ }
16
+ /** Persisted retries also run from the ordinary web notification producer after restart. */
17
+ export async function drainNotificationTargetCleanup(config, dir = cleanupDir(), request = fetch, options = {}) {
18
+ if (!existsSync(dir))
19
+ return;
20
+ for (const file of readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)).slice(0, 32)) {
21
+ try {
22
+ const path = join(dir, file), value = JSON.parse(readFileSync(path, 'utf8'));
23
+ if (typeof value.url !== 'string' || !value.url.startsWith('/fleet/chats?'))
24
+ continue;
25
+ if (options.beforeDelete && !(await options.beforeDelete(value.url))) {
26
+ rmSync(path, { force: true });
27
+ continue;
28
+ }
29
+ const response = await request(config.origin + '/api/v1/delete-target', { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
30
+ headers: { ...(config.gatewayCredential ? { 'X-Ours-Api-Token': config.gatewayCredential, 'X-Ours-Notifications-Producer': config.token } : { Authorization: `Bearer ${config.token}` }), 'Content-Type': 'application/json' }, body: JSON.stringify(value) });
31
+ await response.body?.cancel();
32
+ if (!response.ok)
33
+ break;
34
+ if (!options.retain)
35
+ rmSync(path, { force: true });
36
+ }
37
+ catch {
38
+ break; /* No credentials or response bodies are included in logs. */
39
+ }
40
+ }
41
+ }
package/dist/ops.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { retireNotificationTarget } from './notifications/target-cleanup.js';
1
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, unlinkSync, } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { randomUUID, createHash } from 'node:crypto';
@@ -257,6 +258,7 @@ export async function rmRole(cfg, name, deps) {
257
258
  rmSync(agentDir(name), { recursive: true, force: true });
258
259
  if (generatedSource && existsSync(role.sourceFile)) {
259
260
  unlinkSync(role.sourceFile);
261
+ await retireNotificationTarget(`/fleet/chats?chat=${encodeURIComponent(name)}`).catch(() => { });
260
262
  deps.log(`removed ${role.sourceFile}`);
261
263
  }
262
264
  deps.log(`removed '${name}' (its ours identity is left intact)`);
@@ -6,3 +6,14 @@ export declare function readTunnelSetupStdin(input?: AsyncIterable<Buffer | stri
6
6
  export declare function readTunnelSetupArgument(input: string): string;
7
7
  /** Redeem the grant by HTTPS POST; long-lived connector credentials remain off argv and URLs. */
8
8
  export declare function redeemTunnelSetup(input: string, request?: typeof fetch): Promise<WorkspacePayload>;
9
+ /** Inspect only public destination metadata; this never consumes a one-use grant. */
10
+ export declare function inspectTunnelSetup(input: string, request?: typeof fetch): Promise<{
11
+ workspaceId: string;
12
+ serverCid: string;
13
+ appOrigin: string;
14
+ } | undefined>;
15
+ export declare function verifyTunnelSetupTarget(target: {
16
+ workspaceId: string;
17
+ serverCid: string;
18
+ appOrigin: string;
19
+ } | undefined, payload: WorkspacePayload): void;
@@ -80,3 +80,45 @@ export async function redeemTunnelSetup(input, request = fetch) {
80
80
  throw Error('Tunnel setup response origin mismatch');
81
81
  return payload;
82
82
  }
83
+ /** Inspect only public destination metadata; this never consumes a one-use grant. */
84
+ export async function inspectTunnelSetup(input, request = fetch) {
85
+ let version;
86
+ try {
87
+ version = JSON.parse(Buffer.from(input.trim(), 'base64url').toString()).version;
88
+ }
89
+ catch { }
90
+ if (version === 1) {
91
+ const p = decodeWorkspacePayload(input);
92
+ return { workspaceId: p.challenge.workspaceId, serverCid: p.serverCid, appOrigin: p.appOrigin };
93
+ }
94
+ const grant = decodeTunnelSetupGrant(input);
95
+ let response;
96
+ try {
97
+ response = await request(grant.appOrigin + '/account-api/workspace-install-inspect', { method: 'POST', redirect: 'error', credentials: 'omit', signal: AbortSignal.timeout(15000), headers: { Origin: grant.appOrigin, 'Content-Type': 'application/json' }, body: JSON.stringify({ code: grant.code }) });
98
+ }
99
+ catch {
100
+ throw Error('Could not inspect the account setup command; existing setup and grant preserved');
101
+ }
102
+ if (response.status === 404 || response.status === 405) {
103
+ await response.body?.cancel();
104
+ return;
105
+ }
106
+ if (!response.ok) {
107
+ await response.body?.cancel();
108
+ throw Error('Setup command inspection failed; existing setup and grant preserved');
109
+ }
110
+ let target;
111
+ try {
112
+ target = await response.json();
113
+ }
114
+ catch {
115
+ throw Error('Invalid setup command inspection response');
116
+ }
117
+ if (!target || Object.keys(target).sort().join(',') !== 'serverCid,workspaceId' || typeof target.workspaceId !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(target.workspaceId) || typeof target.serverCid !== 'string' || !/^[a-f0-9]{64}$/i.test(target.serverCid))
118
+ throw Error('Invalid setup command inspection response');
119
+ return { ...target, appOrigin: grant.appOrigin };
120
+ }
121
+ export function verifyTunnelSetupTarget(target, payload) {
122
+ if (target && (target.workspaceId !== payload.challenge.workspaceId || target.serverCid.toUpperCase() !== payload.serverCid.toUpperCase() || target.appOrigin !== payload.appOrigin))
123
+ throw Error('Redeemed setup command differs from inspected destination; existing setup preserved');
124
+ }
@@ -25,5 +25,7 @@ export declare class WorkspaceDeviceStore {
25
25
  valid(id: string): boolean;
26
26
  list(): WorkspaceDevice[];
27
27
  revoke(id: string): void;
28
+ /** Retire account links without changing the installation's stable host ID or other data. */
29
+ revokeAll(): void;
28
30
  close(): void;
29
31
  }
@@ -80,5 +80,18 @@ export class WorkspaceDeviceStore {
80
80
  valid(id) { return Boolean(this.db.prepare('SELECT id FROM devices WHERE id=? AND revokedAt IS NULL AND expiresAt>?').get(id, this.now())); }
81
81
  list() { return this.db.prepare('SELECT id,label,createdAt,lastUsedAt,expiresAt,revokedAt FROM devices ORDER BY createdAt DESC LIMIT 256').all().map(row => ({ id: String(row.id), label: String(row.label), createdAt: Number(row.createdAt), lastUsedAt: Number(row.lastUsedAt), expiresAt: Number(row.expiresAt), revokedAt: row.revokedAt === null ? null : Number(row.revokedAt) })); }
82
82
  revoke(id) { this.db.prepare('UPDATE devices SET revokedAt=? WHERE id=? AND revokedAt IS NULL').run(this.now(), id); this.db.prepare('DELETE FROM enrollments WHERE issuer=?').run(id); }
83
+ /** Retire account links without changing the installation's stable host ID or other data. */
84
+ revokeAll() {
85
+ this.db.exec('BEGIN IMMEDIATE');
86
+ try {
87
+ this.db.prepare('UPDATE devices SET revokedAt=? WHERE revokedAt IS NULL').run(this.now());
88
+ this.db.exec('DELETE FROM enrollments');
89
+ this.db.exec('COMMIT');
90
+ }
91
+ catch (error) {
92
+ this.db.exec('ROLLBACK');
93
+ throw error;
94
+ }
95
+ }
83
96
  close() { this.db.close(); }
84
97
  }
@@ -6,7 +6,7 @@ export interface WorkspacePayload {
6
6
  rootName: string;
7
7
  name: string;
8
8
  surname: string;
9
- connectorToken: string;
9
+ connectorToken?: string;
10
10
  invitation: string;
11
11
  serverCid: string;
12
12
  challenge: {
@@ -17,7 +17,11 @@ export interface WorkspacePayload {
17
17
  };
18
18
  }
19
19
  export declare function readWorkspacePayload(file: string): WorkspacePayload;
20
- export declare function decodeWorkspacePayload(input: string): WorkspacePayload;
20
+ export declare function decodeWorkspacePayload(input: string, options?: {
21
+ allowExpired?: boolean;
22
+ }): WorkspacePayload;
23
+ /** Credential-free renewal is allowed only on the host already holding this private connector. */
24
+ export declare function checkWorkspaceConnector(payload: WorkspacePayload): void;
21
25
  type DaemonClient = Pick<OursClient, 'listIdentities' | 'chooseIdentity' | 'addContact' | 'listContacts' | 'sendCommand' | 'removeContact' | 'releaseLease' | 'close'>;
22
26
  /** How tunnel setup reaches this host's daemon; replaced only by tests. */
23
27
  export type AttachDaemonClient = (options: AttachOursClientOptions) => Promise<DaemonClient>;
@@ -29,6 +33,7 @@ export declare function checkWorkspaceConfiguration(configuration?: string, atta
29
33
  export declare function enrollWorkspace(payload: WorkspacePayload, configuration?: string, options?: {
30
34
  migrateAppOrigin?: boolean;
31
35
  preserveProfile?: boolean;
36
+ replacingWorkspace?: string;
32
37
  attach?: AttachDaemonClient;
33
38
  contactWaitMs?: number;
34
39
  afterInvitation?: () => void;
@@ -51,4 +56,27 @@ export declare function configureWorkspacePort(payload: Pick<WorkspacePayload, '
51
56
  * so the setup can be finished later. A removal that fails is reported and does not undo the setup.
52
57
  */
53
58
  export declare function confirmWorkspaceTarget(payload: Pick<WorkspacePayload, 'appOrigin' | 'challenge'>, hostWorkspaceId: string, rootCid: string, port: number, cleanupFailed?: (error: unknown) => void): Promise<void>;
59
+ /** Retire exactly the original registration using the same signed Human root that bound it. */
60
+ export declare function unregisterWorkspace(previous: {
61
+ workspaceId: string;
62
+ hostWorkspaceId: string;
63
+ appOrigin: string;
64
+ serverCid: string;
65
+ proofRootCid?: string;
66
+ }, payload: WorkspacePayload, operationNonce: string, attach?: AttachDaemonClient, options?: {
67
+ request?: typeof fetch;
68
+ waitMs?: number;
69
+ checkOnly?: boolean;
70
+ beforeSend?: () => void;
71
+ operationExpiresAt?: number;
72
+ onReceipt?: (status: {
73
+ deleted?: boolean;
74
+ retired?: boolean;
75
+ absent?: boolean;
76
+ rejected?: boolean;
77
+ expired?: boolean;
78
+ deadlineEnforced?: boolean;
79
+ oldBindingPresent?: boolean;
80
+ }) => void;
81
+ }): Promise<string>;
54
82
  export {};
@@ -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
- const peer = await client.addContact({ invite: payload.invitation });
138
- if (peer.cid.toUpperCase() !== serverCid)
139
- throw Error('Enrollment server identity mismatch');
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
- await proveWithHumanRoot(attach, identity.root, payload, hostWorkspaceId, options.contactWaitMs ?? 10000);
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
- replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.45",
3
+ "version": "1.2.0-nightly.46",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",