@ours.network/fleet 1.2.0-nightly.71 → 1.2.0-nightly.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,5 @@
1
- export declare const accountOrigins: readonly ["https://app.ours.network", "https://app.ours-tunnel.com"];
1
+ /** Syntax validation for an authority already selected by trusted private state/configuration. */
2
2
  export declare function validateAccountOrigin(value: unknown): string;
3
+ /** Incoming grants/payloads must match explicit operator configuration before any network action. */
4
+ export declare function validateConfiguredAccountOrigin(value: unknown): string;
5
+ export declare function validateConfiguredWorkspaceHost(value: unknown): boolean;
@@ -1,6 +1,31 @@
1
- export const accountOrigins = ['https://app.ours.network', 'https://app.ours-tunnel.com'];
1
+ /** Syntax validation for an authority already selected by trusted private state/configuration. */
2
2
  export function validateAccountOrigin(value) {
3
- if (typeof value !== 'string' || !accountOrigins.some(origin => origin === value))
3
+ let url;
4
+ try {
5
+ if (typeof value !== 'string')
6
+ throw Error();
7
+ url = new URL(value);
8
+ }
9
+ catch {
10
+ throw Error('Unsupported account origin');
11
+ }
12
+ if (url.protocol !== 'https:' || url.origin !== value || url.username || url.password)
4
13
  throw Error('Unsupported account origin');
5
14
  return value;
6
15
  }
16
+ /** Incoming grants/payloads must match explicit operator configuration before any network action. */
17
+ export function validateConfiguredAccountOrigin(value) {
18
+ const configured = validateAccountOrigin(process.env.OURS_ACCOUNT_ORIGIN);
19
+ if (value !== configured)
20
+ throw Error('Unsupported account origin');
21
+ return configured;
22
+ }
23
+ const dnsName = (value) => typeof value === 'string' && value.length <= 253 && value.includes('.') && value.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
24
+ export function validateConfiguredWorkspaceHost(value) {
25
+ if (!dnsName(value))
26
+ return false;
27
+ if (process.env.OURS_WORKSPACE_HOST !== undefined)
28
+ return dnsName(process.env.OURS_WORKSPACE_HOST) && value === process.env.OURS_WORKSPACE_HOST;
29
+ const domain = process.env.OURS_WORKSPACE_DOMAIN;
30
+ return dnsName(domain) && value.endsWith('.' + domain) && !value.slice(0, -domain.length - 1).includes('.');
31
+ }
@@ -100,7 +100,7 @@ export class RoleRemovalService {
100
100
  renameSync(sourceState, join(recoveryPath, 'orphan-state'));
101
101
  removeReminderState(join(recoveryPath, 'orphan-state'));
102
102
  }
103
- await retireNotificationTarget(`/fleet/chats/agents/${encodeURIComponent(preview.role)}`).catch(() => { });
103
+ await retireNotificationTarget({ kind: 'agent', agentId: preview.role }).catch(() => { });
104
104
  this.options.ops.log(`removed orphaned backend/state '${preview.role}'`);
105
105
  }
