@ours.network/fleet 1.2.0-nightly.71 → 1.2.0-nightly.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account-origin.d.ts +4 -1
- package/dist/account-origin.js +27 -2
- package/dist/application/role-removal-service.js +1 -1
- package/dist/build-info.json +4 -4
- package/dist/notifications/fleet-producer.js +6 -6
- package/dist/notifications/outbox.d.ts +14 -1
- package/dist/notifications/outbox.js +51 -10
- package/dist/notifications/target-cleanup.d.ts +9 -4
- package/dist/notifications/target-cleanup.js +34 -22
- package/dist/ops.js +1 -1
- package/dist/paths.d.ts +3 -0
- package/dist/paths.js +3 -0
- package/dist/rooms-tasks/task-state.js +21 -10
- package/dist/rooms-tasks/types.d.ts +4 -1
- package/dist/tunnel-setup-input.js +7 -1
- package/dist/web/auth.d.ts +2 -2
- package/dist/web/auth.js +4 -4
- package/dist/web/runtime.js +2 -2
- package/dist/web/server.js +1 -1
- package/dist/workspace-enrollment.js +53 -28
- package/dist/workspace-replacement.js +28 -10
- package/package.json +5 -5
package/dist/account-origin.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
|
|
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;
|
package/dist/account-origin.js
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
|
-
|
|
1
|
+
/** Syntax validation for an authority already selected by trusted private state/configuration. */
|
|
2
2
|
export function validateAccountOrigin(value) {
|
|
3
|
-
|
|
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(
|
|
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 };
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.73",
|
|
3
|
+
"buildId": "b6b76cb3faf6",
|
|
4
|
+
"commit": "45058aae764ba12d712ea74da7bbdb840c497171",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-10-
|
|
6
|
+
"builtAt": "2026-10-18T13:27:24.108Z",
|
|
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 (
|
|
83
|
-
const name = notificationAgentId(
|
|
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 (
|
|
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
|
-
|
|
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 } : {}),
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =>
|
|
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 =>
|
|
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
|
-
|
|
4
|
-
|
|
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(
|
|
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?: (
|
|
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
|
|
9
|
-
export function notificationAgentId(
|
|
10
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
package/dist/web/auth.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
package/dist/web/runtime.js
CHANGED
|
@@ -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
|
|
82
|
+
let appOrigin;
|
|
83
83
|
try {
|
|
84
84
|
if (existsSync(bindingFile))
|
|
85
|
-
appOrigin = validateAccountOrigin(JSON.parse(readFileSync(bindingFile, 'utf8')).appOrigin
|
|
85
|
+
appOrigin = validateAccountOrigin(JSON.parse(readFileSync(bindingFile, 'utf8')).appOrigin);
|
|
86
86
|
}
|
|
87
87
|
catch (error) {
|
|
88
88
|
lock.release();
|
package/dist/web/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
398
|
-
|
|
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 =
|
|
13
|
+
const replacementPath = workspaceReplacementFile;
|
|
14
14
|
const opaque = /^[\w-]{43}$/;
|
|
15
15
|
function binding(value) {
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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' &&
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.2.0-nightly.73",
|
|
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",
|
|
@@ -48,9 +48,9 @@
|
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@agentclientprotocol/sdk": "^1.3.0",
|
|
50
50
|
"@fastify/websocket": "^11.2.0",
|
|
51
|
-
"@ours.network/cli": "
|
|
52
|
-
"@ours.network/mcp": "1.2.0-nightly.
|
|
53
|
-
"@ours.network/sdk": "
|
|
51
|
+
"@ours.network/cli": "3.1.1-nightly.1",
|
|
52
|
+
"@ours.network/mcp": "1.2.0-nightly.15",
|
|
53
|
+
"@ours.network/sdk": "4.1.1-nightly.1",
|
|
54
54
|
"commander": "^12.1.0",
|
|
55
55
|
"fastify": "^5.4.0",
|
|
56
56
|
"qrcode": "1.5.4",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"@agentclientprotocol/codex-acp": "1.10.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@ours.network/daemon": "
|
|
67
|
+
"@ours.network/daemon": "4.1.1-nightly.1",
|
|
68
68
|
"@types/node": "^22.20.4",
|
|
69
69
|
"@types/qrcode": "^1.5.6",
|
|
70
70
|
"@types/ws": "^8.18.1",
|