@ours.network/fleet 1.2.0-nightly.70 → 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.
Files changed (58) hide show
  1. package/dist/account-origin.d.ts +4 -1
  2. package/dist/account-origin.js +27 -2
  3. package/dist/application/conversation-history.d.ts +7 -0
  4. package/dist/application/conversation-history.js +65 -0
  5. package/dist/application/fleet-query-service.d.ts +1 -1
  6. package/dist/application/fleet-query-service.js +2 -2
  7. package/dist/application/resource-observer.d.ts +40 -0
  8. package/dist/application/resource-observer.js +278 -0
  9. package/dist/application/role-removal-service.js +1 -1
  10. package/dist/application/role-repository.d.ts +1 -1
  11. package/dist/application/role-repository.js +11 -5
  12. package/dist/application/task-layouts.js +2 -0
  13. package/dist/application/task-room-service.d.ts +2 -0
  14. package/dist/application/task-room-service.js +2 -2
  15. package/dist/build-info.json +4 -4
  16. package/dist/daemon-recovery.d.ts +13 -1
  17. package/dist/daemon-recovery.js +117 -3
  18. package/dist/monitor.d.ts +1 -0
  19. package/dist/monitor.js +1 -1
  20. package/dist/notifications/fleet-producer.d.ts +4 -1
  21. package/dist/notifications/fleet-producer.js +39 -10
  22. package/dist/notifications/outbox.d.ts +14 -1
  23. package/dist/notifications/outbox.js +51 -10
  24. package/dist/notifications/target-cleanup.d.ts +10 -4
  25. package/dist/notifications/target-cleanup.js +35 -23
  26. package/dist/notifications/task-producer.d.ts +3 -1
  27. package/dist/notifications/task-producer.js +38 -5
  28. package/dist/ops.js +1 -1
  29. package/dist/paths.d.ts +3 -0
  30. package/dist/paths.js +3 -0
  31. package/dist/rooms-tasks/add-member.js +2 -1
  32. package/dist/rooms-tasks/layout.js +2 -1
  33. package/dist/rooms-tasks/task-state.d.ts +1 -0
  34. package/dist/rooms-tasks/task-state.js +27 -11
  35. package/dist/rooms-tasks/types.d.ts +4 -1
  36. package/dist/runner.js +22 -7
  37. package/dist/subscriptions/login.d.ts +4 -3
  38. package/dist/subscriptions/login.js +14 -6
  39. package/dist/subscriptions/service.d.ts +1 -1
  40. package/dist/subscriptions/service.js +4 -4
  41. package/dist/subscriptions/store.js +3 -0
  42. package/dist/tunnel-setup-input.js +7 -1
  43. package/dist/watchdog/query.d.ts +3 -1
  44. package/dist/watchdog/query.js +2 -1
  45. package/dist/web/auth.d.ts +2 -2
  46. package/dist/web/auth.js +4 -4
  47. package/dist/web/events.d.ts +2 -0
  48. package/dist/web/events.js +9 -0
  49. package/dist/web/prefix-gateway.js +20 -10
  50. package/dist/web/resource-views.d.ts +124 -0
  51. package/dist/web/resource-views.js +424 -0
  52. package/dist/web/runtime.js +6 -3
  53. package/dist/web/server.d.ts +3 -0
  54. package/dist/web/server.js +171 -20
  55. package/dist/workspace-enrollment.js +53 -28
  56. package/dist/workspace-replacement.js +28 -10
  57. package/docs/conversation-stream.md +6 -0
  58. package/package.json +1 -1
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');
@@ -1,3 +1,4 @@
1
+ import { publishFleetResourceCommit } from '../application/resource-observer.js';
1
2
  import { createHash } from 'node:crypto';
2
3
  import { existsSync, mkdirSync, readFileSync } from 'node:fs';
3
4
  import { join } from 'node:path';
@@ -62,7 +63,7 @@ export async function requestMemberAddition(input) {
62
63
  })();
63
64
  const template = sealTemplateSnapshot(plan.snapshot, input.cfg.agentTemplates ?? {}, plan.launchDefinitions);
64
65
  const receipt = { taskId, requestId: r.requestId, roleId, hash, state: 'running' };
