@ours.network/fleet 1.2.0-nightly.5 → 1.2.0-nightly.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +263 -53
- package/dist/account-origin.d.ts +2 -0
- package/dist/account-origin.js +6 -0
- package/dist/agent-ours/bridge.js +9 -0
- package/dist/agent-ours/correspondence.d.ts +10 -0
- package/dist/agent-ours/correspondence.js +1 -0
- package/dist/agent-ours/harness.js +8 -0
- package/dist/agent-ours/runtime.d.ts +6 -0
- package/dist/agent-ours/runtime.js +36 -0
- package/dist/agent-ours/service.js +33 -13
- package/dist/application/codex-models.d.ts +15 -0
- package/dist/application/codex-models.js +56 -0
- package/dist/application/conversation-history.d.ts +25 -0
- package/dist/application/conversation-history.js +189 -0
- package/dist/application/fleet-query-service.d.ts +1 -1
- package/dist/application/fleet-query-service.js +2 -2
- package/dist/application/legacy-supervisor-identity.d.ts +7 -0
- package/dist/application/legacy-supervisor-identity.js +43 -0
- package/dist/application/model-catalog.d.ts +1 -1
- package/dist/application/model-catalog.js +5 -2
- package/dist/application/preset-provenance.d.ts +19 -0
- package/dist/application/preset-provenance.js +59 -0
- package/dist/application/role-creation-service.js +1 -1
- package/dist/application/role-removal-service.js +2 -0
- package/dist/application/role-repository.js +2 -2
- package/dist/application/room-layout-definitions.d.ts +45 -0
- package/dist/application/room-layout-definitions.js +130 -0
- package/dist/application/session-control.d.ts +30 -0
- package/dist/application/session-control.js +72 -3
- package/dist/application/supervisor-ours-tools.d.ts +159 -0
- package/dist/application/supervisor-ours-tools.js +113 -0
- package/dist/application/task-layouts.d.ts +91 -0
- package/dist/application/task-layouts.js +249 -0
- package/dist/application/task-room-service.d.ts +99 -3
- package/dist/application/task-room-service.js +296 -31
- package/dist/atomic-file.d.ts +5 -0
- package/dist/atomic-file.js +62 -1
- package/dist/briefing.d.ts +2 -0
- package/dist/briefing.js +8 -1
- package/dist/build-info.json +7 -4
- package/dist/capabilities.d.ts +6 -1
- package/dist/capabilities.js +8 -0
- package/dist/cli.js +376 -34
- package/dist/client-profile.d.ts +5 -17
- package/dist/client-profile.js +4 -127
- package/dist/config.d.ts +28 -0
- package/dist/config.js +41 -6
- package/dist/creation.js +2 -2
- package/dist/daemon-recovery.d.ts +1 -8
- package/dist/daemon-recovery.js +8 -113
- package/dist/docs.d.ts +3 -3
- package/dist/docs.js +126 -22
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +55 -60
- package/dist/erased-resources.d.ts +9 -0
- package/dist/erased-resources.js +87 -0
- package/dist/fleet-command-audit.d.ts +7 -0
- package/dist/fleet-command-audit.js +26 -9
- package/dist/harness/claude-code.js +9 -1
- package/dist/harness/claude-runtime.d.ts +12 -0
- package/dist/harness/claude-runtime.js +43 -0
- package/dist/harness/codex-app-server-proxy.d.ts +4 -1
- package/dist/harness/codex-app-server-proxy.js +41 -5
- package/dist/harness/codex-reasoning.d.ts +5 -0
- package/dist/harness/codex-reasoning.js +12 -0
- package/dist/harness/codex-runtime.d.ts +11 -1
- package/dist/harness/codex-runtime.js +31 -4
- package/dist/harness/codex.d.ts +3 -2
- package/dist/harness/codex.js +20 -11
- package/dist/init-wizard.d.ts +2 -0
- package/dist/init-wizard.js +38 -19
- package/dist/isolation/policy.js +2 -0
- package/dist/isolation/types.d.ts +5 -0
- package/dist/managed-cli.d.ts +253 -0
- package/dist/managed-cli.js +923 -0
- package/dist/minimal-setup.d.ts +2 -0
- package/dist/minimal-setup.js +44 -0
- package/dist/monitor.d.ts +1 -34
- package/dist/monitor.js +24 -151
- package/dist/notifications/fleet-producer.d.ts +19 -0
- package/dist/notifications/fleet-producer.js +208 -0
- package/dist/notifications/outbox.d.ts +46 -0
- package/dist/notifications/outbox.js +158 -0
- package/dist/notifications/target-cleanup.d.ts +8 -0
- package/dist/notifications/target-cleanup.js +41 -0
- package/dist/ops.js +5 -1
- package/dist/owner-channel/channel.d.ts +3 -0
- package/dist/owner-channel/channel.js +34 -6
- package/dist/owner-channel/commands.d.ts +4 -0
- package/dist/owner-channel/commands.js +8 -0
- package/dist/owner-channel/lifecycle-outbox.js +4 -3
- package/dist/owner-channel/ours-client.js +2 -17
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +2 -0
- package/dist/preset-migration.js +25 -0
- package/dist/resolved-plan.js +1 -0
- package/dist/rooms-tasks/add-member.d.ts +48 -0
- package/dist/rooms-tasks/add-member.js +83 -0
- package/dist/rooms-tasks/archived-absence.d.ts +8 -0
- package/dist/rooms-tasks/archived-absence.js +48 -0
- package/dist/rooms-tasks/cli.js +34 -5
- package/dist/rooms-tasks/close.d.ts +5 -2
- package/dist/rooms-tasks/close.js +208 -28
- package/dist/rooms-tasks/config.d.ts +1 -1
- package/dist/rooms-tasks/config.js +11 -4
- package/dist/rooms-tasks/cowork-adapter.d.ts +6 -4
- package/dist/rooms-tasks/cowork-adapter.js +8 -121
- package/dist/rooms-tasks/deletion.d.ts +1 -1
- package/dist/rooms-tasks/deletion.js +74 -5
- package/dist/rooms-tasks/erasure.d.ts +6 -0
- package/dist/rooms-tasks/erasure.js +169 -0
- package/dist/rooms-tasks/external-worker.d.ts +1 -1
- package/dist/rooms-tasks/external-worker.js +3 -4
- package/dist/rooms-tasks/layout-binding-client.d.ts +11 -0
- package/dist/rooms-tasks/layout-binding-client.js +56 -0
- package/dist/rooms-tasks/layout-binding-grants.d.ts +21 -0
- package/dist/rooms-tasks/layout-binding-grants.js +128 -0
- package/dist/rooms-tasks/layout-cli.d.ts +4 -0
- package/dist/rooms-tasks/layout-cli.js +107 -0
- package/dist/rooms-tasks/layout-config.d.ts +11 -0
- package/dist/rooms-tasks/layout-config.js +89 -0
- package/dist/rooms-tasks/layout-control.d.ts +21 -0
- package/dist/rooms-tasks/layout-control.js +52 -0
- package/dist/rooms-tasks/layout-service.d.ts +42 -0
- package/dist/rooms-tasks/layout-service.js +185 -0
- package/dist/rooms-tasks/layout.d.ts +114 -0
- package/dist/rooms-tasks/layout.js +250 -0
- package/dist/rooms-tasks/live-readiness.d.ts +18 -0
- package/dist/rooms-tasks/live-readiness.js +85 -0
- package/dist/rooms-tasks/member-startup.d.ts +1 -0
- package/dist/rooms-tasks/member-startup.js +2 -0
- package/dist/rooms-tasks/provision.d.ts +14 -0
- package/dist/rooms-tasks/provision.js +173 -46
- package/dist/rooms-tasks/room-state.d.ts +4 -2
- package/dist/rooms-tasks/room-state.js +53 -8
- package/dist/rooms-tasks/task-state.d.ts +42 -7
- package/dist/rooms-tasks/task-state.js +153 -23
- package/dist/rooms-tasks/types.d.ts +28 -0
- package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
- package/dist/rooms-tasks/workspace-artifacts.js +93 -0
- package/dist/rooms-tasks/workspace.d.ts +26 -0
- package/dist/rooms-tasks/workspace.js +195 -0
- package/dist/runner.d.ts +19 -3
- package/dist/runner.js +128 -45
- package/dist/session/acp.d.ts +9 -1
- package/dist/session/acp.js +31 -6
- package/dist/session/arbiter.d.ts +5 -0
- package/dist/session/arbiter.js +28 -2
- package/dist/session/codex-app-server.d.ts +5 -0
- package/dist/session/codex-app-server.js +36 -3
- package/dist/session/control.d.ts +12 -1
- package/dist/session/control.js +68 -3
- package/dist/session/conversation-normalizer.d.ts +2 -1
- package/dist/session/conversation-normalizer.js +1 -1
- package/dist/session/conversation-store.d.ts +1 -1
- package/dist/session/conversation-store.js +7 -11
- package/dist/session/conversation-types.d.ts +7 -3
- package/dist/session/task-notice.d.ts +14 -0
- package/dist/session/task-notice.js +13 -0
- package/dist/session/types.d.ts +6 -0
- package/dist/setup-tunnel-preflight.d.ts +34 -0
- package/dist/setup-tunnel-preflight.js +62 -0
- package/dist/socket-path.d.ts +2 -0
- package/dist/socket-path.js +22 -0
- package/dist/spawn.js +8 -1
- package/dist/startup-readiness.d.ts +13 -0
- package/dist/startup-readiness.js +38 -0
- package/dist/subscriptions/cli.d.ts +91 -0
- package/dist/subscriptions/cli.js +223 -0
- package/dist/subscriptions/launch.d.ts +37 -0
- package/dist/subscriptions/launch.js +93 -0
- package/dist/subscriptions/login.d.ts +75 -0
- package/dist/subscriptions/login.js +182 -0
- package/dist/subscriptions/observed.d.ts +7 -0
- package/dist/subscriptions/observed.js +44 -0
- package/dist/subscriptions/probe.d.ts +10 -0
- package/dist/subscriptions/probe.js +73 -0
- package/dist/subscriptions/service.d.ts +102 -0
- package/dist/subscriptions/service.js +364 -0
- package/dist/subscriptions/store.d.ts +71 -0
- package/dist/subscriptions/store.js +140 -0
- package/dist/supervisor/launchd.js +2 -1
- package/dist/supervisor/systemd.js +9 -23
- package/dist/temp-idle.d.ts +17 -0
- package/dist/temp-idle.js +42 -0
- package/dist/temp-lifecycle.d.ts +6 -1
- package/dist/temp-lifecycle.js +29 -13
- package/dist/tunnel-setup-completion.d.ts +7 -0
- package/dist/tunnel-setup-completion.js +13 -0
- package/dist/tunnel-setup-input.d.ts +19 -0
- package/dist/tunnel-setup-input.js +124 -0
- package/dist/web/agent-attachments.d.ts +29 -0
- package/dist/web/agent-attachments.js +162 -0
- package/dist/web/auth.d.ts +29 -1
- package/dist/web/auth.js +82 -7
- package/dist/web/available-port.d.ts +2 -0
- package/dist/web/available-port.js +23 -0
- package/dist/web/control.d.ts +3 -3
- package/dist/web/control.js +10 -8
- package/dist/web/fleet-config-service.d.ts +4 -1
- package/dist/web/fleet-config-service.js +46 -15
- package/dist/web/prefix-gateway.d.ts +32 -0
- package/dist/web/prefix-gateway.js +234 -0
- package/dist/web/runtime.d.ts +3 -1
- package/dist/web/runtime.js +116 -31
- package/dist/web/server.d.ts +16 -0
- package/dist/web/server.js +588 -53
- package/dist/web/service.d.ts +11 -2
- package/dist/web/service.js +30 -4
- package/dist/web/workspace-devices.d.ts +36 -0
- package/dist/web/workspace-devices.js +101 -0
- package/dist/web/workspace-tunnel.d.ts +1 -0
- package/dist/web/workspace-tunnel.js +34 -0
- package/dist/workspace-enrollment.d.ts +82 -0
- package/dist/workspace-enrollment.js +566 -0
- package/dist/workspace-replacement.d.ts +66 -0
- package/dist/workspace-replacement.js +220 -0
- package/docs/live-voice.md +13 -0
- package/docs/room-layouts.md +227 -0
- package/docs/task-workspaces.md +130 -0
- package/docs/validation/managed-cli-permissions.md +421 -0
- package/examples/room-layouts/fresh.yaml +27 -0
- package/examples/room-layouts/scoped.yaml +21 -0
- package/examples/room-layouts/shared.yaml +19 -0
- package/package.json +15 -18
- package/presets/README.md +78 -0
- package/presets/brain-catalog.json +8 -2
- package/presets/fleet/agent_templates/Critic.yaml +1 -1
- package/presets/fleet/agent_templates/Developer.yaml +1 -1
- package/presets/fleet/agent_templates/Engineer.yaml +5 -0
- package/presets/fleet/agent_templates/LocalCoordinator.yaml +1 -1
- package/presets/fleet/agents/FleetCoordinator.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-1-high.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-low.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-max.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-medium.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-1-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-fable-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-fable-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/claude-haiku-4-5-20251001-default.yaml +4 -0
- package/presets/fleet/brains/claude-opus-5-5-high.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-low.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-max.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-medium.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-5-xhigh.yaml +5 -0
- package/presets/fleet/brains/claude-opus-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-opus-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-high.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-low.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-max.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-medium.yaml +1 -1
- package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-1-sol-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-1-sol-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +1 -1
- package/presets/fleet/brains/codex-gpt-6-luna-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-luna-xhigh.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-high.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-low.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-max.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-medium.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-ultra.yaml +5 -0
- package/presets/fleet/brains/codex-gpt-6-sol-xhigh.yaml +5 -0
- package/presets/fleet/roles/Coordinator.yaml +34 -11
- package/presets/fleet/roles/Engineer.yaml +40 -0
- package/presets/fleet/room_layouts/engineering.yaml +25 -0
- package/presets/fleet/room_layouts/pair.yaml +24 -0
- package/presets/fleet/room_layouts/single.yaml +17 -0
- package/presets/fleet/room_layouts/team.yaml +30 -0
- package/presets/fleet/room_templates/engineering.yaml +19 -0
- package/presets/manifest.json +2 -2
- package/dist/web-app/assets/index-59GF-gsZ.css +0 -1
- package/dist/web-app/assets/index-C0h3ALvs.js +0 -10
- package/dist/web-app/icons/ours-fleet-maskable.svg +0 -4
- package/dist/web-app/icons/ours-fleet.svg +0 -4
- package/dist/web-app/index.html +0 -17
- package/dist/web-app/manifest.webmanifest +0 -15
- package/dist/web-app/offline.html +0 -18
- package/dist/web-app/sw.js +0 -51
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import { validateAccountOrigin } from './account-origin.js';
|
|
2
|
+
import { lstatSync, readFileSync, readdirSync, mkdirSync, existsSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { readClientProfile } from './client-profile.js';
|
|
5
|
+
import { stateRoot, defaultConfigPath, workspaceOwnerInvite } from './paths.js';
|
|
6
|
+
import { replaceFileAtomically } from './atomic-file.js';
|
|
7
|
+
import { loadConfig, splitRootFor } from './config.js';
|
|
8
|
+
import { parse, parseDocument, stringify } from 'yaml';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { attachOursClient } from '@ours.network/sdk/client';
|
|
11
|
+
import { preflightInitPaths } from './init-wizard.js';
|
|
12
|
+
import { WebAccessStore } from './web/access.js';
|
|
13
|
+
import { WorkspaceDeviceStore } from './web/workspace-devices.js';
|
|
14
|
+
export function readWorkspacePayload(file) {
|
|
15
|
+
const path = resolve(file), stat = lstatSync(path);
|
|
16
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0 || stat.size > 32768)
|
|
17
|
+
throw Error('Setup payload requires an owned private regular file (chmod 600)');
|
|
18
|
+
return decodeWorkspacePayload(readFileSync(path, 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
export function decodeWorkspacePayload(input, options = {}) {
|
|
21
|
+
let p;
|
|
22
|
+
try {
|
|
23
|
+
p = JSON.parse(Buffer.from(input.trim(), 'base64url').toString());
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw Error('Invalid workspace payload');
|
|
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)
|
|
29
|
+
throw Error('Workspace payload is invalid or expired');
|
|
30
|
+
for (const k of ['connectorToken', 'invitation', 'name', 'surname']) {
|
|
31
|
+
if (k === 'connectorToken' && p[k] === undefined)
|
|
32
|
+
continue;
|
|
33
|
+
if (typeof p[k] !== 'string' || !p[k] || p[k].length > 8192 || /[\x00-\x1f\x7f]/.test(p[k]))
|
|
34
|
+
throw Error('Invalid workspace payload');
|
|
35
|
+
}
|
|
36
|
+
return p;
|
|
37
|
+
}
|
|
38
|
+
/** Credential-free renewal is allowed only on the host already holding this private connector. */
|
|
39
|
+
export function checkWorkspaceConnector(payload) {
|
|
40
|
+
if (payload.connectorToken !== undefined)
|
|
41
|
+
return;
|
|
42
|
+
const reject = () => { throw Error('This command renews an existing host without a connector credential. Restore its owned private connector file, or remove and re-create the workspace in the App before setting it up again'); };
|
|
43
|
+
const dir = join(stateRoot(), 'workspace');
|
|
44
|
+
let previous;
|
|
45
|
+
try {
|
|
46
|
+
previous = JSON.parse(readFileSync(join(dir, 'binding.json'), 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
reject();
|
|
50
|
+
}
|
|
51
|
+
if (previous.workspaceId !== payload.challenge.workspaceId || validateAccountOrigin(previous.appOrigin ?? 'https://app.ours.network') !== payload.appOrigin || (previous.serverCid && previous.serverCid.toUpperCase() !== payload.serverCid.toUpperCase()))
|
|
52
|
+
reject();
|
|
53
|
+
try {
|
|
54
|
+
const file = join(dir, 'connector'), stat = lstatSync(file);
|
|
55
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0 || stat.size > 8193 || !readFileSync(file, 'utf8').trim())
|
|
56
|
+
reject();
|
|
57
|
+
const tunnel = JSON.parse(readFileSync(join(dir, 'tunnel.json'), 'utf8'));
|
|
58
|
+
if (tunnel.origin !== `https://${payload.hostname}` || tunnel.tokenFile !== file)
|
|
59
|
+
reject();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
reject();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const GENERATED_VERSION = 'ours.network/fleet/v2';
|
|
66
|
+
/** The rooms section tunnel setup writes for the enrolled Human root. */
|
|
67
|
+
const generatedRooms = (rootCid, inviteFile) => ({ rooms: { owner: { provider: 'messenger-server', expected_cid: rootCid, public_invite_file: inviteFile, role: 'Owner' }, defaults: { attach_owner: true, close_when_task_done: true } } });
|
|
68
|
+
/**
|
|
69
|
+
* True only when this configuration is, key for key, what tunnel setup generated itself and nothing
|
|
70
|
+
* else was added beside it. Only then may its Owner entry be treated as the record of an enrollment.
|
|
71
|
+
*/
|
|
72
|
+
function generatedConfigurationOnly(configuration) {
|
|
73
|
+
let document;
|
|
74
|
+
try {
|
|
75
|
+
document = parse(readFileSync(configuration, 'utf8'));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const only = (value, keys) => !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).sort().join() === [...keys].sort().join();
|
|
81
|
+
if (!only(document, ['api_version', 'rooms']) || document.api_version !== GENERATED_VERSION)
|
|
82
|
+
return false;
|
|
83
|
+
const rooms = document.rooms;
|
|
84
|
+
if (!only(rooms, ['owner', 'defaults']) || !only(rooms.owner, ['provider', 'expected_cid', 'public_invite_file', 'role']) || !only(rooms.defaults, ['attach_owner', 'close_when_task_done']))
|
|
85
|
+
return false;
|
|
86
|
+
if (rooms.owner.provider !== 'messenger-server' || rooms.owner.role !== 'Owner' || rooms.owner.public_invite_file !== workspaceOwnerInvite() || typeof rooms.owner.expected_cid !== 'string' || !/^[a-f0-9]{64}$/i.test(rooms.owner.expected_cid)
|
|
87
|
+
|| rooms.defaults.attach_owner !== true || rooms.defaults.close_when_task_done !== true)
|
|
88
|
+
return false;
|
|
89
|
+
const split = splitRootFor(configuration);
|
|
90
|
+
if (!existsSync(split))
|
|
91
|
+
return true;
|
|
92
|
+
const entries = readdirSync(split);
|
|
93
|
+
return entries.every(entry => entry === 'agents') && (!entries.length || (lstatSync(join(split, 'agents')).isDirectory() && readdirSync(join(split, 'agents')).length === 0));
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* The Owner entry of a generated configuration records an enrollment whose host state is gone:
|
|
97
|
+
* no binding and no invitation remain. Nothing else is ever replaced.
|
|
98
|
+
*/
|
|
99
|
+
const recordsLostEnrollment = (configuration) => !existsSync(join(stateRoot(), 'workspace', 'binding.json')) && !existsSync(workspaceOwnerInvite()) && generatedConfigurationOnly(configuration);
|
|
100
|
+
function hostMessenger() {
|
|
101
|
+
const profile = readClientProfile();
|
|
102
|
+
const base = new URL(profile.endpoint);
|
|
103
|
+
base.pathname = base.pathname.replace(/\/daemon\/?$/, '/messenger/');
|
|
104
|
+
if (!base.pathname.endsWith('/messenger/'))
|
|
105
|
+
throw Error('Workspace setup requires the supported gateway client profile');
|
|
106
|
+
const secret = readFileSync(profile.credentialPath, 'utf8').trim();
|
|
107
|
+
return async (path, value) => { const response = await fetch(new URL('api/' + path, base), { method: value === undefined ? 'GET' : 'POST', headers: { 'X-Ours-Api-Token': secret, ...(value === undefined ? {} : { 'Content-Type': 'application/json', 'Origin': base.origin, 'X-Ours-Messenger-CSRF': '1' }) }, ...(value === undefined ? {} : { body: JSON.stringify(value) }), signal: AbortSignal.timeout(value === undefined ? 15000 : 60000), redirect: 'error' }); if (!response.ok) {
|
|
108
|
+
await response.body?.cancel();
|
|
109
|
+
throw Object.assign(Error('Workspace Messenger enrollment failed (HTTP ' + response.status + '); request a fresh setup payload before retrying'), { status: response.status });
|
|
110
|
+
} return response.json(); };
|
|
111
|
+
}
|
|
112
|
+
const daemon = (attach, purpose) => { const profile = readClientProfile(); return attach({ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId, credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken: `ours-fleet-workspace-${purpose}-${process.pid}-${randomUUID()}`, env: {} }); };
|
|
113
|
+
const CID = /^[a-f0-9]{64}$/i, same = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
114
|
+
/**
|
|
115
|
+
* Who this host's Messenger is. Messenger either runs as the Human root itself, or as the person's own
|
|
116
|
+
* identity and then names the root the daemon describes for it. That is accepted only when this host's
|
|
117
|
+
* daemon, asked by Fleet itself, lists exactly one Human root, that root is the named one, and the
|
|
118
|
+
* Messenger identity is listed as a permanent identity under it.
|
|
119
|
+
*/
|
|
120
|
+
async function messengerIdentity(request, attach) {
|
|
121
|
+
const bound = await request('workspace/enrollment-identity');
|
|
122
|
+
if (bound.rootCid === undefined)
|
|
123
|
+
return { cid: bound.cid, preserveProfile: bound.preserveProfile === true };
|
|
124
|
+
if (typeof bound.cid !== 'string' || !CID.test(bound.cid) || typeof bound.rootCid !== 'string' || !CID.test(bound.rootCid) || same(bound.cid, bound.rootCid))
|
|
125
|
+
throw Error('Bound Messenger identity unavailable');
|
|
126
|
+
const root = await humanRoot(attach, bound.rootCid, bound.cid);
|
|
127
|
+
return { cid: bound.cid, preserveProfile: bound.preserveProfile === true, root };
|
|
128
|
+
}
|
|
129
|
+
/** This host's only Human root, as its daemon lists it; optionally with a permanent identity that must be listed under it. */
|
|
130
|
+
async function humanRoot(attach, rootCid, under) {
|
|
131
|
+
const client = await daemon(attach, 'identities');
|
|
132
|
+
const rows = await client.listIdentities().finally(() => client.close());
|
|
133
|
+
const roots = rows.filter(row => 'kind' in row && row.kind === 'root'), root = roots[0], row = under === undefined ? undefined : rows.find(row => 'cid' in row && same(row.cid, under));
|
|
134
|
+
if (roots.length !== 1 || !('kind' in root) || !same(root.cid, rootCid) || (under !== undefined && (!row || !('kind' in row) || row.kind !== 'role' || row.temp !== null)))
|
|
135
|
+
throw Error("This host's Messenger identity is not a permanent identity under this host's Human root. Tunnel setup changed nothing.");
|
|
136
|
+
return { name: root.name, cid: root.cid };
|
|
137
|
+
}
|
|
138
|
+
/** Run one operation as the Human root and always give the root back, whatever happens. */
|
|
139
|
+
async function asHumanRoot(attach, root, purpose, use) {
|
|
140
|
+
const client = await daemon(attach, purpose);
|
|
141
|
+
let done = false;
|
|
142
|
+
try {
|
|
143
|
+
const bound = await client.chooseIdentity({ name: root.name, force: false }).catch((error) => {
|
|
144
|
+
throw Error(`This host's Human root cannot be used right now (${error.code ?? error.message}). Close the session that holds it, then run setup again. Nothing was changed.`);
|
|
145
|
+
});
|
|
146
|
+
if (!same(bound.cid, root.cid))
|
|
147
|
+
throw Error("This host's Human root changed while setup was running. Nothing was changed.");
|
|
148
|
+
const result = await use(client);
|
|
149
|
+
// The operation is not reported as done while the root may still be held. The release is attempted exactly once.
|
|
150
|
+
done = true;
|
|
151
|
+
const released = await client.releaseLease();
|
|
152
|
+
if (released.failed > 0)
|
|
153
|
+
throw Error("This host's Human root was not given back completely. Run setup again.");
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
// After a failure the root is released once as well; what failed first is what is reported.
|
|
158
|
+
if (!done)
|
|
159
|
+
await client.releaseLease().catch(() => { });
|
|
160
|
+
await client.close().catch(() => { });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** The signed workspace binding, sent by the Human root itself: the same command Messenger sends when it runs as the root. */
|
|
164
|
+
async function proveWithHumanRoot(attach, root, payload, hostWorkspaceId, contactWaitMs, reuseContact = false) {
|
|
165
|
+
const serverCid = payload.serverCid.toUpperCase();
|
|
166
|
+
await asHumanRoot(attach, root, 'proof', async (client) => {
|
|
167
|
+
// Retirement may already have redeemed this one-time invitation. Its exact signed
|
|
168
|
+
// handoff authorizes the new challenge; do not consume the invitation again.
|
|
169
|
+
const ready = reuseContact && (await client.listContacts()).contacts.some(contact => same(contact.container_id, serverCid));
|
|
170
|
+
if (!ready) {
|
|
171
|
+
const peer = await client.addContact({ invite: payload.invitation });
|
|
172
|
+
if (peer.cid.toUpperCase() !== serverCid)
|
|
173
|
+
throw Error('Enrollment server identity mismatch');
|
|
174
|
+
}
|
|
175
|
+
const deadline = Date.now() + contactWaitMs;
|
|
176
|
+
while (!(await client.listContacts()).contacts.some(contact => contact.container_id.toUpperCase() === serverCid)) {
|
|
177
|
+
if (Date.now() >= deadline)
|
|
178
|
+
throw Error('Enrollment contact is not ready; obtain a fresh setup payload before retrying');
|
|
179
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
180
|
+
}
|
|
181
|
+
const outcome = await client.sendCommand({ contact: serverCid, command: 'bind-workspace', arguments: { type: 'ours.app.bind-workspace.v1', accountId: payload.challenge.accountId, workspaceId: payload.challenge.workspaceId, nonce: payload.challenge.nonce, hostWorkspaceId } });
|
|
182
|
+
if (!('sent' in outcome) || !outcome.sent)
|
|
183
|
+
throw Error('Workspace proof was not sent');
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* True when the Owner entry is the one tunnel setup manages and it still names the Human root of an
|
|
188
|
+
* installation whose Messenger now runs as the person's own identity under that root. Only then is the
|
|
189
|
+
* entry moved to that identity; an Owner entry anyone else wrote is never touched.
|
|
190
|
+
*/
|
|
191
|
+
function managedRootOwner(configuration, rooms, root) {
|
|
192
|
+
if (!same(rooms.owner.expected_cid, root.cid) || rooms.owner.public_invite_file !== workspaceOwnerInvite() || rooms.defaults?.attach_owner === false)
|
|
193
|
+
return false;
|
|
194
|
+
if (!existsSync(join(stateRoot(), 'workspace', 'binding.json')))
|
|
195
|
+
return false;
|
|
196
|
+
let written;
|
|
197
|
+
try {
|
|
198
|
+
written = parseDocument(readFileSync(configuration, 'utf8')).toJS()?.rooms;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
const entry = written?.owner, defaults = written?.defaults, keys = (value) => Object.keys(value).sort().join();
|
|
204
|
+
// Exactly the Owner entry and defaults tunnel setup writes; anything added or changed beside them is someone's own configuration.
|
|
205
|
+
return !!written && keys(written) === 'defaults,owner' && !!entry && keys(entry) === 'expected_cid,provider,public_invite_file,role' && entry.provider === 'messenger-server' && entry.role === 'Owner' && typeof entry.expected_cid === 'string' && same(entry.expected_cid, root.cid)
|
|
206
|
+
&& !!defaults && keys(defaults) === 'attach_owner,close_when_task_done' && defaults.attach_owner === true && defaults.close_when_task_done === true;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Check the Fleet configuration tunnel setup will use, before the one-time command is used.
|
|
210
|
+
* Reads only; an existing Owner is compared with this host's own Messenger identity.
|
|
211
|
+
*/
|
|
212
|
+
export async function checkWorkspaceConfiguration(configuration = defaultConfigPath(), attach = attachOursClient) {
|
|
213
|
+
if (new WebAccessStore().read().mode === 'none')
|
|
214
|
+
throw Error('Workspace enrollment requires protected web access; enable pairing or password before setup');
|
|
215
|
+
const paths = preflightInitPaths(configuration);
|
|
216
|
+
if (!paths.manifestExisted) {
|
|
217
|
+
if (paths.rootExisted)
|
|
218
|
+
throw Error(`${paths.splitRoot} exists without ${paths.configPath}. Restore that file or move the directory away.`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const loaded = loadConfig(paths.configPath, { yamlMode: 'strict', deferredOwnerInviteFile: workspaceOwnerInvite() });
|
|
222
|
+
const inviteFile = loaded.rooms?.owner.public_invite_file || workspaceOwnerInvite();
|
|
223
|
+
if (existsSync(inviteFile)) {
|
|
224
|
+
const stat = lstatSync(inviteFile);
|
|
225
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0)
|
|
226
|
+
throw Error('Retained Owner invitation file must be owned and private');
|
|
227
|
+
}
|
|
228
|
+
if (!loaded.rooms)
|
|
229
|
+
return;
|
|
230
|
+
if (loaded.rooms.defaults?.attach_owner === false)
|
|
231
|
+
throw Error(`${paths.configPath}: rooms.defaults.attach_owner is disabled; enable it before tunnel setup.`);
|
|
232
|
+
if (!loaded.rooms.owner.public_invite_file)
|
|
233
|
+
throw Error(`${paths.configPath}: rooms.owner must use public_invite_file before tunnel setup.`);
|
|
234
|
+
let cid, root;
|
|
235
|
+
try {
|
|
236
|
+
({ cid, root } = await messengerIdentity(hostMessenger(), attach));
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
cid = undefined;
|
|
240
|
+
}
|
|
241
|
+
if (typeof cid !== 'string' || !/^[a-f0-9]{64}$/i.test(cid))
|
|
242
|
+
throw Error(`${paths.configPath} names a room Owner, and this host's Messenger identity cannot be read to compare with it. Start Messenger on this host first.`);
|
|
243
|
+
if (loaded.rooms.owner.expected_cid.toLowerCase() !== cid.toLowerCase() && !recordsLostEnrollment(paths.configPath) && !(root && managedRootOwner(paths.configPath, loaded.rooms, root)))
|
|
244
|
+
throw Error(`${paths.configPath}: rooms.owner.expected_cid names a different Owner than this host's Messenger identity. Tunnel setup does not replace an existing Owner.`);
|
|
245
|
+
}
|
|
246
|
+
export async function enrollWorkspace(payload, configuration = defaultConfigPath(), options = {}) {
|
|
247
|
+
const attach = options.attach ?? attachOursClient;
|
|
248
|
+
const request = hostMessenger();
|
|
249
|
+
if (new WebAccessStore().read().mode === 'none')
|
|
250
|
+
throw Error('Workspace enrollment requires protected web access; enable pairing or password before setup');
|
|
251
|
+
checkWorkspaceConnector(payload);
|
|
252
|
+
const dir = join(stateRoot(), 'workspace');
|
|
253
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
254
|
+
const appOrigin = validateAccountOrigin(payload.appOrigin);
|
|
255
|
+
const previousFile = join(dir, 'binding.json');
|
|
256
|
+
let migrating = false;
|
|
257
|
+
if (existsSync(previousFile)) {
|
|
258
|
+
const previous = JSON.parse(readFileSync(previousFile, 'utf8'));
|
|
259
|
+
if (previous.workspaceId !== payload.challenge.workspaceId && options.replacingWorkspace !== previous.workspaceId)
|
|
260
|
+
throw Error('This host is already associated with another workspace');
|
|
261
|
+
const priorOrigin = validateAccountOrigin(previous.appOrigin ?? 'https://app.ours.network');
|
|
262
|
+
migrating = priorOrigin !== appOrigin && options.replacingWorkspace !== previous.workspaceId;
|
|
263
|
+
if (migrating && !options.migrateAppOrigin)
|
|
264
|
+
throw Error('Account origin change requires explicit --migrate-app-origin and a fresh setup payload');
|
|
265
|
+
if (migrating && previous.serverCid && previous.serverCid.toUpperCase() !== payload.serverCid.toUpperCase())
|
|
266
|
+
throw Error('Account origin migration must retain the enrollment server identity');
|
|
267
|
+
}
|
|
268
|
+
const loaded = loadConfig(configuration, { deferredOwnerInviteFile: workspaceOwnerInvite() });
|
|
269
|
+
// Decided before anything is sent or written: the Owner entry is replaced only when it records an enrollment this host has lost.
|
|
270
|
+
const lost = Boolean(loaded.rooms) && recordsLostEnrollment(resolve(configuration));
|
|
271
|
+
if (loaded.rooms?.defaults?.attach_owner === false)
|
|
272
|
+
throw Error('Existing rooms.defaults.attach_owner is disabled; enable automatic Human Owner admission before workspace setup');
|
|
273
|
+
if (loaded.rooms && !loaded.rooms.owner.public_invite_file)
|
|
274
|
+
throw Error('Existing room owner configuration must be reviewed before workspace enrollment');
|
|
275
|
+
const identity = await messengerIdentity(request, attach);
|
|
276
|
+
if (!/^[a-f0-9]{64}$/i.test(identity.cid))
|
|
277
|
+
throw Error('Bound Messenger identity unavailable');
|
|
278
|
+
// An installation that ran Messenger as the Human root and now runs it as the person's own identity keeps its managed Owner entry, moved to that identity.
|
|
279
|
+
const moved = Boolean(loaded.rooms) && !lost && !!identity.root && !same(loaded.rooms.owner.expected_cid, identity.cid) && managedRootOwner(resolve(configuration), loaded.rooms, identity.root)
|
|
280
|
+
&& (options.replacingWorkspace !== undefined || JSON.parse(readFileSync(previousFile, 'utf8')).serverCid?.toUpperCase() === payload.serverCid.toUpperCase());
|
|
281
|
+
if (options.preserveProfile && identity.preserveProfile !== true)
|
|
282
|
+
throw Error('Existing-host setup requires Messenger profile-preservation support; update the host Messenger before retrying');
|
|
283
|
+
if (loaded.rooms && !lost && !moved && loaded.rooms.owner.expected_cid.toLowerCase() !== identity.cid.toLowerCase())
|
|
284
|
+
throw Error('Retained room owner CID conflicts with the Human root');
|
|
285
|
+
const inviteFile = loaded.rooms?.owner.public_invite_file || join(dir, 'owner.invite');
|
|
286
|
+
if (existsSync(inviteFile)) {
|
|
287
|
+
const stat = lstatSync(inviteFile);
|
|
288
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0)
|
|
289
|
+
throw Error('Retained Owner invitation file must be owned and private');
|
|
290
|
+
}
|
|
291
|
+
const devices = new WorkspaceDeviceStore();
|
|
292
|
+
const hostWorkspaceId = devices.workspaceId;
|
|
293
|
+
devices.close();
|
|
294
|
+
const { name, surname, ...proofPayload } = payload;
|
|
295
|
+
let result, ownerCid;
|
|
296
|
+
// A replacement can stop after its one-time proof but before local files commit. Read the
|
|
297
|
+
// account's existing signed receipt first; a verified proof must not be sent a second time.
|
|
298
|
+
let proofAlreadyConfirmed = false;
|
|
299
|
+
if (options.replacingWorkspace) {
|
|
300
|
+
let response;
|
|
301
|
+
try {
|
|
302
|
+
response = await fetch(appOrigin + '/account-api/workspace-proof', { method: 'POST', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { Origin: appOrigin, 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload.challenge, hostWorkspaceId, rootCid: identity.root?.cid ?? identity.cid }) });
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
throw Error('Replacement proof status could not be confirmed. Run setup-tunnel --resume');
|
|
306
|
+
}
|
|
307
|
+
if (!response.ok) {
|
|
308
|
+
await response.body?.cancel();
|
|
309
|
+
throw Error('Replacement proof status was rejected; request a fresh App command with --replace-registration');
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
proofAlreadyConfirmed = (await response.json()).verified === true;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
throw Error('Invalid replacement proof receipt');
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (identity.root) {
|
|
319
|
+
// The Human root signs the binding; the person's own Messenger identity is the room Owner and issues the Owner invitation.
|
|
320
|
+
if (!proofAlreadyConfirmed)
|
|
321
|
+
await proveWithHumanRoot(attach, identity.root, payload, hostWorkspaceId, options.contactWaitMs ?? 10000, options.replacingWorkspace !== undefined);
|
|
322
|
+
const invite = await request('invites', { mode: 'public' });
|
|
323
|
+
if (typeof invite.blob !== 'string' || !invite.blob)
|
|
324
|
+
throw Error('Malformed workspace enrollment response');
|
|
325
|
+
const after = await request('workspace/enrollment-identity');
|
|
326
|
+
if (typeof after.cid !== 'string' || !same(after.cid, identity.cid) || typeof after.rootCid !== 'string' || !same(after.rootCid, identity.root.cid))
|
|
327
|
+
throw Error("This host's Messenger identity changed while setup was running; local configuration is unchanged");
|
|
328
|
+
result = { submitted: true, rootCid: identity.root.cid, ownerInvite: invite.blob };
|
|
329
|
+
ownerCid = identity.cid;
|
|
330
|
+
}
|
|
331
|
+
else if (proofAlreadyConfirmed) {
|
|
332
|
+
const invite = await request('invites', { mode: 'public' });
|
|
333
|
+
if (typeof invite.blob !== 'string' || !invite.blob)
|
|
334
|
+
throw Error('Malformed workspace enrollment response');
|
|
335
|
+
result = { submitted: true, rootCid: identity.cid, ownerInvite: invite.blob };
|
|
336
|
+
ownerCid = identity.cid;
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
result = await request('workspace/enroll', options.preserveProfile ? { ...proofPayload, hostWorkspaceId, preserveProfile: true } : { ...payload, hostWorkspaceId });
|
|
340
|
+
if (!result.submitted || !/^[a-f0-9]{64}$/i.test(result.rootCid) || typeof result.ownerInvite !== 'string')
|
|
341
|
+
throw Error('Malformed workspace enrollment response');
|
|
342
|
+
// Whatever the configuration said before, what is recorded from here on is this host's own root.
|
|
343
|
+
if (result.rootCid.toLowerCase() !== identity.cid.toLowerCase())
|
|
344
|
+
throw Error('Workspace enrollment answered for a different root than this host identity; local configuration is unchanged');
|
|
345
|
+
if (loaded.rooms && !lost && loaded.rooms.owner.expected_cid.toLowerCase() !== result.rootCid.toLowerCase())
|
|
346
|
+
throw Error('Retained room owner CID conflicts with the Human root');
|
|
347
|
+
ownerCid = result.rootCid;
|
|
348
|
+
}
|
|
349
|
+
if (migrating) {
|
|
350
|
+
const deadline = Date.now() + 15000;
|
|
351
|
+
while (true) {
|
|
352
|
+
const response = await fetch(appOrigin + '/account-api/workspace-proof', { method: 'POST', headers: { Origin: appOrigin, 'Content-Type': 'application/json' }, credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(5000), body: JSON.stringify({ ...payload.challenge, hostWorkspaceId, rootCid: result.rootCid }) });
|
|
353
|
+
if (!response.ok) {
|
|
354
|
+
await response.body?.cancel();
|
|
355
|
+
throw Error('Account origin migration proof was rejected; local trust unchanged');
|
|
356
|
+
}
|
|
357
|
+
const receipt = await response.json();
|
|
358
|
+
if (receipt.verified === true)
|
|
359
|
+
break;
|
|
360
|
+
if (Date.now() >= deadline)
|
|
361
|
+
throw Error('Account origin migration proof is not confirmed; local trust unchanged');
|
|
362
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
replaceFileAtomically(inviteFile, result.ownerInvite + '\n', 0o600);
|
|
366
|
+
options.afterInvitation?.();
|
|
367
|
+
const split = splitRootFor(configuration);
|
|
368
|
+
mkdirSync(split, { recursive: true, mode: 0o700 });
|
|
369
|
+
if (!loaded.rooms)
|
|
370
|
+
replaceFileAtomically(configuration, readFileSync(configuration, 'utf8') + '\n' + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
|
|
371
|
+
else if (lost && loaded.rooms.owner.expected_cid.toLowerCase() !== ownerCid.toLowerCase())
|
|
372
|
+
replaceFileAtomically(configuration, `api_version: ${GENERATED_VERSION}\n\n` + stringify(generatedRooms(ownerCid, inviteFile)), 0o600);
|
|
373
|
+
else if (moved) {
|
|
374
|
+
// The invitation above and this entry are two files. A setup interrupted between them leaves the entry naming the root,
|
|
375
|
+
// which is this same case again: running setup once more writes both.
|
|
376
|
+
const document = parseDocument(readFileSync(configuration, 'utf8'));
|
|
377
|
+
document.setIn(['rooms', 'owner', 'expected_cid'], ownerCid);
|
|
378
|
+
replaceFileAtomically(configuration, document.toString(), 0o600);
|
|
379
|
+
}
|
|
380
|
+
if (payload.connectorToken !== undefined)
|
|
381
|
+
replaceFileAtomically(join(dir, 'connector'), payload.connectorToken + '\n', 0o600);
|
|
382
|
+
const origin = `https://${payload.hostname}`;
|
|
383
|
+
replaceFileAtomically(join(dir, 'tunnel.json'), JSON.stringify({ origin, tokenFile: join(dir, 'connector') }) + '\n', 0o600);
|
|
384
|
+
replaceFileAtomically(previousFile, JSON.stringify({ workspaceId: payload.challenge.workspaceId, hostWorkspaceId, appOrigin, serverCid: payload.serverCid.toUpperCase(), ...(identity.root ? { proofRootCid: identity.root.cid } : {}) }) + '\n', 0o600);
|
|
385
|
+
loadConfig(configuration);
|
|
386
|
+
return { origin, hostWorkspaceId, rootCid: result.rootCid };
|
|
387
|
+
}
|
|
388
|
+
/** Only the existing signed-root receipt authorizes the scoped tunnel target. */
|
|
389
|
+
/**
|
|
390
|
+
* The enrollment server contact exists only to carry the signed binding. Once the account has confirmed
|
|
391
|
+
* that binding, remove exactly that contact from this host's Messenger, so it does not stay in the
|
|
392
|
+
* person's chats. The server identity is the one recorded with the binding; nothing else is touched.
|
|
393
|
+
*/
|
|
394
|
+
export async function removeEnrollmentContact(attach = attachOursClient) {
|
|
395
|
+
let serverCid, proofRootCid;
|
|
396
|
+
try {
|
|
397
|
+
({ serverCid, proofRootCid } = JSON.parse(readFileSync(join(stateRoot(), 'workspace', 'binding.json'), 'utf8')));
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
if (typeof serverCid !== 'string' || !/^[A-F0-9]{64}$/i.test(serverCid))
|
|
403
|
+
return false;
|
|
404
|
+
const contact = serverCid.toUpperCase();
|
|
405
|
+
// The contact belongs to whoever sent the binding. When that was the Human root itself, the binding records it.
|
|
406
|
+
if (typeof proofRootCid === 'string' && CID.test(proofRootCid)) {
|
|
407
|
+
try {
|
|
408
|
+
const root = await humanRoot(attach, proofRootCid);
|
|
409
|
+
await asHumanRoot(attach, root, 'cleanup', client => client.removeContact({ contact }));
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
throw Error('the Human root did not remove it');
|
|
413
|
+
}
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
await hostMessenger()('contacts/remove', { contact });
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
throw Error('Messenger did not remove it');
|
|
421
|
+
}
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
export async function configureWorkspacePort(payload, hostWorkspaceId, rootCid, port) {
|
|
425
|
+
const deadline = Date.now() + 15000;
|
|
426
|
+
while (true) {
|
|
427
|
+
const response = await fetch(payload.appOrigin + '/account-api/workspace-tunnel-configure', { method: 'POST', headers: { Origin: payload.appOrigin, 'Content-Type': 'application/json' }, credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(15000), body: JSON.stringify({ ...payload.challenge, hostWorkspaceId, rootCid, port }) });
|
|
428
|
+
if (!response.ok) {
|
|
429
|
+
await response.body?.cancel();
|
|
430
|
+
throw Error('Workspace tunnel target configuration failed (HTTP ' + response.status + ')');
|
|
431
|
+
}
|
|
432
|
+
const receipt = await response.json();
|
|
433
|
+
if (receipt.configured === true)
|
|
434
|
+
return;
|
|
435
|
+
if (Date.now() >= deadline)
|
|
436
|
+
throw Error('Signed workspace binding is not confirmed; tunnel target was not changed');
|
|
437
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Point the tunnel at this host's port. The account answers only for a confirmed signed binding, and
|
|
442
|
+
* only after that answer is the enrollment contact removed; a refused or unconfirmed target leaves it,
|
|
443
|
+
* so the setup can be finished later. A removal that fails is reported and does not undo the setup.
|
|
444
|
+
*/
|
|
445
|
+
export async function confirmWorkspaceTarget(payload, hostWorkspaceId, rootCid, port, cleanupFailed = () => { }) {
|
|
446
|
+
await configureWorkspacePort(payload, hostWorkspaceId, rootCid, port);
|
|
447
|
+
await removeEnrollmentContact().catch(cleanupFailed);
|
|
448
|
+
}
|
|
449
|
+
/** Retire exactly the original registration using the same signed Human root that bound it. */
|
|
450
|
+
export async function unregisterWorkspace(previous, payload, operationNonce, attach = attachOursClient, options = {}) {
|
|
451
|
+
const appOrigin = validateAccountOrigin(previous.appOrigin);
|
|
452
|
+
const messenger = hostMessenger();
|
|
453
|
+
const identity = await messengerIdentity(messenger, attach);
|
|
454
|
+
if (identity.preserveProfile !== true)
|
|
455
|
+
throw Error('Existing-host replacement requires Messenger profile-preservation support; existing local setup preserved');
|
|
456
|
+
const root = identity.root ?? await humanRoot(attach, identity.cid);
|
|
457
|
+
if (previous.proofRootCid && !same(root.cid, previous.proofRootCid))
|
|
458
|
+
throw Error('Original workspace proof root differs from this installation; existing local setup preserved');
|
|
459
|
+
const devices = new WorkspaceDeviceStore();
|
|
460
|
+
const hostWorkspaceId = devices.workspaceId;
|
|
461
|
+
devices.close();
|
|
462
|
+
if (hostWorkspaceId !== previous.hostWorkspaceId)
|
|
463
|
+
throw Error('Original workspace host ID differs from this installation; existing local setup preserved');
|
|
464
|
+
const deadlineField = options.operationExpiresAt === undefined ? {} : { operationExpiresAt: options.operationExpiresAt };
|
|
465
|
+
const receiptInput = { workspaceId: previous.workspaceId, hostWorkspaceId, operationNonce, rootCid: root.cid, ...deadlineField };
|
|
466
|
+
const request = options.request ?? fetch;
|
|
467
|
+
const receipt = async () => {
|
|
468
|
+
let response;
|
|
469
|
+
try {
|
|
470
|
+
response = await request(appOrigin + '/account-api/workspace-unregister-receipt', { method: 'POST', credentials: 'omit', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { Origin: appOrigin, 'Content-Type': 'application/json' }, body: JSON.stringify(receiptInput) });
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
throw Error('Registration retirement could not be confirmed; local setup preserved. Run setup-tunnel --resume to retry');
|
|
474
|
+
}
|
|
475
|
+
if (response.status === 404) {
|
|
476
|
+
await response.body?.cancel();
|
|
477
|
+
return {};
|
|
478
|
+
}
|
|
479
|
+
if (!response.ok) {
|
|
480
|
+
await response.body?.cancel();
|
|
481
|
+
throw Error(`Registration retirement receipt failed (HTTP ${response.status}); local setup preserved`);
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
return await response.json();
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
throw Error('Invalid registration retirement receipt; local setup preserved');
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
let status = await receipt();
|
|
491
|
+
options.onReceipt?.(status);
|
|
492
|
+
if (options.checkOnly) {
|
|
493
|
+
if (status.deleted !== true && !(status.deleted === false && status.deadlineEnforced === true))
|
|
494
|
+
throw Error('Account service does not support replacing a registration yet; existing setup preserved');
|
|
495
|
+
// No invitation is consumed and no command is sent during preparation.
|
|
496
|
+
// A different server cannot be reconnected with the successor's invitation.
|
|
497
|
+
if (status.deleted !== true && status.rejected !== true && !same(previous.serverCid, payload.serverCid)) {
|
|
498
|
+
const check = async (client) => { if (!(await client.listContacts()).contacts.some(contact => same(contact.container_id, previous.serverCid)))
|
|
499
|
+
throw Error('Original enrollment server contact is unavailable; existing setup preserved'); };
|
|
500
|
+
if (identity.root)
|
|
501
|
+
await asHumanRoot(attach, root, 'retirement-preflight', check);
|
|
502
|
+
else
|
|
503
|
+
await check({ listContacts: () => messenger('contacts') });
|
|
504
|
+
}
|
|
505
|
+
return root.cid;
|
|
506
|
+
}
|
|
507
|
+
const requireRetryable = () => { if (status.rejected === true)
|
|
508
|
+
throw Error('The account service definitively rejected this retirement operation. Run setup-tunnel --abandon-replacement, then obtain a fresh App command and start again'); };
|
|
509
|
+
requireRetryable();
|
|
510
|
+
if (status.deleted !== true) {
|
|
511
|
+
if (payload.challenge.expiresAt <= Date.now())
|
|
512
|
+
throw Error('Replacement command expired before retirement; existing setup preserved. Obtain a fresh App command');
|
|
513
|
+
const retire = async (client) => {
|
|
514
|
+
const serverCid = previous.serverCid.toUpperCase();
|
|
515
|
+
let contacts = await client.listContacts();
|
|
516
|
+
if (!contacts.contacts.some(contact => same(contact.container_id, serverCid))) {
|
|
517
|
+
if (!same(payload.serverCid, serverCid))
|
|
518
|
+
throw Error('Original enrollment server contact is unavailable. Reconnect it or remove the registration in the original account first; existing local setup preserved');
|
|
519
|
+
const peer = await client.addContact({ invite: payload.invitation });
|
|
520
|
+
if (!same(peer.cid, serverCid))
|
|
521
|
+
throw Error('Original enrollment server identity mismatch; existing local setup preserved');
|
|
522
|
+
const deadline = Date.now() + (options.waitMs ?? 10000);
|
|
523
|
+
while (!(contacts = await client.listContacts()).contacts.some(contact => same(contact.container_id, serverCid))) {
|
|
524
|
+
if (Date.now() >= deadline)
|
|
525
|
+
throw Error('Original enrollment contact is not ready; local setup preserved. Run setup-tunnel --resume');
|
|
526
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const replacement = appOrigin === validateAccountOrigin(payload.appOrigin) && same(serverCid, payload.serverCid) && previous.workspaceId !== payload.challenge.workspaceId
|
|
530
|
+
? { workspaceId: payload.challenge.workspaceId, accountId: payload.challenge.accountId, nonce: payload.challenge.nonce } : undefined;
|
|
531
|
+
// Persist uncertainty before invoking the transport: a lost response must
|
|
532
|
+
// never make an in-flight retirement safe to abandon.
|
|
533
|
+
options.beforeSend?.();
|
|
534
|
+
const outcome = await client.sendCommand({ contact: serverCid, command: 'unregister-workspace', arguments: { type: 'ours.app.unregister-workspace.v1', workspaceId: previous.workspaceId, hostWorkspaceId, operationNonce, ...deadlineField, ...(replacement ? { replacement } : {}) } });
|
|
535
|
+
if (!('sent' in outcome) || !outcome.sent)
|
|
536
|
+
throw Error('Registration retirement was not sent; local setup preserved');
|
|
537
|
+
};
|
|
538
|
+
if (identity.root)
|
|
539
|
+
await asHumanRoot(attach, root, 'unregister', retire);
|
|
540
|
+
else {
|
|
541
|
+
// Messenger already holds the root lease. Its first-party endpoint signs
|
|
542
|
+
// as that bound root; acquiring a second daemon lease would fail.
|
|
543
|
+
await retire({
|
|
544
|
+
listContacts: () => messenger('contacts'),
|
|
545
|
+
addContact: args => messenger('contacts/add', args),
|
|
546
|
+
sendCommand: async (args) => {
|
|
547
|
+
const answer = await messenger('workspace/unregister', { serverCid: args.contact, rootCid: root.cid, ...args.arguments });
|
|
548
|
+
if (answer.submitted !== true || typeof answer.rootCid !== 'string' || !same(answer.rootCid, root.cid))
|
|
549
|
+
throw Error('Registration retirement answered for a different root; local setup preserved');
|
|
550
|
+
return { sent: true };
|
|
551
|
+
},
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
const deadline = Date.now() + (options.waitMs ?? 30000);
|
|
556
|
+
while (status.deleted !== true || status.retired !== true) {
|
|
557
|
+
if (Date.now() >= deadline)
|
|
558
|
+
throw Error('Registration retirement is not confirmed; local setup preserved. Run setup-tunnel --resume with the same operation, or --abandon-replacement once the server confirms terminal rejection');
|
|
559
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
560
|
+
status = await receipt();
|
|
561
|
+
requireRetryable();
|
|
562
|
+
}
|
|
563
|
+
if (status.absent)
|
|
564
|
+
process.stderr.write('The account service has no record of the old registration. No old provider resources were deleted by this service.\n');
|
|
565
|
+
return root.cid;
|
|
566
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { enrollWorkspace, unregisterWorkspace, type AttachDaemonClient, type WorkspacePayload } from './workspace-enrollment.js';
|
|
2
|
+
export interface WorkspaceBinding {
|
|
3
|
+
workspaceId: string;
|
|
4
|
+
hostWorkspaceId: string;
|
|
5
|
+
appOrigin: string;
|
|
6
|
+
serverCid?: string;
|
|
7
|
+
proofRootCid?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function readWorkspaceBinding(): WorkspaceBinding | undefined;
|
|
10
|
+
export declare function sameWorkspaceBinding(previous: WorkspaceBinding, payload: WorkspacePayload): boolean;
|
|
11
|
+
/** A workspace ID is never retired to rerun or migrate that same workspace. */
|
|
12
|
+
export declare function requiresWorkspaceReplacement(previous: WorkspaceBinding, target: {
|
|
13
|
+
workspaceId: string;
|
|
14
|
+
appOrigin: string;
|
|
15
|
+
serverCid: string;
|
|
16
|
+
}, migrateAppOrigin?: boolean): boolean;
|
|
17
|
+
/** Ask before redeeming the one-use grant, whose destination is deliberately opaque. */
|
|
18
|
+
export declare function confirmWorkspaceReplacement(previous: WorkspaceBinding, explicit?: boolean, effects?: {
|
|
19
|
+
write?: (value: string) => void;
|
|
20
|
+
interactive?: boolean;
|
|
21
|
+
ask?: () => Promise<string>;
|
|
22
|
+
}): Promise<boolean>;
|
|
23
|
+
export interface PendingWorkspaceReplacement {
|
|
24
|
+
version: 1 | 2;
|
|
25
|
+
previous: WorkspaceBinding & {
|
|
26
|
+
serverCid: string;
|
|
27
|
+
};
|
|
28
|
+
payload: WorkspacePayload;
|
|
29
|
+
configuration: string;
|
|
30
|
+
operationNonce: string;
|
|
31
|
+
rootCid?: string;
|
|
32
|
+
retired?: boolean;
|
|
33
|
+
cleaned?: boolean;
|
|
34
|
+
dispatchAttempted?: boolean;
|
|
35
|
+
operationExpiresAt?: number;
|
|
36
|
+
}
|
|
37
|
+
export declare function saveWorkspaceReplacement(record: PendingWorkspaceReplacement): void;
|
|
38
|
+
export declare function readWorkspaceReplacement(): PendingWorkspaceReplacement | undefined;
|
|
39
|
+
export declare function clearWorkspaceReplacement(): void;
|
|
40
|
+
/** Explicit recovery after the user removes an unsuccessful successor in the App. */
|
|
41
|
+
export declare function discardRetiredWorkspaceReplacement(): void;
|
|
42
|
+
export declare function beginWorkspaceReplacement(previous: WorkspaceBinding, payload: WorkspacePayload, configuration: string, effects?: {
|
|
43
|
+
preflight?: typeof unregisterWorkspace;
|
|
44
|
+
attach?: AttachDaemonClient;
|
|
45
|
+
}): Promise<PendingWorkspaceReplacement>;
|
|
46
|
+
/** Abandon only after a private receipt confirms no deletion and dispatch was unsent or terminally rejected. */
|
|
47
|
+
export declare function abandonWorkspaceReplacement(effects?: {
|
|
48
|
+
inspect?: typeof unregisterWorkspace;
|
|
49
|
+
attach?: AttachDaemonClient;
|
|
50
|
+
}): Promise<void>;
|
|
51
|
+
/** A failed/expired one-time command can be renewed only for its pinned successor. */
|
|
52
|
+
export declare function refreshWorkspaceReplacement(record: PendingWorkspaceReplacement, payload: WorkspacePayload, configuration: string): void;
|
|
53
|
+
/** Only tunnel files and account-issued device capabilities belong to this registration. */
|
|
54
|
+
export declare function clearReplacedWorkspaceLocalState(): Promise<void>;
|
|
55
|
+
/** Persist every irreversible boundary; the original binding stays until enrollment writes its successor. */
|
|
56
|
+
export declare function finishWorkspaceReplacement(record: PendingWorkspaceReplacement, effects?: {
|
|
57
|
+
unregister?: typeof unregisterWorkspace;
|
|
58
|
+
inspect?: typeof unregisterWorkspace;
|
|
59
|
+
cleanup?: () => Promise<void>;
|
|
60
|
+
enroll?: typeof enrollWorkspace;
|
|
61
|
+
attach?: AttachDaemonClient;
|
|
62
|
+
}): Promise<{
|
|
63
|
+
origin: string;
|
|
64
|
+
hostWorkspaceId: string;
|
|
65
|
+
rootCid: string;
|
|
66
|
+
}>;
|