106
106
  return { ...preview, removed: true, recoveryPath };
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.71",
3
- "buildId": "ec6069200698",
4
- "commit": "80250c2c6fbaa1818aeaaf2089eb9d396dee5d93",
2
+ "version": "1.2.0-nightly.72",
3
+ "buildId": "b6b76cb3faf6",
4
+ "commit": "912fae4f35543806656b5f0488fce830330681d5",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-17T21:21:29.368Z",
6
+ "builtAt": "2026-10-18T12:25:33.807Z",
7
7
  "capabilities": [
8
8
  "service.refresh-v1",
9
9
  "managed-cli.codex-reasoning-effort-v1",
@@ -1,6 +1,6 @@
1
1
  import { observeFleetResources } from '../application/resource-observer.js';
2
2
  import { roomsDir } from '../rooms-tasks/room-state.js';
3
- import { cleanupDir, drainNotificationTargetCleanup, notificationAgentId } from './target-cleanup.js';
3
+ import { cleanupDir, drainNotificationTargetCleanup, notificationAgentId, hasPendingNotificationTargetCleanup } from './target-cleanup.js';
4
4
  import { closeSync, existsSync, lstatSync, openSync, readSync, readFileSync, readdirSync, statSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
6
  import { createHash } from 'node:crypto';
@@ -79,8 +79,8 @@ export class FleetNotificationProducer {
79
79
  poll() {
80
80
  if (this.stopped)
81
81
  return;
82
- this.cleanup ??= drainNotificationTargetCleanup(this.config, undefined, undefined, { beforeDelete: async (url) => {
83
- const name = notificationAgentId(url);
82
+ this.cleanup ??= drainNotificationTargetCleanup(this.config, undefined, undefined, { beforeDelete: async (target) => {
83
+ const name = notificationAgentId(target);
84
84
  if (!name || !/^[A-Za-z0-9_-]+$/.test(name))
85
85
  return false;
86
86
  // A recreated or retained role still has a valid target. Filesystem errors cannot prove removal.
@@ -117,7 +117,7 @@ export class FleetNotificationProducer {
117
117
  } }).catch(() => { }).finally(() => {
118
118
  this.cleanup = undefined;
119
119
  try {
120
- if (readdirSync(cleanupDir()).some(name => /^[a-f0-9]{64}\.json$/.test(name)))
120
+ if (hasPendingNotificationTargetCleanup())
121
121
  this.resume(3000);
122
122
  }
123
123
  catch { }
@@ -142,7 +142,7 @@ export class FleetNotificationProducer {
142
142
  if (!outbox) {
143
143
  const key = createHash('sha256').update(roleDir).digest('hex');
144
144
  outbox = new NotificationOutbox(join(this.stateDir, key + '.json'), this.config, async (value, eventId) => ({ eventId, title: `${roleId}: agent completed`, body: 'Your agent finished responding. Open the chat to view the result.',
145
- url: `/fleet/chats/agents/${encodeURIComponent(roleId)}`, ...value }), this.warn);
145
+ ...value, target: { kind: 'agent', agentId: roleId } }), this.warn);
146
146
  if (!outbox.checkpoint) {
147
147
  const segment = this.existing.has(roleDir) ? segments.at(-1) : segments[0];
148
148
  outbox.saveCheckpoint({ segment, offset: this.existing.has(roleDir) ? statSync(join(dir, segment)).size : 0, prompts: [] });
@@ -205,7 +205,7 @@ export class FleetNotificationProducer {
205
205
  if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
206
206
  current.messages[prompt] = event.messageId;
207
207
  if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
208
- if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(taskRoles ? { taskId: taskRoles.get(roleId) ?? null } : {}), url: `/fleet/chats/agents/${encodeURIComponent(roleId)}#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` })) {
208
+ if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(taskRoles ? { taskId: taskRoles.get(roleId) ?? null } : {}), anchor: { kind: 'fleet-message', messageId: current.messages[prompt] ?? event.promptId } })) {
209
209
  this.resume(1000);
210
210
  break;
211
211
  }
@@ -1,10 +1,23 @@
1
+ export type NotificationTarget = {
2
+ kind: 'agent';
3
+ agentId: string;
4
+ } | {
5
+ kind: 'task' | 'task-deleted';
6
+ taskId: string;
7
+ };
1
8
  export interface NotificationInput {
2
9
  eventId: string;
3
10
  title: string;
4
11
  body: string;
5
- url: string;
12
+ target: NotificationTarget;
13
+ anchor?: {
14
+ kind: 'fleet-message';
15
+ messageId: string;
16
+ };
6
17
  taskId?: string | null;
7
18
  }
19
+ /** Only the closed typed contract is sent; any other payload is never sent or converted. */
20
+ export declare function isNotificationInput(value: unknown): value is NotificationInput;
8
21
  export interface ProducerConfig {
9
22
  /** Service base URL. With a gateway credential it is the server gateway's notifications route. */
10
23
  origin: string;
@@ -2,6 +2,38 @@ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, re
2
2
  import { dirname, join } from 'node:path';
3
3
  import { readPrivateFile } from '@ours.network/sdk/connector';
4
4
  import { readClientProfile } from '../client-profile.js';
5
+ const object = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
6
+ const closed = (value, keys) => Object.keys(value).every(key => keys.includes(key));
7
+ const domainId = (id) => typeof id === 'string' && /^[A-Za-z0-9_-]{1,120}$/.test(id);
8
+ const text = (value, max) => typeof value === 'string' && !!value.trim() && value.length <= max;
9
+ /** Only the closed typed contract is sent; any other payload is never sent or converted. */
10
+ export function isNotificationInput(value) {
11
+ if (!object(value) || !closed(value, ['eventId', 'title', 'body', 'target', 'anchor', 'taskId'])
12
+ || !text(value.eventId, 512) || !text(value.title, 160) || !text(value.body, 512) || !object(value.target))
13
+ return false;
14
+ const target = value.target;
15
+ if (target.kind === 'agent') {
16
+ if (!closed(target, ['kind', 'agentId']) || !domainId(target.agentId))
17
+ return false;
18
+ }
19
+ else if (target.kind === 'task' || target.kind === 'task-deleted') {
20
+ if (!closed(target, ['kind', 'taskId']) || !domainId(target.taskId) || (value.taskId !== undefined && value.taskId !== target.taskId))
21
+ return false;
22
+ }
23
+ else
24
+ return false;
25
+ if (value.taskId !== undefined && value.taskId !== null && !domainId(value.taskId))
26
+ return false;
27
+ return value.anchor === undefined || (target.kind === 'agent' && object(value.anchor)
28
+ && closed(value.anchor, ['kind', 'messageId']) && value.anchor.kind === 'fleet-message' && text(value.anchor.messageId, 1024) && !/[\x00-\x1f\x7f]/.test(value.anchor.messageId));
29
+ }
30
+ const has = (entry, key) => Object.hasOwn(entry, key);
31
+ // An entry is sent only when it is an object with a string ID and its frozen payload is the closed
32
+ // typed contract or, with no payload field at all, it carries the current version marker. Anything
33
+ // else, including null or scalar entries, is never read further, projected, sent or changed.
34
+ const supported = (entry) => object(entry) && typeof entry.id === 'string' && !has(entry, 'operation')
35
+ && (has(entry, 'payload') ? isNotificationInput(entry.payload) : entry.targetVersion === 1);
36
+ const same = (id) => (entry) => supported(entry) && entry.id === id;
5
37
  function defaultProfile() {
6
38
  try {
7
39
  return readClientProfile();
@@ -75,11 +107,12 @@ export class NotificationOutbox {
75
107
  this.warn = warn;
76
108
  mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
77
109
  const saved = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { entries: [] };
78
- this.entries = Array.isArray(saved) ? saved : saved.entries;
110
+ // Only the current state shape is read; anything else is refused and left unchanged on disk.
111
+ if (!saved || typeof saved !== 'object' || Array.isArray(saved) || !Array.isArray(saved.entries))
112
+ throw new Error('Unsupported notification outbox state');
113
+ this.entries = saved.entries;
79
114
  this.cursor = saved.cursor;
80
115
  this.checkpoint = saved.checkpoint;
81
- if (!Array.isArray(this.entries))
82
- throw new Error('invalid notification outbox');
83
116
  this.timer = setInterval(() => { void this.drain(); }, 3000);
84
117
  this.timer.unref();
85
118
  }
@@ -109,14 +142,14 @@ export class NotificationOutbox {
109
142
  enqueue(id, value, cursor = this.cursor) {
110
143
  if (this.retired)
111
144
  return false;
112
- if (this.entries.some(e => e.id === id)) {
145
+ if (this.entries.some(same(id))) {
113
146
  if (cursor !== this.cursor)
114
147
  this.advance(cursor);
115
148
  return true;
116
149
  }
117
- if (this.entries.length >= 2000)
150
+ if (this.entries.filter(supported).length >= 2000)
118
151
  return false;
119
- this.persist([...this.entries, { id, value }], cursor);
152
+ this.persist([...this.entries, { id, value, targetVersion: 1 }], cursor);
120
153
  return true;
121
154
  }
122
155
  drain() {
@@ -125,15 +158,23 @@ export class NotificationOutbox {
125
158
  return this.running ??= this.drainOnce().finally(() => { this.running = undefined; });
126
159
  }
127
160
  async drainOnce() {
128
- for (const entry of [...this.entries].slice(0, 32)) {
161
+ let attempts = 0;
162
+ for (const entry of [...this.entries]) {
163
+ if (!supported(entry))
164
+ continue;
165
+ if (attempts >= 32)
166
+ break;
129
167
  if (this.retired)
130
168
  break;
131
169
  try {
132
170
  const value = entry.payload ?? await this.project(entry.value, entry.id);
171
+ if (!isNotificationInput(value))
172
+ continue;
173
+ attempts++;
133
174
  // Freeze the canonical projection before any network attempt. A crash
134
175
  // after service acceptance must replay the identical dedupe payload.
135
176
  if (!entry.payload)
136
- this.persist(this.entries.map(e => e.id === entry.id ? { ...e, payload: value } : e));
177
+ this.persist(this.entries.map(e => same(entry.id)(e) ? { ...e, payload: value } : e));
137
178
  const response = await fetch(this.config.origin + '/api/v1/send', {
138
179
  method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
139
180
  headers: { ...(this.config.gatewayCredential
@@ -144,7 +185,7 @@ export class NotificationOutbox {
144
185
  await response.body?.cancel();
145
186
  if (!response.ok)
146
187
  throw new Error('notification service rejected event');
147
- this.persist(this.entries.filter(e => e.id !== entry.id));
188
+ this.persist(this.entries.filter(e => !same(entry.id)(e)));
148
189
  }
149
190
  catch {
150
191
  this.warn('Notification producer delivery pending; retrying from durable outbox');
@@ -153,6 +194,6 @@ export class NotificationOutbox {
153
194
  }
154
195
  }
155
196
  /** 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([]); }
197
+ async retire() { this.retired = true; clearInterval(this.timer); await this.running; this.persist(this.entries.filter(entry => !supported(entry))); }
157
198
  async close() { clearInterval(this.timer); await this.running; }
158
199
  }
@@ -1,11 +1,16 @@
1
1
  import { type ProducerConfig } from './outbox.js';
2
2
  export declare const cleanupDir: () => string;
3
- /** Only the canonical agent namespace can authorize role-target cleanup. */
4
- export declare function notificationAgentId(path: string): string | null;
3
+ export type AgentTarget = {
4
+ kind: 'agent';
5
+ agentId: string;
6
+ };
7
+ /** Only a closed agent identity authorizes this producer's role-target cleanup. */
8
+ export declare function notificationAgentId(target: unknown): string | null;
9
+ export declare function hasPendingNotificationTargetCleanup(dir?: string): boolean;
5
10
  /** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
6
- export declare function retireNotificationTarget(url: string, config?: ProducerConfig | undefined, dir?: string, request?: typeof fetch): Promise<void>;
11
+ export declare function retireNotificationTarget(target: AgentTarget, config?: ProducerConfig | undefined, dir?: string, request?: typeof fetch): Promise<void>;
7
12
  /** Persisted retries also run from the ordinary web notification producer after restart. */
8
13
  export declare function drainNotificationTargetCleanup(config: ProducerConfig, dir?: string, request?: typeof fetch, options?: {
9
14
  retain?: boolean;
10
- beforeDelete?: (url: string) => Promise<boolean>;
15
+ beforeDelete?: (target: AgentTarget) => Promise<boolean>;
11
16
  }): Promise<void>;
@@ -5,30 +5,35 @@ import { replaceFileAtomically } from '../atomic-file.js';
5
5
  import { stateRoot } from '../paths.js';
6
6
  import { producerConfig } from './outbox.js';
7
7
  export const cleanupDir = () => join(stateRoot(), 'notifications', 'removed-targets');
8
- /** Only the canonical agent namespace can authorize role-target cleanup. */
9
- export function notificationAgentId(path) {
10
- try {
11
- if (typeof path !== 'string' || /[\\\x00-\x20]|%(?:2f|5c|00|0[ad])/i.test(path))
12
- return null;
13
- const match = /^\/fleet\/chats\/agents\/([^/?#]+)(?:[?#]|$)/.exec(path);
14
- if (!match)
15
- return null;
16
- const url = new URL(path, 'https://ours.invalid');
17
- if (['source', 'chat', 'task', 'detail'].some(key => url.searchParams.has(key)))
18
- return null;
19
- const id = decodeURIComponent(match[1]);
20
- return /^[A-Za-z0-9_-]+$/.test(id) ? id : null;
21
- }
22
- catch {
8
+ /** Only a closed agent identity authorizes this producer's role-target cleanup. */
9
+ export function notificationAgentId(target) {
10
+ if (!target || typeof target !== 'object' || Array.isArray(target))
23
11
  return null;
24
- }
12
+ const value = target;
13
+ return Object.keys(value).every(key => ['kind', 'agentId'].includes(key)) && value.kind === 'agent'
14
+ && typeof value.agentId === 'string' && /^[A-Za-z0-9_-]{1,120}$/.test(value.agentId) ? value.agentId : null;
15
+ }
16
+ export function hasPendingNotificationTargetCleanup(dir = cleanupDir()) {
17
+ if (!existsSync(dir))
18
+ return false;
19
+ return readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)).some(file => {
20
+ try {
21
+ const value = JSON.parse(readFileSync(join(dir, file), 'utf8'));
22
+ return !!value && typeof value === 'object' && Object.keys(value).length === 1 && !!notificationAgentId(value.target);
23
+ }
24
+ catch (error) {
25
+ return !(error instanceof SyntaxError);
26
+ }
27
+ });
25
28
  }
26
29
  /** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
27
- export async function retireNotificationTarget(url, config = producerConfig(), dir = cleanupDir(), request = fetch) {
30
+ export async function retireNotificationTarget(target, config = producerConfig(), dir = cleanupDir(), request = fetch) {
28
31
  if (!config)
29
32
  return;
33
+ if (!notificationAgentId(target))
34
+ throw new Error('Invalid notification target');
30
35
  mkdirSync(dir, { recursive: true, mode: 0o700 });
31
- replaceFileAtomically(join(dir, createHash('sha256').update(url).digest('hex') + '.json'), JSON.stringify({ url }) + '\n', 0o600);
36
+ replaceFileAtomically(join(dir, createHash('sha256').update(JSON.stringify({ kind: 'agent', agentId: target.agentId })).digest('hex') + '.json'), JSON.stringify({ target }) + '\n', 0o600);
32
37
  await drainNotificationTargetCleanup(config, dir, request, { retain: true });
33
38
  }
34
39
  /** Persisted retries also run from the ordinary web notification producer after restart. */
@@ -39,12 +44,19 @@ export async function drainNotificationTargetCleanup(config, dir = cleanupDir(),
39
44
  for (const file of readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file))) {
40
45
  if (attempts >= 32)
41
46
  break;
47
+ const path = join(dir, file);
48
+ let value;
49
+ try {
50
+ value = JSON.parse(readFileSync(path, 'utf8'));
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== 1 || !notificationAgentId(value.target))
56
+ continue;
42
57
  try {
43
- const path = join(dir, file), value = JSON.parse(readFileSync(path, 'utf8'));
44
- if (!notificationAgentId(value.url))
45
- continue;
46
58
  attempts++;
47
- if (options.beforeDelete && !(await options.beforeDelete(value.url))) {
59
+ if (options.beforeDelete && !(await options.beforeDelete(value.target))) {
48
60
  rmSync(path, { force: true });
49
61
  continue;
50
62
  }
package/dist/ops.js CHANGED
@@ -271,7 +271,7 @@ export async function rmRole(cfg, name, deps) {
271
271
  rmSync(agentDir(name), { recursive: true, force: true });
272
272
  if (generatedSource && existsSync(role.sourceFile)) {
273
273
  unlinkSync(role.sourceFile);
274
- await retireNotificationTarget(`/fleet/chats/agents/${encodeURIComponent(name)}`).catch(() => { });
274
+ await retireNotificationTarget({ kind: 'agent', agentId: name }).catch(() => { });
275
275
  deps.log(`removed ${role.sourceFile}`);
276
276
  }
277
277
  deps.log(`removed '${name}' (its ours identity is left intact)`);
package/dist/paths.d.ts CHANGED
@@ -13,6 +13,9 @@ export declare function deriveXdgRuntimeDir(env?: NodeJS.ProcessEnv, uid?: numbe
13
13
  export declare const stateRoot: () => string;
14
14
  /** The Owner invitation file that tunnel setup writes itself after enrollment. */
15
15
  export declare const workspaceOwnerInvite: () => string;
16
+ /** Serializes writers of the workspace binding and of its unfinished replacement record. */
17
+ export declare const workspaceBindingLock: () => string;
18
+ export declare const workspaceReplacementFile: () => string;
16
19
  export declare const agentsRoot: () => string;
17
20
  export declare const tmpRoot: () => string;
18
21
  export declare const logsRoot: () => string;
package/dist/paths.js CHANGED
@@ -23,6 +23,9 @@ export function deriveXdgRuntimeDir(env = process.env, uid = process.getuid?.(),
23
23
  export const stateRoot = () => join(home(), '.ours-fleet');
24
24
  /** The Owner invitation file that tunnel setup writes itself after enrollment. */
25
25
  export const workspaceOwnerInvite = () => join(stateRoot(), 'workspace', 'owner.invite');
26
+ /** Serializes writers of the workspace binding and of its unfinished replacement record. */
27
+ export const workspaceBindingLock = () => join(stateRoot(), 'workspace', '.binding.lock');
28
+ export const workspaceReplacementFile = () => join(stateRoot(), 'workspace', 'replacement.json');
26
29
  export const agentsRoot = () => join(stateRoot(), 'agents');
27
30
  export const tmpRoot = () => join(stateRoot(), 'tmp');
28
31
  export const logsRoot = () => join(stateRoot(), 'logs');
@@ -101,7 +101,7 @@ function writeTask(record) {
101
101
  pending.push({ eventId: `task:${record.task_id}:${randomUUID()}`, taskId: record.task_id,
102
102
  title: `Task ${!previous ? 'created' : 'updated'}: ${record.title}`.slice(0, 160),
103
103
  body: `Task ${record.task_id}: ${before ? `${before} → ` : 'created in '}${next}.`,
104
- url: `/fleet/tasks/${record.task_id}` });
104
+ target: { kind: 'task', taskId: record.task_id } });
105
105
  }
106
106
  if (pending.length)
107
107
  persisted.notification_events = pending;
@@ -111,6 +111,17 @@ function writeTask(record) {
111
111
  publishFleetResourceCommit(taskPath(record.task_id));
112
112
  }
113
113
  const retiredNotificationsDir = () => join(tasksDir(), '.notification-retired');
114
+ function typedTaskNotification(value, id) {
115
+ if (!value || typeof value !== 'object' || Object.keys(value).some(key => !['eventId', 'title', 'body', 'target', 'taskId'].includes(key)))
116
+ return false;
117
+ const target = value.target;
118
+ return typeof value.eventId === 'string' && value.eventId.startsWith(`task:${id}:`) && value.eventId.length <= 512
119
+ && value.taskId === id && !!target && typeof target === 'object' && !Array.isArray(target)
120
+ && Object.keys(target).every(key => ['kind', 'taskId'].includes(key)) && target.taskId === id
121
+ && (target.kind === 'task' || (target.kind === 'task-deleted' && value.eventId === `task:${id}:deleted`))
122
+ && typeof value.title === 'string' && !!value.title.trim() && value.title.length <= 160
123
+ && typeof value.body === 'string' && !!value.body.trim() && value.body.length <= 512;
124
+ }
114
125
  /** Atomic snapshots contain the events, including intermediate states between polls. */
115
126
  export function pendingTaskNotifications(warn = () => { }) {
116
127
  const result = [];
@@ -129,13 +140,13 @@ export function pendingTaskNotifications(warn = () => { }) {
129
140
  continue;
130
141
  if (value.notification_events === undefined)
131
142
  continue;
132
- if (!Array.isArray(value.notification_events) || value.notification_events.some(e => !e
133
- || typeof e.eventId !== 'string' || !e.eventId.startsWith(`task:${id}:`) || e.eventId.length > 512
134
- || e.taskId !== id || (e.url !== `/fleet/tasks/${id}` && !(e.eventId === `task:${id}:deleted` && e.url === `/fleet/tasks?deletedTask=${id}`)) || typeof e.title !== 'string' || !e.title.trim() || e.title.length > 160
135
- || typeof e.body !== 'string' || !e.body.trim() || e.body.length > 512))
143
+ if (!Array.isArray(value.notification_events))
136
144
  throw new Error('invalid notification events');
137
- if (value.notification_events.length)
138
- result.push({ taskId: id, events: value.notification_events });
145
+ // Only typed task events are delivered. Unsupported entries are never sent or converted,
146
+ // and they do not block later lifecycle events.
147
+ const events = value.notification_events.filter(event => typedTaskNotification(event, id));
148
+ if (events.length)
149
+ result.push({ taskId: id, events });
139
150
  }
140
151
  catch (error) {
141
152
  if (!isNotFoundError(error))
@@ -153,9 +164,9 @@ export function acknowledgeTaskNotification(id, eventId) {
153
164
  if (!existsSync(path))
154
165
  continue;
155
166
  const value = JSON.parse(readFileSync(path, 'utf8'));
156
- if (!value.notification_events?.some(event => event.eventId === eventId))
167
+ if (!value.notification_events?.some(event => typedTaskNotification(event, id) && event.eventId === eventId))
157
168
  continue;
158
- value.notification_events = value.notification_events.filter(event => event.eventId !== eventId);
169
+ value.notification_events = value.notification_events.filter(event => !typedTaskNotification(event, id) || event.eventId !== eventId);
159
170
  replaceFileAtomically(path, JSON.stringify(value, null, 2) + '\n');
160
171
  if (path.startsWith(retiredNotificationsDir() + '/') && !value.notification_events.length)
161
172
  unlinkSync(path);
@@ -890,7 +901,7 @@ export function unlinkDeletedTask(id) {
890
901
  // before unlink may replay two copies; both carry identical service dedupe IDs.
891
902
  const removed = { eventId: `task:${id}:deleted`, taskId: id,
892
903
  title: `Task deleted: ${stored.title}`.slice(0, 160), body: `Task ${id}: deleting → deleted.`,
893
- url: `/fleet/tasks?deletedTask=${id}` };
904
+ target: { kind: 'task-deleted', taskId: id } };
894
905
  replaceFileAtomically(join(retiredNotificationsDir(), `${id}.json`), JSON.stringify({ notification_events: [...(stored.notification_events ?? []), removed] }) + '\n');
895
906
  try {
896
907
  unlinkSync(taskPath(id));
@@ -110,7 +110,10 @@ export interface TaskNotificationEvent {
110
110
  eventId: string;
111
111
  title: string;
112
112
  body: string;
113
- url: string;
113
+ target: {
114
+ kind: 'task' | 'task-deleted';
115
+ taskId: string;
116
+ };
114
117
  taskId: string;
115
118
  }
116
119
  export interface TaskRecord {
@@ -1,3 +1,4 @@
1
+ import { validateConfiguredAccountOrigin } from './account-origin.js';
1
2
  import { lstatSync, readFileSync } from 'node:fs';
2
3
  import { decodeWorkspacePayload } from './workspace-enrollment.js';
3
4
  /** Read the same private-file transport as enrollment, without consuming the grant. */
@@ -31,8 +32,13 @@ function decodeTunnelSetupGrant(input) {
31
32
  catch {
32
33
  throw Error('Invalid tunnel setup payload');
33
34
  }
35
+ try {
36
+ validateConfiguredAccountOrigin(grant?.appOrigin);
37
+ }
38
+ catch {
39
+ throw Error('Tunnel setup grant is invalid or expired; get a fresh command from the App');
40
+ }
34
41
  if (grant?.version !== 2 || Object.keys(grant).sort().join(',') !== 'appOrigin,code,expiresAt,version'
35
- || !['https://app.ours.network', 'https://app.ours-tunnel.com'].includes(grant.appOrigin)
36
42
  || !/^[A-Za-z0-9_-]{43}$/.test(grant.code) || !Number.isSafeInteger(grant.expiresAt)
37
43
  || grant.expiresAt <= Date.now() || grant.expiresAt > Date.now() + 16 * 60000)
38
44
  throw Error('Tunnel setup grant is invalid or expired; get a fresh command from the App');
@@ -32,7 +32,7 @@ export declare class WebAuth {
32
32
  private readonly devices;
33
33
  private readonly access;
34
34
  private readonly workspaceDevices?;
35
- readonly appOrigin: string;
35
+ readonly appOrigin?: string | undefined;
36
36
  private _bootstrapSecret;
37
37
  private bootstrapExpiresAt;
38
38
  private bootstrapUsed;
@@ -43,7 +43,7 @@ export declare class WebAuth {
43
43
  private readonly transports;
44
44
  private readonly renewals;
45
45
  private readonly sockets;
46
- constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig, workspaceDevices?: WorkspaceDeviceStore | undefined, appOrigin?: string);
46
+ constructor(_origin: string, _host: string, now?: () => number, devices?: TrustedDeviceStore, access?: WebAccessConfig, workspaceDevices?: WorkspaceDeviceStore | undefined, appOrigin?: string | undefined);
47
47
  get bootstrapSecret(): string;
48
48
  get origin(): string;
49
49
  get host(): string;
package/dist/web/auth.js CHANGED
@@ -27,7 +27,7 @@ export class WebAuth {
27
27
  transports = new Map();
28
28
  renewals = new Map();
29
29
  sockets = new Map();
30
- constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }, workspaceDevices, appOrigin = 'https://app.ours.network') {
30
+ constructor(_origin, _host, now = Date.now, devices = new TrustedDeviceStore(), access = { version: 1, mode: 'pairing' }, workspaceDevices, appOrigin) {
31
31
  this._origin = _origin;
32
32
  this._host = _host;
33
33
  this.now = now;
@@ -58,7 +58,7 @@ export class WebAuth {
58
58
  }
59
59
  /** The configured account origin, when this request comes from it and workspace devices are enabled. */
60
60
  accountOrigin(request) {
61
- return this.workspaceDevices && request.headers.origin === this.appOrigin ? this.appOrigin : undefined;
61
+ return this.workspaceDevices && this.appOrigin && request.headers.origin === this.appOrigin ? this.appOrigin : undefined;
62
62
  }
63
63
  validateBoundary(request, requireOrigin) {
64
64
  const host = request.headers.host;
@@ -68,7 +68,7 @@ export class WebAuth {
68
68
  throw new FleetError('forbidden', `This address is not configured for the fleet console. Open ${this.origin} or set --public-origin.`);
69
69
  if (this.workspaceDevices && String(request.headers.authorization ?? '').startsWith('Bearer ') && request.headers.origin && request.headers.origin !== this.appOrigin && !origins.has(request.headers.origin))
70
70
  throw new FleetError('forbidden', 'workspace request Origin does not match configured account origin');
71
- const workspaceRequest = Boolean(this.workspaceDevices) && request.headers.origin === this.appOrigin
71
+ const workspaceRequest = Boolean(this.workspaceDevices) && Boolean(this.appOrigin) && request.headers.origin === this.appOrigin
72
72
  && (String(request.headers.authorization ?? '').startsWith('Bearer ') || (request.url ?? '').split('?')[0] === '/api/v1/devices/enroll' || request.method === 'OPTIONS');
73
73
  if (requireOrigin && !workspaceRequest && (!request.headers.origin || !origins.has(request.headers.origin)))
74
74
  throw new FleetError('forbidden', 'request Origin does not match the configured control-panel origin');
@@ -131,7 +131,7 @@ export class WebAuth {
131
131
  return session;
132
132
  }
133
133
  this.validateBoundary(request, mutation);
134
- if (request.headers.origin === this.appOrigin && !this.allowedOrigins.has(this.appOrigin))
134
+ if (this.appOrigin && request.headers.origin === this.appOrigin && !this.allowedOrigins.has(this.appOrigin))
135
135
  throw new FleetError('unauthorized', 'workspace bearer credential required');
136
136
  const id = parseCookies(request.headers.cookie ?? '').ofs_session;
137
137
  const session = id ? this.sessions.get(id) : undefined;
@@ -79,10 +79,10 @@ export async function startWebConsole(options) {
79
79
  }
80
80
  const access = options.access ?? new WebAccessStore(webDir).read();
81
81
  const bindingFile = resolve(stateRoot(), 'workspace', 'binding.json');
82
- let appOrigin = 'https://app.ours.network';
82
+ let appOrigin;
83
83
  try {
84
84
  if (existsSync(bindingFile))
85
- appOrigin = validateAccountOrigin(JSON.parse(readFileSync(bindingFile, 'utf8')).appOrigin ?? appOrigin);
85
+ appOrigin = validateAccountOrigin(JSON.parse(readFileSync(bindingFile, 'utf8')).appOrigin);
86
86
  }
87
87
  catch (error) {
88
88
  lock.release();
@@ -53,7 +53,7 @@ export async function buildWebServer(services, boundary, options = {}) {
53
53
  reply.header('Content-Security-Policy', "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
54
54
  if (request.url.startsWith('/api/'))
55
55
  reply.header('Cache-Control', 'no-store');
56
- if (request.headers.origin === auth.appOrigin && request.url.startsWith('/api/')) {
56
+ if (auth.appOrigin && request.headers.origin === auth.appOrigin && request.url.startsWith('/api/')) {
57
57
  reply.header('Access-Control-Allow-Origin', auth.appOrigin).header('Vary', 'Origin');
58
58
  reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
59
59
  reply.header('Access-Control-Expose-Headers', 'X-Ours-Workspace-Auth, X-Ours-Request-Id');
@@ -1,9 +1,9 @@
1
- import { validateAccountOrigin } from './account-origin.js';
1
+ import { validateAccountOrigin, validateConfiguredAccountOrigin, validateConfiguredWorkspaceHost } from './account-origin.js';
2
2
  import { lstatSync, readFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { readClientProfile } from './client-profile.js';
5
- import { stateRoot, defaultConfigPath, workspaceOwnerInvite } from './paths.js';
6
- import { replaceFileAtomically } from './atomic-file.js';
5
+ import { stateRoot, defaultConfigPath, workspaceOwnerInvite, workspaceBindingLock, workspaceReplacementFile } from './paths.js';
6
+ import { replaceFileAtomically, withSynchronousFileLock } from './atomic-file.js';
7
7
  import { loadConfig, splitRootFor } from './config.js';
8
8
  import { parse, parseDocument, stringify } from 'yaml';
9
9
  import { randomUUID } from 'node:crypto';
@@ -25,7 +25,13 @@ export function decodeWorkspacePayload(input, options = {}) {
25
25
  catch {
26
26
  throw Error('Invalid workspace payload');
27
27
  }
28
- if (!p || typeof p !== 'object' || Array.isArray(p) || 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)
28
+ try {
29
+ validateConfiguredAccountOrigin(p?.appOrigin);
30
+ }
31
+ catch {
32
+ throw Error('Workspace payload is invalid or expired');
33
+ }
34
+ if (!p || typeof p !== 'object' || Array.isArray(p) || p.version !== 1 || !validateConfiguredWorkspaceHost(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
35
  throw Error('Workspace payload is invalid or expired');
30
36
  for (const k of ['connectorToken', 'invitation']) {
31
37
  if (k === 'connectorToken' && p[k] === undefined)
@@ -58,7 +64,7 @@ export function checkWorkspaceConnector(payload) {
58
64
  catch {
59
65
  reject();
60
66
  }
61
- if (previous.workspaceId !== payload.challenge.workspaceId || validateAccountOrigin(previous.appOrigin ?? 'https://app.ours.network') !== payload.appOrigin || (previous.serverCid && previous.serverCid.toUpperCase() !== payload.serverCid.toUpperCase()))
67
+ if (previous.workspaceId !== payload.challenge.workspaceId || validateAccountOrigin(previous.appOrigin) !== payload.appOrigin || (previous.serverCid && previous.serverCid.toUpperCase() !== payload.serverCid.toUpperCase()))
62
68
  reject();
63
69
  try {
64
70
  const file = join(dir, 'connector'), stat = lstatSync(file);
@@ -264,11 +270,24 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
264
270
  const appOrigin = validateAccountOrigin(payload.appOrigin);
265
271
  const previousFile = join(dir, 'binding.json');
266
272
  let migrating = false;
267
- if (existsSync(previousFile)) {
268
- const previous = JSON.parse(readFileSync(previousFile, 'utf8'));
273
+ // Every decision below rests on this binding and on the unfinished replacement record (none, or the
274
+ // one this setup completes); both are rechecked under the binding lock before the first change and
275
+ // again at the final write, so a concurrent setup or replacement is never overwritten.
276
+ const read = (file) => existsSync(file) ? readFileSync(file, 'utf8') : null;
277
+ const priorBinding = read(previousFile), priorReplacement = read(workspaceReplacementFile());
278
+ if (priorReplacement !== null && options.replacingWorkspace === undefined)
279
+ throw Error('An unfinished replacement exists; resume or abandon it before setup. Existing setup preserved');
280
+ const unchangedBinding = () => {
281
+ if (read(previousFile) !== priorBinding)
282
+ throw Error('The workspace binding changed during setup; existing setup preserved. Run the setup command again');
283
+ if (read(workspaceReplacementFile()) !== priorReplacement)
284
+ throw Error('The unfinished replacement changed during setup; existing setup preserved. Review it with setup-tunnel --resume');
285
+ };
286
+ if (priorBinding !== null) {
287
+ const previous = JSON.parse(priorBinding);
269
288
  if (previous.workspaceId !== payload.challenge.workspaceId && options.replacingWorkspace !== previous.workspaceId)
270
289
  throw Error('This host is already associated with another workspace');
271
- const priorOrigin = validateAccountOrigin(previous.appOrigin ?? 'https://app.ours.network');
290
+ const priorOrigin = validateAccountOrigin(previous.appOrigin);
272
291
  migrating = priorOrigin !== appOrigin && options.replacingWorkspace !== previous.workspaceId;
273
292
  if (migrating && !options.migrateAppOrigin)
274
293
  throw Error('Account origin change requires explicit --migrate-app-origin and a fresh setup payload');
@@ -287,7 +306,7 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
287
306
  throw Error('Bound Messenger identity unavailable');
288
307
  // 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.
289
308
  const moved = Boolean(loaded.rooms) && !lost && !!identity.root && !same(loaded.rooms.owner.expected_cid, identity.cid) && managedRootOwner(resolve(configuration), loaded.rooms, identity.root)
290
- && (options.replacingWorkspace !== undefined || JSON.parse(readFileSync(previousFile, 'utf8')).serverCid?.toUpperCase() === payload.serverCid.toUpperCase());
309
+ && (options.replacingWorkspace !== undefined || JSON.parse(priorBinding).serverCid?.toUpperCase() === payload.serverCid.toUpperCase());
291
310
  if (options.preserveProfile && identity.preserveProfile !== true)
292
311
  throw Error('Existing-host setup requires Messenger profile-preservation support; update the host Messenger before retrying');
293
312
  if (!options.preserveProfile && (payload.fullName?.trim() || !payload.name?.trim() || !payload.surname?.trim()) && !identity.optionalWorkspaceProfileV1)
@@ -327,6 +346,7 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
327
346
  throw Error('Invalid replacement proof receipt');
328
347
  }
329
348
  }
349
+ withSynchronousFileLock(workspaceBindingLock(), unchangedBinding);
330
350
  if (identity.root) {
331
351
  if (!options.preserveProfile)
332
352
  await request('identity/profile', { fullName: fullName ?? null, name: name ?? null, surname: surname ?? null });
@@ -376,26 +396,31 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
376
396
  await new Promise(resolve => setTimeout(resolve, 500));
377
397
  }
378
398
  }
379
- replaceFileAtomically(inviteFile, result.ownerInvite + '\n', 0o600);
380
- options.afterInvitation?.();
381
- const split = splitRootFor(configuration);
382
- mkdirSync(split, { recursive: true, mode: 0o700 });
383
- if (!loaded.rooms)
384
- replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
385
- else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !== ownerCid.toLowerCase())
386
- replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
387
- else if (moved) {
388
- // The invitation above and this entry are two files. A setup interrupted between them leaves the entry naming the root,
389
- // which is this same case again: running setup once more writes both.
390
- const document = parseDocument(readFileSync(configuration, 'utf8'));
391
- document.setIn(['rooms', 'owner', 'expected_cid'], ownerCid);
392
- replaceFileAtomically(configuration, document.toString(), 0o600);
393
- }
394
- if (payload.connectorToken !== undefined)
395
- replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
396
399
  const origin = `https://${payload.hostname}`;
397
- replaceFileAtomically(join(dir, 'tunnel.json'), JSON.stringify({ origin, tokenFile: join(dir, 'connector') }) + '\n', 0o600);
398
- replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase(), ...(identity.root ? { proofRootCid: identity.root.cid } : {}) }) + '\n', 0o600);
400
+ // Local files commit together under the binding lock, after rechecking the binding this setup decided
401
+ // on, so a stale concurrent setup refuses before changing any of them.
402
+ withSynchronousFileLock(workspaceBindingLock(), () => {
403
+ unchangedBinding();
404
+ replaceFileAtomically(inviteFile, result.ownerInvite + '\n', 0o600);
405
+ options.afterInvitation?.();
406
+ const split = splitRootFor(configuration);
407
+ mkdirSync(split, { recursive: true, mode: 0o700 });
408
+ if (!loaded.rooms)
409
+ replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
410
+ else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !== ownerCid.toLowerCase())
411
+ replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
412
+ else if (moved) {
413
+ // The invitation above and this entry are two files. A setup interrupted between them leaves the entry naming the root,
414
+ // which is this same case again: running setup once more writes both.
415
+ const document = parseDocument(readFileSync(configuration, 'utf8'));
416
+ document.setIn(['rooms', 'owner', 'expected_cid'], ownerCid);
417
+ replaceFileAtomically(configuration, document.toString(), 0o600);
418
+ }
419
+ if (payload.connectorToken !== undefined)
420
+ replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
421
+ replaceFileAtomically(join(dir, 'tunnel.json'), JSON.stringify({ origin, tokenFile: join(dir, 'connector') }) + '\n', 0o600);
422
+ replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase(), ...(identity.root ? { proofRootCid: identity.root.cid } : {}) }) + '\n', 0o600);
423
+ });
399
424
  loadConfig(configuration);
400
425
  return { origin, hostWorkspaceId, rootCid: result.rootCid };
401
426
  }
@@ -3,20 +3,21 @@ import { existsSync, lstatSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { createInterface } from 'node:readline/promises';
5
5
  import { validateAccountOrigin } from './account-origin.js';
6
- import { replaceFileAtomically } from './atomic-file.js';
7
- import { stateRoot } from './paths.js';
6
+ import { replaceFileAtomically, withSynchronousFileLock } from './atomic-file.js';
7
+ import { stateRoot, workspaceBindingLock, workspaceReplacementFile } from './paths.js';
8
8
  import { decodeWorkspacePayload, enrollWorkspace, unregisterWorkspace, checkWorkspaceConnector } from './workspace-enrollment.js';
9
9
  import { WorkspaceDeviceStore } from './web/workspace-devices.js';
10
10
  import { webControlPath } from './web/control.js';
11
11
  import { WebServiceManager } from './web/service.js';
12
12
  const workspaceDir = () => join(stateRoot(), 'workspace');
13
- const replacementPath = () => join(workspaceDir(), 'replacement.json');
13
+ const replacementPath = workspaceReplacementFile;
14
14
  const opaque = /^[\w-]{43}$/;
15
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)))
16
+ const text = (id, pattern) => typeof id === 'string' && pattern.test(id);
17
+ if (!value || typeof value !== 'object' || !text(value.workspaceId, opaque) || !text(value.hostWorkspaceId, opaque) || (value.serverCid !== undefined && !text(value.serverCid, /^[a-f0-9]{64}$/i))
18
+ || (value.proofRootCid !== undefined && !text(value.proofRootCid, /^[a-f0-9]{64}$/i)))
18
19
  throw Error('Existing workspace binding is invalid; restore it before tunnel setup');
19
- return { ...value, appOrigin: validateAccountOrigin(value.appOrigin ?? 'https://app.ours.network') };
20
+ return { ...value, appOrigin: validateAccountOrigin(value.appOrigin) };
20
21
  }
21
22
  export function readWorkspaceBinding() {
22
23
  const file = join(workspaceDir(), 'binding.json');
@@ -53,7 +54,7 @@ export async function confirmWorkspaceReplacement(previous, explicit = false, ef
53
54
  let hostname = '';
54
55
  try {
55
56
  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
+ if (typeof tunnel.origin === 'string' && validateAccountOrigin(tunnel.origin))
57
58
  hostname = ` (${new URL(tunnel.origin).hostname})`;
58
59
  }
59
60
  catch { }
@@ -84,7 +85,7 @@ export async function confirmWorkspaceReplacement(previous, explicit = false, ef
84
85
  return false;
85
86
  }
86
87
  export function saveWorkspaceReplacement(record) {
87
- replaceFileAtomically(replacementPath(), JSON.stringify(record) + '\n', 0o600);
88
+ withSynchronousFileLock(workspaceBindingLock(), () => replaceFileAtomically(replacementPath(), JSON.stringify(record) + '\n', 0o600));
88
89
  }
89
90
  export function readWorkspaceReplacement() {
90
91
  const file = replacementPath();
@@ -110,7 +111,7 @@ export function readWorkspaceReplacement() {
110
111
  record.payload = decodeWorkspacePayload(Buffer.from(JSON.stringify(record.payload)).toString('base64url'), { allowExpired: true });
111
112
  return record;
112
113
  }
113
- export function clearWorkspaceReplacement() { rmSync(replacementPath(), { force: true }); }
114
+ export function clearWorkspaceReplacement() { withSynchronousFileLock(workspaceBindingLock(), () => rmSync(replacementPath(), { force: true })); }
114
115
  /** Explicit recovery after the user removes an unsuccessful successor in the App. */
115
116
  export function discardRetiredWorkspaceReplacement() {
116
117
  const record = readWorkspaceReplacement(), current = readWorkspaceBinding();
@@ -132,9 +133,26 @@ export async function beginWorkspaceReplacement(previous, payload, configuration
132
133
  if (!requiresWorkspaceReplacement(previous, { workspaceId: payload.challenge.workspaceId, appOrigin: payload.appOrigin, serverCid: payload.serverCid }))
133
134
  throw Error('The same workspace must never be retired');
134
135
  checkReplacementService(configuration);
136
+ // The record is created only for the binding this decision was made on, and only if no other
137
+ // replacement or setup changed the binding or the record meanwhile; enrollment checks the same pair.
138
+ const bindingFile = join(workspaceDir(), 'binding.json'), current = () => existsSync(bindingFile) ? readFileSync(bindingFile, 'utf8') : null;
139
+ const unchanged = (bytes) => {
140
+ if (current() !== bytes)
141
+ throw Error('The workspace binding changed during setup; existing setup preserved. Run the setup command again');
142
+ if (existsSync(replacementPath()))
143
+ throw Error('Another replacement started during setup; existing setup preserved. Review it with setup-tunnel --resume');
144
+ };
145
+ const before = withSynchronousFileLock(workspaceBindingLock(), () => {
146
+ const bytes = current();
147
+ unchanged(bytes);
148
+ const now = readWorkspaceBinding();
149
+ if (!now || now.workspaceId !== previous.workspaceId || now.hostWorkspaceId !== previous.hostWorkspaceId || now.appOrigin !== previous.appOrigin || now.serverCid?.toUpperCase() !== previous.serverCid?.toUpperCase())
150
+ throw Error('The workspace binding changed during setup; existing setup preserved. Run the setup command again');
151
+ return bytes;
152
+ });
135
153
  const record = { version: 2, previous: previous, payload, configuration: resolve(configuration), operationNonce: randomBytes(32).toString('base64url'), dispatchAttempted: false, operationExpiresAt: Date.now() + 86400000 - 5 * 60000 };
136
154
  record.rootCid = await (effects.preflight ?? unregisterWorkspace)(record.previous, payload, record.operationNonce, effects.attach, { checkOnly: true, operationExpiresAt: record.operationExpiresAt });
137
- saveWorkspaceReplacement(record);
155
+ withSynchronousFileLock(workspaceBindingLock(), () => { unchanged(before); replaceFileAtomically(replacementPath(), JSON.stringify(record) + '\n', 0o600); });
138
156
  return record;
139
157
  }
140
158
  /** Abandon only after a private receipt confirms no deletion and dispatch was unsent or terminally rejected. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.71",
3
+ "version": "1.2.0-nightly.72",
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",