65
- const save = () => replaceFileAtomically(path, JSON.stringify(receipt) + '\n', 0o600);
66
+ const save = () => { replaceFileAtomically(path, JSON.stringify(receipt) + '\n', 0o600); publishFleetResourceCommit(path); };
66
67
  save();
67
68
  running.add(path);
68
69
  void (input.provision ?? provisionMembers)({ cfg: input.cfg, cowork: input.cowork, configPath: input.configPath, binPath: input.binPath,
@@ -1,3 +1,4 @@
1
+ import { publishFleetResourceCommit } from '../application/resource-observer.js';
1
2
  /** Durable local room-layout controller.
2
3
  * Uses native Cowork operations and a supervising Fleet's exact-instance port.
3
4
  * Unknown mutation outcomes stop for reconciliation; they are never replayed.
@@ -45,7 +46,7 @@ export class RoomLayout {
45
46
  }
46
47
  get cowork() { return this.coworkAdapter ??= this.coworkFactory(); }
47
48
  snapshot() { return JSON.parse(readFileSync(this.file, 'utf8')); }
48
- save(s) { replaceFileAtomically(this.file, JSON.stringify(s, null, 2)); }
49
+ save(s) { replaceFileAtomically(this.file, JSON.stringify(s, null, 2)); publishFleetResourceCommit(this.file); }
49
50
  async verify(i) {
50
51
  need(i.supervisor === this.supervisor.id || i.remote, 'remote binding unsupported');
51
52
  need(i.launch && i.cid && i.session, 'incomplete instance reference');
@@ -39,6 +39,7 @@ export declare function listTasks(filter?: {
39
39
  state?: TaskState | TaskState[];
40
40
  listId?: string;
41
41
  includeDeleting?: boolean;
42
+ strict?: boolean;
42
43
  }): TaskRecord[];
43
44
  export declare function moveTaskToList(id: string, listId: string): TaskRecord;
44
45
  export declare function findByIdempotencyKey(key: string): TaskRecord | undefined;
@@ -1,3 +1,4 @@
1
+ import { publishFleetResourceCommit } from '../application/resource-observer.js';
1
2
  import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, rmSync, renameSync } from 'node:fs';
2
3
  import { join } from 'node:path';
3
4
  import { randomUUID } from 'node:crypto';
@@ -100,15 +101,27 @@ function writeTask(record) {
100
101
  pending.push({ eventId: `task:${record.task_id}:${randomUUID()}`, taskId: record.task_id,
101
102
  title: `Task ${!previous ? 'created' : 'updated'}: ${record.title}`.slice(0, 160),
102
103
  body: `Task ${record.task_id}: ${before ? `${before} → ` : 'created in '}${next}.`,
103
- url: `/fleet/tasks/${record.task_id}` });
104
+ target: { kind: 'task', taskId: record.task_id } });
104
105
  }
105
106
  if (pending.length)
106
107
  persisted.notification_events = pending;
107
108
  else
108
109
  delete persisted.notification_events;
109
110
  replaceFileAtomically(taskPath(record.task_id), JSON.stringify(persisted, null, 2) + '\n');
111
+ publishFleetResourceCommit(taskPath(record.task_id));
110
112
  }
111
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
+ }
112
125
  /** Atomic snapshots contain the events, including intermediate states between polls. */
113
126
  export function pendingTaskNotifications(warn = () => { }) {
114
127
  const result = [];
@@ -127,13 +140,13 @@ export function pendingTaskNotifications(warn = () => { }) {
127
140
  continue;
128
141
  if (value.notification_events === undefined)
129
142
  continue;
130
- if (!Array.isArray(value.notification_events) || value.notification_events.some(e => !e
131
- || typeof e.eventId !== 'string' || !e.eventId.startsWith(`task:${id}:`) || e.eventId.length > 512
132
- || 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
133
- || typeof e.body !== 'string' || !e.body.trim() || e.body.length > 512))
143
+ if (!Array.isArray(value.notification_events))
134
144
  throw new Error('invalid notification events');
135
- if (value.notification_events.length)
136
- 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 });
137
150
  }
138
151
  catch (error) {
139
152
  if (!isNotFoundError(error))
@@ -151,9 +164,9 @@ export function acknowledgeTaskNotification(id, eventId) {
151
164
  if (!existsSync(path))
152
165
  continue;
153
166
  const value = JSON.parse(readFileSync(path, 'utf8'));
154
- if (!value.notification_events?.some(event => event.eventId === eventId))
167
+ if (!value.notification_events?.some(event => typedTaskNotification(event, id) && event.eventId === eventId))
155
168
  continue;
156
- 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);
157
170
  replaceFileAtomically(path, JSON.stringify(value, null, 2) + '\n');
158
171
  if (path.startsWith(retiredNotificationsDir() + '/') && !value.notification_events.length)
159
172
  unlinkSync(path);
@@ -280,7 +293,10 @@ export function listTasks(filter) {
280
293
  if ((!states || states.includes(t.state)) && (!filter?.listId || t.list_id === filter.listId))
281
294
  tasks.push(t);
282
295
  }
283
- catch { /* corrupt file, skip */ }
296
+ catch (error) {
297
+ if (filter?.strict)
298
+ throw error; /* legacy CLI inventory tolerates corrupt files */
299
+ }
284
300
  }
285
301
  const order = new Map(readTaskLists().map((item, index) => [item.list_id, index]));
286
302
  return tasks.sort((a, b) => (order.get(a.list_id) - order.get(b.list_id))
@@ -885,7 +901,7 @@ export function unlinkDeletedTask(id) {
885
901
  // before unlink may replay two copies; both carry identical service dedupe IDs.
886
902
  const removed = { eventId: `task:${id}:deleted`, taskId: id,
887
903
  title: `Task deleted: ${stored.title}`.slice(0, 160), body: `Task ${id}: deleting → deleted.`,
888
- url: `/fleet/tasks?deletedTask=${id}` };
904
+ target: { kind: 'task-deleted', taskId: id } };
889
905
  replaceFileAtomically(join(retiredNotificationsDir(), `${id}.json`), JSON.stringify({ notification_events: [...(stored.notification_events ?? []), removed] }) + '\n');
890
906
  try {
891
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 {
package/dist/runner.js CHANGED
@@ -17,7 +17,7 @@ import { agentDir, stateRoot } from './paths.js';
17
17
  import { loadConfig, findRole, isolationContextFor, resolveSnapshotMonitor, resolvePermissions, } from './config.js';
18
18
  import { getAdapter } from './harness/registry.js';
19
19
  import { createMonitor, probeIdentityPresence, } from './monitor.js';
20
- import { DaemonGenerationObserver, RoleRecoveryController, probeDaemonGeneration, } from './daemon-recovery.js';
20
+ import { DaemonGenerationObserver, RoleRecoveryController, probeDaemonGeneration, acquireSelectedDaemonHealth, } from './daemon-recovery.js';
21
21
  import { realExec } from './exec.js';
22
22
  import { resolveIsolation } from './isolation/policy.js';
23
23
  import { pinSubscriptionForLaunch } from './subscriptions/launch.js';
@@ -48,6 +48,7 @@ export class SupervisorRecycleRequiredError extends Error {
48
48
  this.name = 'SupervisorRecycleRequiredError';
49
49
  }
50
50
  }
51
+ const defaultProbeGeneration = (env) => probeDaemonGeneration((url, init) => globalThis.fetch(url, init), env);
51
52
  const defaultDeps = () => ({
52
53
  prepareAgentOurs: prepareManagedAgent,
53
54
  releaseAgentOurs: releaseManagedAgent,
@@ -64,7 +65,7 @@ const defaultDeps = () => ({
64
65
  now: () => Date.now(),
65
66
  log: line => process.stderr.write(line + '\n'),
66
67
  fetch: (url, init) => globalThis.fetch(url, init),
67
- probeGeneration: env => probeDaemonGeneration((url, init) => globalThis.fetch(url, init), env),
68
+ probeGeneration: defaultProbeGeneration,
68
69
  createMonitor: opts => createMonitor(opts),
69
70
  createOwnerChannel: opts => new OwnerChannel(opts),
70
71
  startAgentSession: (adapter, options) => adapter.start(options),
@@ -622,6 +623,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
622
623
  // Self-reminders live and die with this attempt's runtime binding: every exit
623
624
  // path below stops the dispatcher before that binding is released.
624
625
  let reminderManager;
626
+ let selectedHealth;
625
627
  try {
626
628
  const managedHarness = prepareManagedHarness(role, dir, runCwd, managedService.descriptor, { ...harnessChildEnv(role, launch.env, dir), ...subscription?.env });
627
629
  // Start-stagger: space this launch at least start_stagger_ms after the previous
@@ -669,8 +671,15 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
669
671
  name, identity: role.identity, agentDir: dir, cfg: role.monitor,
670
672
  deps: resolvedMonitorDeps,
671
673
  }) : null;
674
+ // Injected legacy probes keep their test/embedding contract. Production uses
675
+ // one retained owner for generation and identity health in this process.
676
+ const retainHealth = deps.probeGeneration === defaultProbeGeneration
677
+ && !deps.identityProbeDeps ? acquireSelectedDaemonHealth : undefined;
678
+ selectedHealth = retainHealth?.(resolvedMonitorDeps.env);
672
679
  const daemonObserver = new DaemonGenerationObserver();
673
- const initialDaemon = daemonObserver.observe(await deps.probeGeneration(resolvedMonitorDeps.env));
680
+ const initialDaemon = daemonObserver.observe(selectedHealth
681
+ ? (await selectedHealth.sample()).generation
682
+ : await deps.probeGeneration(resolvedMonitorDeps.env));
674
683
  let sessionStartedWithoutDaemonBaseline = initialDaemon.kind !== 'baseline';
675
684
  if (monitor)
676
685
  await monitor.prime({ resetCursor: resetMonitorCursor });
@@ -1113,7 +1122,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
1113
1122
  const now = deps.now();
1114
1123
  if (now >= nextDaemonProbeAt) {
1115
1124
  nextDaemonProbeAt = now + 2_000;
1116
- const observation = daemonObserver.observe(await deps.probeGeneration(resolvedMonitorDeps.env));
1125
+ const observation = daemonObserver.observe(selectedHealth
1126
+ ? (await selectedHealth.sample()).generation
1127
+ : await deps.probeGeneration(resolvedMonitorDeps.env));
1117
1128
  if (observation.kind === 'lost')
1118
1129
  recoveryController.noteLoss(observation.reason);
1119
1130
  if (observation.kind === 'changed' || observation.kind === 'available'
@@ -1147,19 +1158,22 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
1147
1158
  }
1148
1159
  if (temp && now >= nextIdentityPollAt) {
1149
1160
  nextIdentityPollAt = now + TEMP_IDENTITY_POLL_MS;
1150
- const presence = await probeIdentityPresence(role.identity, deps.fetch, resolvedMonitorDeps.env, deps.identityProbeDeps);
1161
+ const presence = selectedHealth
1162
+ ? selectedHealth.presence(role.identity, await selectedHealth.sample())
1163
+ : await probeIdentityPresence(role.identity, deps.fetch, resolvedMonitorDeps.env, deps.identityProbeDeps);
1164
+ const identityObservedAt = deps.now();
1151
1165
  if (presence.state === 'present') {
1152
1166
  identityObserved = true;
1153
1167
  identityAbsentSince = undefined;
1154
1168
  }
1155
1169
  else if (presence.state === 'absent' && identityObserved) {
1156
- identityAbsentSince ??= now;
1170
+ identityAbsentSince ??= identityObservedAt;
1157
1171
  // Require a continuous, time-bounded run of authoritative absence.
1158
1172
  // The first positive observation is the readiness gate: cold harness
1159
1173
  // starts may spend minutes loading the harness and briefing before the
1160
1174
  // agent creates/binds its identity, and absence before then is not a
1161
1175
  // close event. After readiness, debounce a real disappearance.
1162
- if (now - identityAbsentSince >= TEMP_IDENTITY_CLOSE_DEBOUNCE_MS) {
1176
+ if (identityObservedAt - identityAbsentSince >= TEMP_IDENTITY_CLOSE_DEBOUNCE_MS) {
1163
1177
  retirementReason = 'identity-closed';
1164
1178
  deps.log(`[${name}] temporary identity '${role.identity}' closed; retiring session and supervisor`);
1165
1179
  reminderManager?.stop();
@@ -1273,6 +1287,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
1273
1287
  };
1274
1288
  }
1275
1289
  finally {
1290
+ selectedHealth?.release();
1276
1291
  reminderManager?.stop();
1277
1292
  managedService.setReminders?.(undefined);
1278
1293
  await managedService.close(layoutRetiring);
@@ -33,6 +33,7 @@ declare abstract class BaseLogin implements LoginHandle {
33
33
  readonly provider: SubscriptionProvider;
34
34
  readonly owner: string;
35
35
  readonly kind: LoginView['kind'];
36
+ protected readonly changed: () => void;
36
37
  readonly loginId: string;
37
38
  readonly expiresAt: number;
38
39
  state: LoginState;
@@ -42,7 +43,7 @@ declare abstract class BaseLogin implements LoginHandle {
42
43
  done: Promise<boolean>;
43
44
  protected finish: (ok: boolean) => void;
44
45
  private readonly timer;
45
- constructor(provider: SubscriptionProvider, owner: string, kind: LoginView['kind'], now?: number);
46
+ constructor(provider: SubscriptionProvider, owner: string, kind: LoginView['kind'], now?: number, changed?: () => void);
46
47
  view(): LoginView;
47
48
  abstract submitCode(code: string): void;
48
49
  protected abstract stop(): void;
@@ -59,7 +60,7 @@ export declare class ClaudeLogin extends BaseLogin {
59
60
  private continued;
60
61
  /** Resolves with the CLI exit status once it exits. */
61
62
  exited: Promise<number | null>;
62
- constructor(owner: string, bins: SubscriptionBinaries, home: string, profileId: string);
63
+ constructor(owner: string, bins: SubscriptionBinaries, home: string, profileId: string, changed?: () => void);
63
64
  submitCode(code: string): void;
64
65
  protected stop(): void;
65
66
  }
@@ -69,7 +70,7 @@ export declare class CodexDeviceLogin extends BaseLogin {
69
70
  private codexLoginId?;
70
71
  /** Resolves true when the app-server reports a successful login. */
71
72
  completed: Promise<boolean>;
72
- constructor(owner: string, bins: SubscriptionBinaries, home: string, profileId: string);
73
+ constructor(owner: string, bins: SubscriptionBinaries, home: string, profileId: string, changed?: () => void);
73
74
  submitCode(): void;
74
75
  protected stop(): void;
75
76
  }
@@ -26,6 +26,7 @@ class BaseLogin {
26
26
  provider;
27
27
  owner;
28
28
  kind;
29
+ changed;
29
30
  loginId = randomBytes(16).toString('hex');
30
31
  expiresAt;
31
32
  state = 'starting';
@@ -35,10 +36,11 @@ class BaseLogin {
35
36
  done;
36
37
  finish;
37
38
  timer;
38
- constructor(provider, owner, kind, now = Date.now()) {
39
+ constructor(provider, owner, kind, now = Date.now(), changed = () => { }) {
39
40
  this.provider = provider;
40
41
  this.owner = owner;
41
42
  this.kind = kind;
43
+ this.changed = changed;
42
44
  this.expiresAt = now + LOGIN_TTL_MS;
43
45
  this.done = new Promise(resolve => { this.finish = resolve; });
44
46
  this.timer = setTimeout(() => this.end('expired', 'login timed out'), LOGIN_TTL_MS);
@@ -64,6 +66,7 @@ class BaseLogin {
64
66
  clearTimeout(this.timer);
65
67
  this.stop();
66
68
  this.finish(state === 'succeeded');
69
+ this.changed();
67
70
  }
68
71
  /** Called by the service after verifying the new home. */
69
72
  complete(ok, error) { this.end(ok ? 'succeeded' : 'failed', error); }
@@ -76,8 +79,8 @@ export class ClaudeLogin extends BaseLogin {
76
79
  continued = false;
77
80
  /** Resolves with the CLI exit status once it exits. */
78
81
  exited;
79
- constructor(owner, bins, home, profileId) {
80
- super('claude', owner, 'paste_code');
82
+ constructor(owner, bins, home, profileId, changed) {
83
+ super('claude', owner, 'paste_code', Date.now(), changed);
81
84
  const darwin = process.platform === 'darwin';
82
85
  const args = darwin
83
86
  ? ['-q', '/dev/null', bins.claude, 'auth', 'login']
@@ -109,6 +112,7 @@ export class ClaudeLogin extends BaseLogin {
109
112
  }
110
113
  this.url = url;
111
114
  this.state = 'awaiting_user';
115
+ this.changed();
112
116
  }
113
117
  }
114
118
  // Claude asks for Enter after success; the answer carries no secret.
@@ -131,6 +135,7 @@ export class ClaudeLogin extends BaseLogin {
131
135
  throw new FleetError('invalid_request', 'that does not look like a sign-in code');
132
136
  this.submitted = true;
133
137
  this.state = 'verifying';
138
+ this.changed();
134
139
  this.output = '';
135
140
  this.child?.stdin.write(`${code}\r`);
136
141
  }
@@ -151,8 +156,8 @@ export class CodexDeviceLogin extends BaseLogin {
151
156
  codexLoginId;
152
157
  /** Resolves true when the app-server reports a successful login. */
153
158
  completed;
154
- constructor(owner, bins, home, profileId) {
155
- super('codex', owner, 'device_code');
159
+ constructor(owner, bins, home, profileId, changed) {
160
+ super('codex', owner, 'device_code', Date.now(), changed);
156
161
  this.completed = (async () => {
157
162
  try {
158
163
  this.server = await CodexAppServer.start(bins.codex, profileEnv('CODEX_HOME', profileId, home));
@@ -184,9 +189,12 @@ export class CodexDeviceLogin extends BaseLogin {
184
189
  this.url = url;
185
190
  this.userCode = String(started.userCode).slice(0, 32);
186
191
  this.state = 'awaiting_user';
192
+ this.changed();
187
193
  const ok = await Promise.race([completion, this.done.then(() => false)]);
188
- if (this.state === 'awaiting_user')
194
+ if (this.state === 'awaiting_user') {
189
195
  this.state = 'verifying';
196
+ this.changed();
197
+ }
190
198
  return ok;
191
199
  }
192
200
  catch {
@@ -71,7 +71,7 @@ export declare class SubscriptionService {
71
71
  private readonly logins;
72
72
  private readonly probes;
73
73
  constructor(options: SubscriptionServiceOptions);
74
- list(): Promise<ProviderView[]>;
74
+ list(observeOnly?: boolean): Promise<ProviderView[]>;
75
75
  /** Health + account + (Codex) usage for one profile. Concurrent calls share one run. */
76
76
  check(provider: SubscriptionProvider, profileId: string): Promise<CheckResult>;
77
77
  private checkClaude;
@@ -23,7 +23,7 @@ export class SubscriptionService {
23
23
  this.bins = options.binaries ?? defaultBinaries();
24
24
  this.now = options.now ?? Date.now;
25
25
  }
26
- async list() {
26
+ async list(observeOnly = false) {
27
27
  const state = readSubscriptionState();
28
28
  const pins = await this.pins();
29
29
  return SUBSCRIPTION_PROVIDERS.map(provider => {
@@ -31,7 +31,7 @@ export class SubscriptionService {
31
31
  const profiles = p.profiles.map(profile => {
32
32
  const key = `${provider}/${profile.id}`;
33
33
  const cached = this.checks.get(key);
34
- if (!cached || this.now() - Date.parse(cached.health.checkedAt ?? '0') > STALE_CHECK_MS)
34
+ if (!observeOnly && (!cached || this.now() - Date.parse(cached.health.checkedAt ?? '0') > STALE_CHECK_MS))
35
35
  void this.check(provider, profile.id).catch(() => { });
36
36
  const observed = provider === 'claude' ? this.observedUsage(pins, profile.id) : undefined;
37
37
  const probed = provider === 'claude' ? this.probedUsage(profile.id) : undefined;
@@ -135,8 +135,8 @@ export class SubscriptionService {
135
135
  }
136
136
  const { id: profileId, home } = createProfileHome(provider);
137
137
  const login = provider === 'claude'
138
- ? new ClaudeLogin(owner, this.bins, home, profileId)
139
- : new CodexDeviceLogin(owner, this.bins, home, profileId);
138
+ ? new ClaudeLogin(owner, this.bins, home, profileId, this.options.onChange)
139
+ : new CodexDeviceLogin(owner, this.bins, home, profileId, this.options.onChange);
140
140
  const loginId = login.view().loginId;
141
141
  this.logins.set(loginId, login);
142
142
  void this.completeLogin(provider, profileId, login);
@@ -1,3 +1,4 @@
1
+ import { publishFleetResourceCommit } from '../application/resource-observer.js';
1
2
  import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync } from 'node:fs';
2
3
  import { randomBytes } from 'node:crypto';
3
4
  import { join } from 'node:path';
@@ -94,6 +95,7 @@ export async function updateSubscriptionState(fn) {
94
95
  const state = readSubscriptionState();
95
96
  const result = fn(state);
96
97
  replaceFileAtomically(stateFile(), `${JSON.stringify(state, null, 2)}\n`, 0o600);
98
+ publishFleetResourceCommit(stateFile());
97
99
  return result;
98
100
  });
99
101
  }
@@ -137,4 +139,5 @@ export function readPin(stateDir, provider) {
137
139
  }
138
140
  export function writePin(stateDir, pin) {
139
141
  replaceFileAtomically(join(stateDir, PIN_FILE), `${JSON.stringify({ [pin.provider]: pin })}\n`, 0o600);
142
+ publishFleetResourceCommit(join(stateDir, PIN_FILE));
140
143
  }
@@ -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');
@@ -39,7 +39,9 @@ export declare function buildWatchdogFindings(cfg: {
39
39
  * polling. `now` is injectable (defaults to Date.now) so the TTL boundary is
40
40
  * unit-testable without real timers.
41
41
  */
42
- export declare function cachedWatchdogFindingsProvider(build: () => Map<string, WatchdogRoleFinding>, ttlMs: number, now?: () => number): () => Map<string, WatchdogRoleFinding>;
42
+ export declare function cachedWatchdogFindingsProvider(build: () => Map<string, WatchdogRoleFinding>, ttlMs: number, now?: () => number): (() => Map<string, WatchdogRoleFinding>) & {
43
+ invalidate: () => void;
44
+ };
43
45
  export interface WatchdogSummary {
44
46
  name: string;
45
47
  enabled: boolean;
@@ -59,12 +59,13 @@ export function buildWatchdogFindings(cfg, latestReport) {
59
59
  */
60
60
  export function cachedWatchdogFindingsProvider(build, ttlMs, now = Date.now) {
61
61
  let cached;
62
- return () => {
62
+ const read = () => {
63
63
  const at = now();
64
64
  if (!cached || at - cached.at >= ttlMs)
65
65
  cached = { at, value: build() };
66
66
  return cached.value;
67
67
  };
68
+ return Object.assign(read, { invalidate: () => { cached = undefined; } });
68
69
  }
69
70
  const DEFAULT_REPORTS_LIMIT = 50;
70
71
  const MAX_REPORTS_LIMIT = 500;
@@ -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;
@@ -8,6 +8,8 @@ export interface FleetEvent {
8
8
  }
9
9
  export declare class FleetEventBus {
10
10
  private readonly events;
11
+ private readonly resourceListeners;
12
+ subscribeResources(listener: () => void): () => void;
11
13
  private readonly clients;
12
14
  publish(kind: string, payload: unknown, roleId?: string): FleetEvent;
13
15
  attach(socket: WebSocket, lastEventId?: string): () => void;
@@ -1,6 +1,8 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  export class FleetEventBus {
3
3
  events = [];
4
+ resourceListeners = new Set();
5
+ subscribeResources(listener) { this.resourceListeners.add(listener); return () => { this.resourceListeners.delete(listener); }; }
4
6
  clients = new Set();
5
7
  publish(kind, payload, roleId) {
6
8
  const event = { eventId: randomUUID(), at: new Date().toISOString(), roleId, kind, payload };
@@ -12,6 +14,12 @@ export class FleetEventBus {
12
14
  if (socket.readyState === socket.OPEN && socket.bufferedAmount < 1024 * 1024)
13
15
  socket.send(frame);
14
16
  }
17
+ for (const listener of this.resourceListeners) {
18
+ try {
19
+ listener();
20
+ }
21
+ catch { }
22
+ }
15
23
  return event;
16
24
  }
17
25
  attach(socket, lastEventId) {
@@ -30,5 +38,6 @@ export class FleetEventBus {
30
38
  for (const socket of this.clients)
31
39
  socket.close(1001, 'server shutdown');
32
40
  this.clients.clear();
41
+ this.resourceListeners.clear();
33
42
  }
34
43
  }
@@ -103,17 +103,24 @@ export function createPrefixGateway(options) {
103
103
  delete headers['sec-websocket-protocol'];
104
104
  return { ...route, headers, session, authHeaders, deviceSocket: socketCredential !== undefined };
105
105
  };
106
- const track = (authHeaders, session, close) => {
106
+ const track = (authHeaders, session, close, neutral = false) => {
107
107
  if (!session)
108
108
  return () => { };
109
109
  // The session authenticated for this request, with its own deadline, not whatever is current later.
110
110
  const unbind = auth.bindTransport(session, close);
111
- const timer = setInterval(() => { try {
112
- auth.authenticate({ headers: authHeaders });
113
- }
114
- catch {
115
- close();
116
- } }, 30_000);
111
+ const timer = setInterval(() => {
112
+ if (neutral) {
113
+ if (!auth.sessionValid(session))
114
+ close();
115
+ }
116
+ else
117
+ try {
118
+ auth.authenticate({ headers: authHeaders });
119
+ }
120
+ catch {
121
+ close();
122
+ }
123
+ }, neutral ? 1000 : 30_000);
117
124
  timer.unref();
118
125
  return () => { clearInterval(timer); unbind(); };
119
126
  };
@@ -123,7 +130,7 @@ export function createPrefixGateway(options) {
123
130
  const accountCors = (req) => {
124
131
  const origin = auth?.accountOrigin(req);
125
132
  return origin ? { 'access-control-allow-origin': origin, vary: 'Origin',
126
- 'access-control-expose-headers': 'Accept-Ranges, Content-Disposition, Content-Length, Content-Range, Content-Type, ETag, X-Ours-Workspace-Auth, X-Ours-Request-Id' } : {};
133
+ 'access-control-expose-headers': 'Accept-Ranges, Content-Disposition, Content-Length, Content-Range, Content-Type, ETag, X-Ours-Workspace-Auth, X-Ours-Request-Id, X-Resource-Producer, X-Resource-Revision, X-Resource-Source-Generation, X-Resource-Source-Position, X-Resource-Connection, X-Resource-View, X-Resource-Epoch, X-Resource-Baseline' } : {};
127
134
  };
128
135
  const server = createServer((req, res) => {
129
136
  const cors = accountCors(req);
@@ -134,7 +141,7 @@ export function createPrefixGateway(options) {
134
141
  auth.validateBoundary({ headers: req.headers, method: req.method, url: req.url }, false);
135
142
  res.writeHead(204, { ...cors, 'cache-control': 'no-store', 'access-control-max-age': '600',
136
143
  'access-control-allow-methods': 'GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS',
137
- 'access-control-allow-headers': 'Authorization, Content-Type, Idempotency-Key, If-None-Match, Range, X-CSRF-Token, X-Ours-Messenger-CSRF, X-Voice-Duration' });
144
+ 'access-control-allow-headers': 'Authorization, Content-Type, Idempotency-Key, If-None-Match, Range, X-CSRF-Token, X-Ours-Messenger-CSRF, X-Voice-Duration, X-Resource-Connection, X-Resource-View, X-Resource-Epoch, X-Resource-Baseline' });
138
145
  res.end();
139
146
  return;
140
147
  }
@@ -186,7 +193,10 @@ export function createPrefixGateway(options) {
186
193
  res.writeHead(502, { ...(route.service ? cors : {}), 'content-type': 'application/json' });
187
194
  res.end(JSON.stringify({ error: { code: 'upstream_unavailable', message: 'Service is unavailable' } }));
188
195
  });
189
- const untrack = track(route.authHeaders, route.session, () => { upstream.destroy(); res.destroy(); });
196
+ const neutral = req.method === 'GET' && route.service?.prefix === '/notifications'
197
+ && new URL(req.url ?? '/', 'http://gateway.invalid').searchParams.get('resources') === '1'
198
+ && new URL(req.url ?? '/', 'http://gateway.invalid').pathname === '/notifications/api/v1/events';
199
+ const untrack = track(route.authHeaders, route.session, () => { upstream.destroy(); res.destroy(); }, neutral);
190
200
  res.on('close', untrack);
191
201
  req.on('aborted', () => upstream.destroy());
192
202
  res.on('close', () => upstream.destroy());