@ours.network/fleet 1.2.0-nightly.66 → 1.2.0-nightly.68

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.
@@ -0,0 +1,266 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, sep } from 'node:path';
3
+ import { replaceFileAtomically } from '../atomic-file.js';
4
+ import { realExec } from '../exec.js';
5
+ import { home } from '../paths.js';
6
+ import { WATCHDOG_LAUNCHD_LABEL, WATCHDOG_SYSTEMD_UNIT } from '../watchdog/service.js';
7
+ import { WEB_LAUNCHD_LABEL, WEB_SYSTEMD_UNIT } from '../web/service.js';
8
+ import { labelFor } from './launchd.js';
9
+ import { UNIT_TEMPLATE } from './systemd.js';
10
+ /**
11
+ * Service definitions written by `init`, `web install` and the watchdog
12
+ * installer pin the realpath of the CLI that wrote them: an immutable,
13
+ * per-release package tree. An update installs a new tree and moves the
14
+ * `ours-fleet` command, but a definition is only rewritten when that command
15
+ * runs again, so after the next reboot the OS starts the previous release's
16
+ * supervisor while agents, web and CLI run the new one.
17
+ *
18
+ * Refresh re-points an existing Fleet-generated definition at the invoking
19
+ * release and changes nothing else: environment, profile, ports and drop-ins
20
+ * are kept byte for byte, and no unit is created, enabled, started, stopped
21
+ * or restarted. A running process keeps its old code until its operator
22
+ * restarts it, which the report states rather than doing.
23
+ */
24
+ const PACKAGE_SCRIPT = ['node_modules', '@ours.network', 'fleet', 'dist', 'cli.js'].join(sep);
25
+ /** The release tree that owns a Fleet CLI script, if it is a packaged one. */
26
+ export function releaseRoot(script) {
27
+ const suffix = sep + PACKAGE_SCRIPT;
28
+ return script.endsWith(suffix) ? script.slice(0, -suffix.length) : undefined;
29
+ }
30
+ function definitions(platform, homeDir) {
31
+ if (platform === 'linux') {
32
+ const dir = join(homeDir, '.config', 'systemd', 'user');
33
+ return [
34
+ { service: 'supervisor', unit: UNIT_TEMPLATE, path: join(dir, UNIT_TEMPLATE), entry: '_run-fleet',
35
+ marker: 'ours-fleet process manager' },
36
+ { service: 'web', unit: WEB_SYSTEMD_UNIT, path: join(dir, WEB_SYSTEMD_UNIT), entry: 'web',
37
+ marker: 'ours-fleet localhost web console' },
38
+ { service: 'watchdogs', unit: WATCHDOG_SYSTEMD_UNIT, path: join(dir, WATCHDOG_SYSTEMD_UNIT), entry: '_run-watchdogs',
39
+ marker: 'ours-fleet watchdog scheduler' },
40
+ ];
41
+ }
42
+ if (platform === 'darwin') {
43
+ const dir = join(homeDir, 'Library', 'LaunchAgents');
44
+ return [
45
+ { service: 'supervisor', path: join(dir, `${labelFor('fleet')}.plist`), entry: '_run-fleet', marker: labelFor('fleet') },
46
+ { service: 'web', path: join(dir, `${WEB_LAUNCHD_LABEL}.plist`), entry: 'web', marker: WEB_LAUNCHD_LABEL },
47
+ { service: 'watchdogs', path: join(dir, `${WATCHDOG_LAUNCHD_LABEL}.plist`), entry: '_run-watchdogs',
48
+ marker: WATCHDOG_LAUNCHD_LABEL },
49
+ ];
50
+ }
51
+ return [];
52
+ }
53
+ const NODE_RUNTIME = /^node(js)?[0-9.]*$/;
54
+ const systemdQuote = (value) => `"${value.replace(/[%\\"]/g, char => char === '%' ? '%%' : `\\${char}`)}"`;
55
+ const xml = (value) => value.replace(/[&<>"']/g, char => ({
56
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;',
57
+ }[char]));
58
+ const unxml = (value) => value.replaceAll('&quot;', '"').replaceAll('&apos;', "'")
59
+ .replaceAll('&lt;', '<').replaceAll('&gt;', '>').replaceAll('&amp;', '&');
60
+ function parseGenerated(text, platform, definition) {
61
+ if (platform === 'linux') {
62
+ const lines = text.split('\n');
63
+ const only = (pattern) => {
64
+ const found = lines.flatMap((line, index) => pattern.test(line) ? [index] : []);
65
+ return found.length === 1 ? found[0] : undefined;
66
+ };
67
+ if (lines[only(/^Description=/) ?? -1] !== `Description=${definition.marker}`)
68
+ return undefined;
69
+ if (lines[only(/^Type=/) ?? -1] !== 'Type=simple')
70
+ return undefined;
71
+ const exec = only(/^\s*ExecStart\s*=/);
72
+ if (exec === undefined || !lines[exec].startsWith('ExecStart="'))
73
+ return undefined;
74
+ const args = [...lines[exec].slice('ExecStart='.length).matchAll(/"((?:[^"\\]|\\.)*)"|(\S+)/g)]
75
+ .map(match => match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1').replaceAll('%%', '%') : match[2]);
76
+ const [runtime, script, entry] = args;
77
+ if (!runtime?.startsWith('/') || !NODE_RUNTIME.test(runtime.split('/').at(-1)) || entry !== definition.entry || !script)
78
+ return undefined;
79
+ const quoted = `${systemdQuote(runtime)} ${systemdQuote(script)} `;
80
+ if (!lines[exec].startsWith(`ExecStart=${quoted}`))
81
+ return undefined;
82
+ const pathLine = only(/^Environment="PATH=/);
83
+ return {
84
+ script,
85
+ rewrite(next, path) {
86
+ const out = [...lines];
87
+ out[exec] = `ExecStart=${systemdQuote(runtime)} ${systemdQuote(next)} ${lines[exec].slice(`ExecStart=${quoted}`.length)}`;
88
+ if (pathLine !== undefined) {
89
+ const value = /^Environment="PATH=((?:[^"\\]|\\.)*)"$/.exec(lines[pathLine])?.[1];
90
+ if (value !== undefined && !/[\\%]/.test(value))
91
+ out[pathLine] = `Environment="PATH=${value.split(':').map(path).join(':')}"`;
92
+ }
93
+ return out.join('\n');
94
+ },
95
+ };
96
+ }
97
+ if (!new RegExp(`<key>Label</key>\\s*<string>${definition.marker.replaceAll('.', '\\.')}</string>`).test(text))
98
+ return undefined;
99
+ const program = /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>\s*<string>([^<]*)<\/string>\s*<string>([^<]*)<\/string>/.exec(text);
100
+ if (!program)
101
+ return undefined;
102
+ const [, runtime, script, entry] = program.map(unxml);
103
+ if (!runtime.startsWith('/') || !NODE_RUNTIME.test(runtime.split('/').at(-1)) || entry !== definition.entry)
104
+ return undefined;
105
+ return {
106
+ script,
107
+ rewrite(next, path) {
108
+ const head = program[0];
109
+ const moved = head.replace(`<string>${program[2]}</string>`, `<string>${xml(next)}</string>`);
110
+ let out = text.replace(head, moved);
111
+ out = out.replace(/(<key>PATH<\/key>\s*<string>)([^<]*)(<\/string>)/, (_all, open, value, close) => `${open}${xml(unxml(value).split(':').map(path).join(':'))}${close}`);
112
+ return out;
113
+ },
114
+ };
115
+ }
116
+ /** A systemd drop-in that sets ExecStart owns the command line; Fleet must not fight it. */
117
+ function overriddenByDropIn(path) {
118
+ const dir = `${path}.d`;
119
+ try {
120
+ return readdirSync(dir).some(name => name.endsWith('.conf')
121
+ && /^\s*ExecStart\s*=/m.test(readFileSync(join(dir, name), 'utf8')));
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
127
+ const procArgv = (pid) => {
128
+ try {
129
+ return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').filter(Boolean);
130
+ }
131
+ catch {
132
+ return undefined;
133
+ }
134
+ };
135
+ async function runningState(definition, options, platform) {
136
+ if (platform !== 'linux' || !definition.unit)
137
+ return 'unknown';
138
+ const exec = options.exec ?? realExec;
139
+ const shown = await exec('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', definition.unit]);
140
+ if (shown.code !== 0)
141
+ return 'unknown';
142
+ const pid = Number(shown.stdout.trim());
143
+ if (!Number.isSafeInteger(pid) || pid < 0)
144
+ return 'unknown';
145
+ if (pid === 0)
146
+ return 'stopped';
147
+ const argv = (options.processArgv ?? procArgv)(pid);
148
+ const script = argv?.[1];
149
+ if (!script)
150
+ return 'unknown';
151
+ // A stable launcher resolves to whichever release it pointed at when started.
152
+ if (!releaseRoot(script))
153
+ return 'unknown';
154
+ return script === options.binPath ? 'current' : 'stale';
155
+ }
156
+ /**
157
+ * Whether systemd still runs an older load of this unit than the file on disk.
158
+ * A rewrite whose daemon-reload failed looks current on the next pass; this is
159
+ * what keeps that pending reload visible instead of losing it.
160
+ */
161
+ async function reloadPending(definition, options, platform) {
162
+ if (platform !== 'linux' || !definition.unit)
163
+ return false;
164
+ const shown = await (options.exec ?? realExec)('systemctl', ['--user', 'show', '-p', 'NeedDaemonReload', '--value', definition.unit]);
165
+ return shown.code === 0 && shown.stdout.trim() === 'yes';
166
+ }
167
+ /** Report, and with `write` refresh, each installed Fleet service definition. */
168
+ export async function refreshServiceRegistrations(options) {
169
+ const platform = options.platform ?? process.platform;
170
+ const homeDir = options.homeDir ?? home();
171
+ const exists = options.exists ?? existsSync;
172
+ const target = releaseRoot(options.binPath);
173
+ const reports = [];
174
+ let rewrote = false;
175
+ for (const definition of definitions(platform, homeDir)) {
176
+ const base = { service: definition.service, path: definition.path };
177
+ if (!exists(definition.path)) {
178
+ reports.push({ ...base, state: 'absent', running: 'stopped' });
179
+ continue;
180
+ }
181
+ const running = await runningState(definition, options, platform);
182
+ const text = readFileSync(definition.path, 'utf8');
183
+ const generated = parseGenerated(text, platform, definition);
184
+ if (!generated) {
185
+ reports.push({ ...base, state: 'unrecognized', running,
186
+ detail: 'not the definition Fleet generates for this service; left untouched' });
187
+ continue;
188
+ }
189
+ const from = releaseRoot(generated.script);
190
+ if (!from || generated.script === options.binPath) {
191
+ if (await reloadPending(definition, options, platform)) {
192
+ // Normally an earlier refresh whose daemon-reload failed: the file is
193
+ // right, the loaded unit is not. Reload now, or keep reporting drift.
194
+ if (options.write) {
195
+ rewrote = true;
196
+ reports.push({ ...base, state: 'refreshed', running, ...(from ? { from } : {}),
197
+ detail: 'definition on disk was not loaded yet; reloaded' });
198
+ }
199
+ else
200
+ reports.push({ ...base, state: 'reload-failed', running, ...(from ? { from } : {}),
201
+ detail: 'systemd has not loaded the definition on disk; run systemctl --user daemon-reload' });
202
+ continue;
203
+ }
204
+ reports.push({ ...base, state: 'current', running, ...(from ? { from } : {}) });
205
+ continue;
206
+ }
207
+ if (!target) {
208
+ reports.push({ ...base, state: 'stale', running, from,
209
+ detail: 'the invoking CLI is not a packaged release; nothing to point the definition at' });
210
+ continue;
211
+ }
212
+ if (platform === 'linux' && overriddenByDropIn(definition.path)) {
213
+ reports.push({ ...base, state: 'unrecognized', running, from,
214
+ detail: `a drop-in in ${definition.path}.d sets ExecStart; left untouched` });
215
+ continue;
216
+ }
217
+ if (!options.write) {
218
+ reports.push({ ...base, state: 'stale', running, from, to: target });
219
+ continue;
220
+ }
221
+ // Only bundled tool directories on PATH follow the release; every other
222
+ // setting, including a profile or home stored under a release tree, stays.
223
+ const kept = [];
224
+ const next = generated.rewrite(options.binPath, entry => {
225
+ if (!entry.startsWith(from + '/'))
226
+ return entry;
227
+ const moved = target + entry.slice(from.length);
228
+ if (exists(moved))
229
+ return moved;
230
+ kept.push(entry);
231
+ return entry;
232
+ });
233
+ if (parseGenerated(next, platform, definition)?.script !== options.binPath) {
234
+ reports.push({ ...base, state: 'unrecognized', running, from,
235
+ detail: 'the rewritten definition would not launch the invoking release; left untouched' });
236
+ continue;
237
+ }
238
+ replaceFileAtomically(definition.path, next, statSync(definition.path).mode & 0o777);
239
+ rewrote = true;
240
+ reports.push({ ...base, state: 'refreshed', running, from, to: target,
241
+ ...(kept.length ? { detail: `kept PATH entries absent from the new release: ${kept.join(', ')}` } : {}) });
242
+ }
243
+ if (rewrote && platform === 'linux') {
244
+ const reload = await (options.exec ?? realExec)('systemctl', ['--user', 'daemon-reload']);
245
+ // The file is rewritten, but systemd keeps the loaded unit until a reload,
246
+ // so a failed reload is not a refresh.
247
+ if (reload.code !== 0)
248
+ for (const report of reports)
249
+ if (report.state === 'refreshed') {
250
+ report.state = 'reload-failed';
251
+ report.detail = [report.detail, `systemctl --user daemon-reload failed: ${reload.stderr.trim() || `exit ${reload.code}`}; `
252
+ + 'run it, then restart the service when convenient'].filter(Boolean).join('; ');
253
+ }
254
+ }
255
+ return reports;
256
+ }
257
+ /** One operator line per definition, including the restart a refresh cannot do. */
258
+ export function describeRegistration(report) {
259
+ const what = `${report.service.padEnd(10)} ${report.state}`;
260
+ const pins = report.from ? ` ${report.from}${report.to ? ` -> ${report.to}` : ''}` : '';
261
+ const restart = report.running === 'stale'
262
+ ? `; the running process still uses ${report.state === 'refreshed' ? 'the previous' : 'another'} release until restarted`
263
+ : '';
264
+ return `${what}${pins}${restart}${report.detail ? ` (${report.detail})` : ''}`;
265
+ }
266
+ export const registrationDrift = (reports) => reports.filter(report => report.state === 'stale' || report.state === 'reload-failed' || report.running === 'stale');
@@ -4,8 +4,9 @@ export interface WorkspacePayload {
4
4
  appOrigin: string;
5
5
  hostname: string;
6
6
  rootName: string;
7
- name: string;
8
- surname: string;
7
+ fullName?: string | null;
8
+ name?: string | null;
9
+ surname?: string | null;
9
10
  connectorToken?: string;
10
11
  invitation: string;
11
12
  serverCid: string;
@@ -25,14 +25,24 @@ export function decodeWorkspacePayload(input, options = {}) {
25
25
  catch {
26
26
  throw Error('Invalid workspace payload');
27
27
  }
28
- if (p.version !== 1 || !['https://app.ours.network', 'https://app.ours-tunnel.com'].includes(p.appOrigin) || !/^[a-z0-9][a-z0-9-]{2,60}\.ours-tunnel\.com$/.test(p.hostname) || !/^[a-z0-9-]{2,30}@[a-z0-9-]{2,30}$/.test(p.rootName) || !/^[a-f0-9]{64}$/i.test(p.serverCid) || !p.challenge || !['nonce', 'accountId', 'workspaceId'].every(k => /^[\w-]{43}$/.test(String(p.challenge[k]))) || (!options.allowExpired && p.challenge.expiresAt <= Date.now()) || !Number.isSafeInteger(p.challenge.expiresAt) || p.challenge.expiresAt > Date.now() + 16 * 60000)
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)
29
29
  throw Error('Workspace payload is invalid or expired');
30
- for (const k of ['connectorToken', 'invitation', 'name', 'surname']) {
30
+ for (const k of ['connectorToken', 'invitation']) {
31
31
  if (k === 'connectorToken' && p[k] === undefined)
32
32
  continue;
33
33
  if (typeof p[k] !== 'string' || !p[k] || p[k].length > 8192 || /[\x00-\x1f\x7f]/.test(p[k]))
34
34
  throw Error('Invalid workspace payload');
35
35
  }
36
+ for (const k of ['fullName', 'name', 'surname']) {
37
+ const value = p[k];
38
+ if (value == null || value === '') {
39
+ p[k] = null;
40
+ continue;
41
+ }
42
+ if (typeof value !== 'string' || value.length > 8192 || /[\x00-\x1f\x7f]/.test(value))
43
+ throw Error('Invalid workspace profile');
44
+ p[k] = value.trim() || null;
45
+ }
36
46
  return p;
37
47
  }
38
48
  /** Credential-free renewal is allowed only on the host already holding this private connector. */
@@ -120,11 +130,11 @@ const CID = /^[a-f0-9]{64}$/i, same = (a, b) => a.toLowerCase() === b.toLowerCas
120
130
  async function messengerIdentity(request, attach) {
121
131
  const bound = await request('workspace/enrollment-identity');
122
132
  if (bound.rootCid === undefined)
123
- return { cid: bound.cid, preserveProfile: bound.preserveProfile === true };
133
+ return { cid: bound.cid, preserveProfile: bound.preserveProfile === true, optionalWorkspaceProfileV1: bound.optionalWorkspaceProfileV1 === true };
124
134
  if (typeof bound.cid !== 'string' || !CID.test(bound.cid) || typeof bound.rootCid !== 'string' || !CID.test(bound.rootCid) || same(bound.cid, bound.rootCid))
125
135
  throw Error('Bound Messenger identity unavailable');
126
136
  const root = await humanRoot(attach, bound.rootCid, bound.cid);
127
- return { cid: bound.cid, preserveProfile: bound.preserveProfile === true, root };
137
+ return { cid: bound.cid, preserveProfile: bound.preserveProfile === true, optionalWorkspaceProfileV1: bound.optionalWorkspaceProfileV1 === true, root };
128
138
  }
129
139
  /** This host's only Human root, as its daemon lists it; optionally with a permanent identity that must be listed under it. */
130
140
  async function humanRoot(attach, rootCid, under) {
@@ -280,6 +290,8 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
280
290
  && (options.replacingWorkspace !== undefined || JSON.parse(readFileSync(previousFile, 'utf8')).serverCid?.toUpperCase() === payload.serverCid.toUpperCase());
281
291
  if (options.preserveProfile && identity.preserveProfile !== true)
282
292
  throw Error('Existing-host setup requires Messenger profile-preservation support; update the host Messenger before retrying');
293
+ if (!options.preserveProfile && (payload.fullName?.trim() || !payload.name?.trim() || !payload.surname?.trim()) && !identity.optionalWorkspaceProfileV1)
294
+ throw Error('Optional workspace profile requires updated Messenger; update the host Messenger before retrying (the host is not enrolled)');
283
295
  if (loaded.rooms && !lost && !moved && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
284
296
  throw Error('Retained room owner CID conflicts with the Human root');
285
297
  const inviteFile = loaded.rooms?.owner.public_invite_file || join(dir, 'owner.invite');
@@ -291,7 +303,7 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
291
303
  const devices = new WorkspaceDeviceStore();
292
304
  const hostWorkspaceId = devices.workspaceId;
293
305
  devices.close();
294
- const { name, surname, ...proofPayload } = payload;
306
+ const { fullName, name, surname, ...proofPayload } = payload;
295
307
  let result, ownerCid;
296
308
  // A replacement can stop after its one-time proof but before local files commit. Read the
297
309
  // account's existing signed receipt first; a verified proof must not be sent a second time.
@@ -316,6 +328,8 @@ export async function enrollWorkspace(payload, configuration = defaultConfigPath
316
328
  }
317
329
  }
318
330
  if (identity.root) {
331
+ if (!options.preserveProfile)
332
+ await request('identity/profile', { fullName: fullName ?? null, name: name ?? null, surname: surname ?? null });
319
333
  // The Human root signs the binding; the person's own Messenger identity is the room Owner and issues the Owner invitation.
320
334
  if (!proofAlreadyConfirmed)
321
335
  await proveWithHumanRoot(attach, identity.root, payload, hostWorkspaceId, options.contactWaitMs ?? 10000, options.replacingWorkspace !== undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.66",
3
+ "version": "1.2.0-nightly.68",
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",