@ours.network/install 1.2.1-nightly.38 → 1.2.1-nightly.39

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 CHANGED
@@ -637,7 +637,7 @@ Existing installation records keep their ports and identities. A listener acquir
637
637
  by another process after preflight still causes startup to fail safely; no foreign
638
638
  process is stopped and no retained installation is silently moved.
639
639
 
640
- Workspace setup pins Fleet `1.2.0-nightly.50` with its recorded registry integrity. This release includes first-time Fleet setup through the host API for the account App, direct App access to the workspace services with the device credential, v2 positional one-use grants and automatic QR/connection-code output from `ours-fleet setup-tunnel`, optional private file/stdin inputs for legacy v1 payloads, a separate `ours-fleet link-device` command for additional devices, host prerequisite checks before the one-time workspace proof (`setup-tunnel --check`), `setup-tunnel --resume` to finish a setup that stopped after the host saved its private pending setup record (not when the proof's answer was lost, or after the setup window expired with an unconfirmed non-default port), and persists the available Fleet loopback port used by the workspace tunnel. It also accepts a setup-generated configuration left behind after the host enrollment state was removed, runs the host Codex when it is at least as new as the packaged one, offers only the Codex models the signed-in account can use, and removes the temporary setup contact after confirmation. Fleet serves no pages and opens no browser: every screen is in the App. Fleet also serves the workspace notification routes, produces notifications through the server gateway with the imported producer credential, accepts the App's device credential on the notification presence socket so an open App receives no push, and defaults agent wakes to `monitor.interrupt: after_tool` (Hermes: no interrupt; role snapshots saved before this default keep no interrupt). Existing installation identities and configured ports remain retained; a retained-port collision fails safely.
640
+ Workspace setup pins Fleet `1.2.0-nightly.52` with its recorded registry integrity. This release includes first-time Fleet setup through the host API for the account App, direct App access to the workspace services with the device credential, v2 positional one-use grants and automatic QR/connection-code output from `ours-fleet setup-tunnel`, optional private file/stdin inputs for legacy v1 payloads, a separate `ours-fleet link-device` command for additional devices, host prerequisite checks before the one-time workspace proof (`setup-tunnel --check`), `setup-tunnel --resume` to finish a setup that stopped after the host saved its private pending setup record (not when the proof's answer was lost, or after the setup window expired with an unconfirmed non-default port), and persists the available Fleet loopback port used by the workspace tunnel. It also accepts a setup-generated configuration left behind after the host enrollment state was removed, runs the host Codex when it is at least as new as the packaged one, offers only the Codex models the signed-in account can use, and removes the temporary setup contact after confirmation. Fleet serves no pages and opens no browser: every screen is in the App. Fleet also serves the workspace notification routes, produces notifications through the server gateway with the imported producer credential, accepts the App's device credential on the notification presence socket so an open App receives no push, and defaults agent wakes to `monitor.interrupt: after_tool` (Hermes: no interrupt; role snapshots saved before this default keep no interrupt). Existing installation identities and configured ports remain retained; a retained-port collision fails safely.
641
641
 
642
642
  ### App tunnel setup prerelease sequencing
643
643
 
@@ -654,7 +654,7 @@ connection code automatically; the App checks readiness and opens the code field
654
654
  Private `--file`/`--stdin` transports remain optional and are the only transports
655
655
  for legacy v1 payloads with long-lived credentials.
656
656
 
657
- The nightly installer release manifest selects published Fleet `1.2.0-nightly.50`
657
+ The nightly installer release manifest selects published Fleet `1.2.0-nightly.52`
658
658
  and its actual registry integrity through the normal release manifest/source-policy
659
659
  generators. This release includes v2 argument and private-input support. The former
660
660
  nightly.41 selection predates this capability. Merge and publish the repinned
@@ -668,3 +668,60 @@ conversation using the credential's fixed user/source scope. Browser inbox
668
668
  `/api/v1/delete` continues to require the user credential. The notification
669
669
  service must support the scoped lifecycle route; this change does not repin or
670
670
  publish an installer release.
671
+
672
+ ## Boot and failure recovery
673
+
674
+ Docker long-running daemon, Telegram, Cowork, Messenger/Notifications and gateway
675
+ containers use `unless-stopped`. They resume when the selected Docker Engine
676
+ starts, and Docker restarts a failed container with its built-in backoff. A manual
677
+ `docker stop`, `ours-install server stop`, or uninstall stays stopped across
678
+ Engine restarts. Administrative/maintenance jobs have no automatic restart.
679
+ Docker health status alone does not restart a container.
680
+
681
+ The four application containers also supervise their locked entrypoints. After
682
+ 90 seconds of startup grace, probes run every 5 seconds with a 15-second timeout.
683
+ Three consecutive local health failures or a child exit stop its entire process
684
+ group (TERM, then KILL within 20 seconds) before restarting. Cowork's detached
685
+ workers carry an explicit supervisor-PID marker; recovery also kills those
686
+ owned groups and waits for them to stop before launching a replacement. Each container start
687
+ allows three automatic child recovery attempts. Exhaustion leaves the container
688
+ running but unhealthy, with its children stopped and a diagnostic in its logs.
689
+ Correct the cause and use `ours-install server restart --state-dir PATH` to
690
+ reset that circuit; `server start` alone does not reset a running circuit holder.
691
+ Consumers wait for authenticated readiness of their pinned daemon instance and
692
+ do not spend their recovery budget on a daemon outage. The gateway uses Docker
693
+ process recovery only; a gateway hang requires operator restart.
694
+
695
+ Re-run `server install` to deliver recovery to an existing installation without
696
+ changing its retained exact packages, identity, credentials, volumes or config.
697
+ This explicit install starts its selected services, as before. `server update`
698
+ with reviewed storage compatibility delivers the new selected release and
699
+ restores only previously running services. Retained runtime scripts are refreshed
700
+ and rebuilt on install; an interrupted refresh retains a rebuild marker. The
701
+ restart policy overlay also covers retained Compose files. Reconciliation updates
702
+ only this project's recorded long-running containers and does not start or
703
+ recreate a deliberately stopped container. `server start` can reconcile policies
704
+ but does not retrofit health supervision into an old image: run install/update.
705
+ Backup, migration and update still stop writers before maintenance, and a failed
706
+ maintenance operation keeps them stopped. `server uninstall` (also `ours-uninstall --state-dir PATH` for a managed
707
+ schema-2 installation) stops and removes managed services, retaining volumes,
708
+ identity and credentials. It refuses unfinished transitions and data purge.
709
+ Recovery introduces no host watchdog or service.
710
+
711
+ Host boot is a separate prerequisite. On Linux, enable the selected Docker
712
+ Engine system service; rootless Docker additionally needs its user service and
713
+ user linger. The installer reports read-only boot diagnostics and does not enable
714
+ or restart host services. A reachable remote Engine or Docker Desktop context
715
+ cannot prove boot recovery on this machine. A custom Unix socket also has an
716
+ unverified boot-service binding; a host unit cannot establish its startup. On macOS/Windows with WSL, enable Docker
717
+ Desktop startup at sign-in; this does not provide pre-login startup. Rootless
718
+ Podman's existing enabled socket/restart-service and linger preflight remains.
719
+ Native package mode retains its package-owned systemd user/launchd services:
720
+ Linux needs user linger for pre-login startup, macOS needs a GUI login session,
721
+ and native hung-process recovery is not supplied by this Docker supervisor.
722
+ Fleet stays on the host with Fleet-owned lifecycle and crash protection. Installing
723
+ the exact Fleet nightly binary does not upgrade or restart an existing supervisor.
724
+ The published no-policy core delivery is a verified recovery omission that
725
+ reproduces the reported symptom. The original incident cause remains unconfirmed:
726
+ actual host Engine availability, installed policies and pre-reboot stopped state
727
+ still require Owner confirmation.
package/assets/Dockerfile CHANGED
@@ -27,7 +27,7 @@ COPY --chmod=644 scripts/runtime/diagnostics.mjs /opt/ours/docker/diagnostics.mj
27
27
  COPY --chmod=644 scripts/runtime/legacy-import.mjs /opt/ours/docker/legacy-import.mjs
28
28
  COPY --chmod=644 scripts/runtime/entrypoint.sh /opt/ours/docker/entrypoint.sh
29
29
  COPY --chmod=644 scripts/runtime/healthcheck.mjs /opt/ours/docker/healthcheck.mjs
30
- COPY --chmod=644 scripts/runtime/start-telegram.sh scripts/runtime/health-telegram.sh scripts/runtime/start-cowork.sh scripts/runtime/health-cowork.sh scripts/runtime/start-messenger.sh scripts/runtime/health-messenger.mjs scripts/runtime/notifications-gateway.mjs scripts/runtime/supervise.mjs /opt/ours/docker/
30
+ COPY --chmod=644 scripts/runtime/start-telegram.sh scripts/runtime/health-telegram.sh scripts/runtime/start-cowork.sh scripts/runtime/health-cowork.sh scripts/runtime/start-messenger.sh scripts/runtime/health-messenger.mjs scripts/runtime/notifications-gateway.mjs scripts/runtime/supervise.mjs scripts/runtime/recover.mjs scripts/runtime/dependency-ready.mjs /opt/ours/docker/
31
31
  RUN chmod 755 /opt/ours/maintenance /opt/ours/docker
32
32
  FROM node:24 AS maintenance
33
33
  LABEL network.ours.build-context="1"
@@ -83,6 +83,7 @@ services:
83
83
 
84
84
  daemon:
85
85
  <<: *runtime
86
+ restart: unless-stopped
86
87
  environment: *owner-environment
87
88
  volumes:
88
89
  - {type: volume, source: server-storage, target: /var/lib/ours, volume: {nocopy: true, subpath: state/daemon}}
@@ -107,6 +108,7 @@ services:
107
108
 
108
109
  telegram:
109
110
  <<: *runtime
111
+ restart: unless-stopped
110
112
  entrypoint: [/bin/sh, -e, /opt/ours/docker/start-telegram.sh]
111
113
  environment:
112
114
  HOME: /tmp
@@ -129,6 +131,7 @@ services:
129
131
 
130
132
  cowork:
131
133
  <<: *runtime
134
+ restart: unless-stopped
132
135
  entrypoint: [/bin/sh, -e, /opt/ours/docker/start-cowork.sh]
133
136
  environment:
134
137
  HOME: /tmp
@@ -153,6 +156,7 @@ services:
153
156
 
154
157
  messenger:
155
158
  <<: *runtime
159
+ restart: unless-stopped
156
160
  entrypoint: [/bin/sh, -e, /opt/ours/docker/start-messenger.sh]
157
161
  environment:
158
162
  HOME: /tmp
@@ -13,7 +13,7 @@
13
13
  "@ours.network/codex": "1.2.0-nightly.12",
14
14
  "@ours.network/cowork": "1.3.3-nightly.20261005.f549477",
15
15
  "@ours.network/daemon": "3.8.1-nightly.9",
16
- "@ours.network/fleet": "1.2.0-nightly.50",
16
+ "@ours.network/fleet": "1.2.0-nightly.52",
17
17
  "@ours.network/mcp": "1.2.0-nightly.12",
18
18
  "@ours.network/messenger-server": "1.0.31-nightly.17",
19
19
  "@ours.network/notifications": "0.3.1-nightly.2",
@@ -542,9 +542,9 @@
542
542
  ]
543
543
  },
544
544
  "node_modules/@anthropic-ai/sdk": {
545
- "version": "0.131.0",
546
- "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.131.0.tgz",
547
- "integrity": "sha512-9+PepoU7qVMmM70jSahdnfcMHAvAnzhMOLv2W9WjRLO+OG/MAiBETkwKZqv7KIJ1Cj5h8eZ019bHzZaq+Mbepw==",
545
+ "version": "0.132.0",
546
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.132.0.tgz",
547
+ "integrity": "sha512-hsKXmb+GjIgGpaJRF7rf2yKFa1SPyVJPQhZLNPDYA4J5YRrcVh4jn/aozLRKHWXGCGM9MPw16S+ry+zKIKCr7Q==",
548
548
  "license": "MIT",
549
549
  "optional": true,
550
550
  "peer": true,
@@ -565,9 +565,9 @@
565
565
  }
566
566
  },
567
567
  "node_modules/@babel/runtime": {
568
- "version": "7.29.7",
569
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
570
- "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
568
+ "version": "7.29.10",
569
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
570
+ "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
571
571
  "license": "MIT",
572
572
  "optional": true,
573
573
  "peer": true,
@@ -1002,9 +1002,9 @@
1002
1002
  }
1003
1003
  },
1004
1004
  "node_modules/@ours.network/fleet": {
1005
- "version": "1.2.0-nightly.50",
1006
- "resolved": "https://registry.npmjs.org/@ours.network/fleet/-/fleet-1.2.0-nightly.50.tgz",
1007
- "integrity": "sha512-5hotA7c/I29Fj6KM3jjDxwmtlt1H5nKCCOWir4G37CqHcYIvMDuTwJKiL7RHR0OYjCdoS3zHj2hEKlwA+06M+A==",
1005
+ "version": "1.2.0-nightly.52",
1006
+ "resolved": "https://registry.npmjs.org/@ours.network/fleet/-/fleet-1.2.0-nightly.52.tgz",
1007
+ "integrity": "sha512-nIwGknps0FBDhdtivXAFalRbZ67XHAdNcs6AiTBE0ULYeLcohdfqoFqyAS0iZQPUeEUhxkPTKfVuESKeQKP/ow==",
1008
1008
  "license": "FSL-1.1-Apache-2.0",
1009
1009
  "dependencies": {
1010
1010
  "@agentclientprotocol/sdk": "^1.3.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "channel": "nightly",
4
- "installerVersion": "1.2.1-nightly.38",
4
+ "installerVersion": "1.2.1-nightly.39",
5
5
  "packages": {
6
6
  "@ours.network/sdk": {
7
7
  "version": "3.8.1-nightly.17",
@@ -28,8 +28,8 @@
28
28
  "integrity": "sha512-OjnrJ8ZuOfKYT4KAmiRLU7czfiuacs6rShwOvvqxqBaUKcs1UwjsYwHARNb6erMxG4am3xxcE0ItahL+d2vt4A=="
29
29
  },
30
30
  "@ours.network/fleet": {
31
- "version": "1.2.0-nightly.50",
32
- "integrity": "sha512-5hotA7c/I29Fj6KM3jjDxwmtlt1H5nKCCOWir4G37CqHcYIvMDuTwJKiL7RHR0OYjCdoS3zHj2hEKlwA+06M+A=="
31
+ "version": "1.2.0-nightly.52",
32
+ "integrity": "sha512-nIwGknps0FBDhdtivXAFalRbZ67XHAdNcs6AiTBE0ULYeLcohdfqoFqyAS0iZQPUeEUhxkPTKfVuESKeQKP/ow=="
33
33
  },
34
34
  "@ours.network/mcp": {
35
35
  "version": "1.2.0-nightly.12",
@@ -22,3 +22,10 @@ mode. Docker uses one named `server-storage` volume: `state/daemon`, `state/mcp`
22
22
  Preparation mounts the storage root; running applications mount only their own
23
23
  state children and selected credentials. Backups and maintenance staging are
24
24
  outside `state/`. The owner-lock volume contains transient runtime locks.
25
+
26
+ `recover.mjs` supervises the locked entrypoints with timed health probes and three
27
+ automatic recovery attempts per container start. It kills the entire child group
28
+ and waits for lock release before a new generation. Exhaustion holds unhealthy
29
+ until explicit restart. `dependency-ready.mjs` waits for the authenticated pinned
30
+ daemon without consuming the consumer's failure circuit. Host boot still depends
31
+ on an independently configured Docker Engine or Desktop startup.
@@ -0,0 +1,13 @@
1
+ import { attachOursClient } from '@ours.network/sdk/client';
2
+ let client;
3
+ try {
4
+ const service = process.argv[2];
5
+ if (!['telegram', 'cowork', 'messenger'].includes(service)) throw new Error('Unknown dependency');
6
+ client = await attachOursClient({ endpoint: 'http://daemon:3050',
7
+ expectedInstanceId: process.env[service === 'telegram' ? 'OURS_TG_DAEMON_ID' : 'OURS_DAEMON_ID'],
8
+ credentialPath: `/credentials/${service}/daemon-token`,
9
+ requiredCapabilities: ['external-sessions-v1'], sessionMode: 'external',
10
+ leaseToken: `container-dependency-${service}`, env: {} });
11
+ await client.listIdentities();
12
+ } catch { process.exitCode = 1; }
13
+ finally { await client?.close(); }
@@ -1,4 +1,8 @@
1
1
  #!/bin/sh
2
+ # Re-enter this entrypoint for every recovery, including its locks and checks.
3
+ if [ "${OURS_RECOVERY_CHILD:-}" != 1 ]; then
4
+ exec node "${OURS_RUNTIME_ROOT:-/opt/ours}/docker/recover.mjs" daemon
5
+ fi
2
6
  set -eu
3
7
  umask 077
4
8
  exec 3</var/lib/ours
@@ -0,0 +1,165 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { setTimeout as sleep } from 'node:timers/promises';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { readdirSync, readFileSync } from 'node:fs';
5
+
6
+ // The container owns recovery; no Docker socket, host service or stored state is
7
+ // accessed. Every launch re-enters the locked, provenance-checking entrypoint.
8
+ export async function runRecovery({ command, health, dependency, graceMs = 90_000,
9
+ intervalMs = 5_000, probeTimeoutMs = 15_000,
10
+ killAfterMs = 20_000, retries = 3, failures = 3, ownerPidEnvironment, log = console.error }) {
11
+ let stopping = false, active;
12
+ const probes = new Set();
13
+ const controller = new AbortController();
14
+ const pause = ms => sleep(ms, undefined, { signal: controller.signal }).catch(() => {});
15
+ const processFields = id => {
16
+ try {
17
+ const stat = readFileSync(`/proc/${id}/stat`, 'utf8');
18
+ return stat.slice(stat.lastIndexOf(')') + 2).split(' ');
19
+ } catch { return null; }
20
+ };
21
+ const groups = child => {
22
+ if (!child?.pid) return [];
23
+ child.groups ??= new Set([child.pid]);
24
+ // Cowork's serve supervisor explicitly marks a detached worker with its
25
+ // owner PID. The marker survives owner death, unlike PPID ancestry. No
26
+ // unrelated process or unmarked group is selected, including Docker exec.
27
+ if (ownerPidEnvironment && !child.probe) for (const id of readdirSync('/proc')) {
28
+ if (!/^\d+$/.test(id)) continue;
29
+ try {
30
+ const env = readFileSync(`/proc/${id}/environ`, 'utf8').split('\0');
31
+ if (!env.includes(`${ownerPidEnvironment}=${child.pid}`)) continue;
32
+ const fields = processFields(id);
33
+ if (fields && !['Z', 'X'].includes(fields[0])) child.groups.add(Number(fields[2]));
34
+ } catch { /* foreign or exited process */ }
35
+ }
36
+ return [...child.groups].filter(pid => Number.isInteger(pid) && pid > 1 && pid !== process.pid);
37
+ };
38
+ const signalGroup = (child, signal, all = true) => {
39
+ const selected = all ? groups(child) : child?.pid ? [child.pid] : [];
40
+ for (const pid of selected) try { process.kill(-pid, signal); }
41
+ catch (error) { if (error.code !== 'ESRCH') throw error; }
42
+ };
43
+ const launch = (args, probe = false) => {
44
+ const child = spawn(args[0], args.slice(1), { detached: true,
45
+ stdio: probe ? 'ignore' : 'inherit', env: { ...process.env, OURS_RECOVERY_CHILD: '1' } });
46
+ child.probe = probe;
47
+ child.done = new Promise(resolve => {
48
+ child.once('error', () => resolve(1));
49
+ child.once('exit', (code) => resolve(code ?? 1));
50
+ });
51
+ return child;
52
+ };
53
+ const groupAlive = child => {
54
+ const selected = groups(child);
55
+ return readdirSync('/proc').some(id => {
56
+ if (!/^\d+$/.test(id)) return false;
57
+ const fields = processFields(id);
58
+ return fields && selected.includes(Number(fields[2])) && !['Z', 'X'].includes(fields[0]);
59
+ });
60
+ };
61
+ const terminate = async child => {
62
+ groups(child);
63
+ // Graceful shutdown reaches only the owning entrypoint; Cowork retains
64
+ // exclusive IPC control of its worker until the force-kill boundary.
65
+ signalGroup(child, 'SIGTERM', false);
66
+ let timer;
67
+ await Promise.race([child.done, new Promise(resolve => { timer = setTimeout(() => {
68
+ signalGroup(child, 'SIGKILL'); resolve();
69
+ }, killAfterMs); })]);
70
+ clearTimeout(timer);
71
+ // A leader may exit before a stubborn grandchild. Kill the remaining group
72
+ // even when child.done won the race, then await kernel lock release.
73
+ signalGroup(child, 'SIGKILL');
74
+ await child.done;
75
+ const deadline = Date.now() + killAfterMs;
76
+ while (groupAlive(child)) {
77
+ if (Date.now() >= deadline) throw new Error('Recovery refused: child process group did not stop');
78
+ await sleep(25);
79
+ }
80
+ };
81
+ const probe = async args => {
82
+ const child = launch(args, true);
83
+ probes.add(child);
84
+ let timer;
85
+ const code = await Promise.race([child.done, new Promise(resolve => {
86
+ timer = setTimeout(() => { signalGroup(child, 'SIGKILL'); resolve(1); }, probeTimeoutMs);
87
+ })]);
88
+ clearTimeout(timer);
89
+ await terminate(child);
90
+ probes.delete(child);
91
+ return code === 0;
92
+ };
93
+ const stop = () => { stopping = true; controller.abort(); signalGroup(active, 'SIGTERM', false); for (const child of probes) signalGroup(child, 'SIGKILL'); };
94
+ process.on('SIGTERM', stop); process.on('SIGINT', stop);
95
+ try {
96
+ let recoveries = 0;
97
+ while (!stopping) {
98
+ if (recoveries > retries) {
99
+ log(`OURS recovery exhausted (${retries} automatic attempts); children stopped. Inspect logs and use server restart after correcting the cause.`);
100
+ while (!stopping) await pause(60_000);
101
+ break;
102
+ }
103
+ // A dependency outage is a wait, not a local failure. Never spend a
104
+ // consumer's circuit on daemon boot order, restart or unavailability.
105
+ if (dependency) {
106
+ let waiting = false;
107
+ while (!stopping && !await probe(dependency)) {
108
+ if (!waiting) log('OURS waiting for the authenticated daemon; recovery budget retained');
109
+ waiting = true;
110
+ await pause(intervalMs);
111
+ }
112
+ }
113
+ if (stopping) break;
114
+ if (recoveries) log(`OURS recovery attempt ${recoveries}/${retries}`);
115
+ active = launch(command);
116
+ let exited = false, monitoring = true;
117
+ const generation = new AbortController();
118
+ const wait = ms => sleep(ms, undefined, { signal: generation.signal }).catch(() => {});
119
+ const cancel = () => generation.abort();
120
+ controller.signal.addEventListener('abort', cancel, { once: true });
121
+ active.done.then(() => { exited = true; generation.abort(); });
122
+ const watch = async () => {
123
+ await wait(graceMs);
124
+ let failed = 0;
125
+ while (monitoring && !stopping && !exited) {
126
+ const healthy = await probe(health);
127
+ if (!monitoring || stopping || exited) break;
128
+ if (!healthy && dependency && !await probe(dependency)) failed = 0;
129
+ else failed = healthy ? 0 : failed + 1;
130
+ if (failed >= failures) { log('OURS service health failed repeatedly; stopping children before recovery'); return; }
131
+ await wait(intervalMs);
132
+ }
133
+ };
134
+ const watching = watch();
135
+ await Promise.race([active.done, watching]);
136
+ monitoring = false; generation.abort();
137
+ await terminate(active);
138
+ await watching;
139
+ controller.signal.removeEventListener('abort', cancel);
140
+ active = undefined;
141
+ if (!stopping) {
142
+ if (!dependency || await probe(dependency)) recoveries++;
143
+ await pause(intervalMs);
144
+ }
145
+ }
146
+ } finally {
147
+ if (active) await terminate(active);
148
+ process.off('SIGTERM', stop); process.off('SIGINT', stop);
149
+ }
150
+ }
151
+
152
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
153
+ const service = process.argv[2], runtime = process.env.OURS_RUNTIME_ROOT ?? '/opt/ours';
154
+ const health = {
155
+ daemon: ['node', `${runtime}/docker/healthcheck.mjs`],
156
+ telegram: ['/bin/sh', `${runtime}/docker/health-telegram.sh`],
157
+ cowork: ['/bin/sh', `${runtime}/docker/health-cowork.sh`],
158
+ messenger: ['node', `${runtime}/docker/health-messenger.mjs`],
159
+ }[service];
160
+ if (!health) throw new Error('Unknown recovery service');
161
+ const entry = service === 'daemon' ? 'entrypoint.sh' : `start-${service}.sh`;
162
+ await runRecovery({ command: ['/bin/sh', '-e', `${runtime}/docker/${entry}`], health,
163
+ ...(service === 'cowork' ? { ownerPidEnvironment: 'OURS_COWORK_SUPERVISOR_PID' } : {}),
164
+ ...(service !== 'daemon' ? { dependency: ['node', `${runtime}/docker/dependency-ready.mjs`, service] } : {}) });
165
+ }
@@ -1,4 +1,9 @@
1
1
  #!/bin/sh
2
+ set -e
3
+ # Re-enter this entrypoint for every recovery, including its locks and checks.
4
+ if [ "${OURS_RECOVERY_CHILD:-}" != 1 ]; then
5
+ exec node "${OURS_RUNTIME_ROOT:-/opt/ours}/docker/recover.mjs" cowork
6
+ fi
2
7
  umask 077
3
8
  exec 3</var/lib/ours-cowork
4
9
  flock -n 3
@@ -1,4 +1,8 @@
1
1
  #!/bin/sh
2
+ # Re-enter this entrypoint for every recovery, including its locks and checks.
3
+ if [ "${OURS_RECOVERY_CHILD:-}" != 1 ]; then
4
+ exec node "${OURS_RUNTIME_ROOT:-/opt/ours}/docker/recover.mjs" messenger
5
+ fi
2
6
  set -e
3
7
  umask 077
4
8
  runtime="${OURS_RUNTIME_ROOT:-/opt/ours}"
@@ -1,4 +1,9 @@
1
1
  #!/bin/sh
2
+ set -e
3
+ # Re-enter this entrypoint for every recovery, including its locks and checks.
4
+ if [ "${OURS_RECOVERY_CHILD:-}" != 1 ]; then
5
+ exec node "${OURS_RUNTIME_ROOT:-/opt/ours}/docker/recover.mjs" telegram
6
+ fi
2
7
  umask 077
3
8
  exec 3</var/lib/ours-telegram
4
9
  flock -n 3
@@ -2,7 +2,7 @@
2
2
  "release": {
3
3
  "schema": 1,
4
4
  "channel": "nightly",
5
- "installerVersion": "1.2.1-nightly.38",
5
+ "installerVersion": "1.2.1-nightly.39",
6
6
  "packages": {
7
7
  "@ours.network/sdk": {
8
8
  "version": "3.8.1-nightly.17",
@@ -29,8 +29,8 @@
29
29
  "integrity": "sha512-OjnrJ8ZuOfKYT4KAmiRLU7czfiuacs6rShwOvvqxqBaUKcs1UwjsYwHARNb6erMxG4am3xxcE0ItahL+d2vt4A=="
30
30
  },
31
31
  "@ours.network/fleet": {
32
- "version": "1.2.0-nightly.50",
33
- "integrity": "sha512-5hotA7c/I29Fj6KM3jjDxwmtlt1H5nKCCOWir4G37CqHcYIvMDuTwJKiL7RHR0OYjCdoS3zHj2hEKlwA+06M+A=="
32
+ "version": "1.2.0-nightly.52",
33
+ "integrity": "sha512-nIwGknps0FBDhdtivXAFalRbZ67XHAdNcs6AiTBE0ULYeLcohdfqoFqyAS0iZQPUeEUhxkPTKfVuESKeQKP/ow=="
34
34
  },
35
35
  "@ours.network/mcp": {
36
36
  "version": "1.2.0-nightly.12",
@@ -87,7 +87,7 @@
87
87
  },
88
88
  "@ours.network/fleet": {
89
89
  "type": "npm",
90
- "version": "1.2.0-nightly.50"
90
+ "version": "1.2.0-nightly.52"
91
91
  },
92
92
  "@ours.network/mcp": {
93
93
  "type": "npm",
@@ -0,0 +1,43 @@
1
+ /** Diagnose availability separately from boot enablement. Never alter host services. */
2
+ export async function reportBootReadiness(effects, record) {
3
+ const platform = effects.platform.platform;
4
+ if (record.mode === 'packages') {
5
+ if (platform === 'linux') {
6
+ try {
7
+ const linger = await effects.run('loginctl', ['show-user', String(process.getuid()), '--property=Linger', '--value']);
8
+ if (linger.stdout.trim() !== 'yes') throw new Error('linger disabled');
9
+ effects.out('Native user-service boot requires enabled package-owned units; user linger is enabled.');
10
+ } catch { effects.out('Native user services require a login session; pre-login host boot is not verified. Ask the operator to enable user linger.'); }
11
+ } else effects.out('Native launchd services start in the macOS GUI login session; pre-login boot and hung-process recovery are not supplied.');
12
+ return;
13
+ }
14
+ if (record.mode !== 'docker' || (record.containerEngine ?? 'docker') !== 'docker') return;
15
+ if (platform !== 'linux') {
16
+ effects.out('Container recovery requires Docker Desktop to start at sign-in. Enable its startup setting; this installer does not arrange pre-login host boot on this platform.');
17
+ return;
18
+ }
19
+ try {
20
+ const context = JSON.parse((await effects.run('docker', ['context', 'inspect'])).stdout);
21
+ const endpoint = effects.env.DOCKER_HOST ?? context[0]?.Endpoints?.docker?.Host;
22
+ if (effects.env.DOCKER_CONTEXT || !endpoint?.startsWith('unix://') || endpoint.includes('/.docker/desktop/')) {
23
+ effects.out('Container recovery requires the selected remote/Desktop Docker Engine to start independently; local systemd boot cannot be verified for this context.');
24
+ return;
25
+ }
26
+ const info = JSON.parse((await effects.run('docker', ['info', '--format', '{{json .SecurityOptions}}'])).stdout);
27
+ const rootless = info.some(option => option.startsWith('name=rootless'));
28
+ const sockets = rootless ? [`unix:///run/user/${process.getuid()}/docker.sock`] : ['unix:///var/run/docker.sock', 'unix:///run/docker.sock'];
29
+ if (!sockets.includes(endpoint)) {
30
+ effects.out('The selected custom Docker socket is reachable, but its boot service binding is unverified; enable and verify the service owning that Engine.');
31
+ return;
32
+ }
33
+ const enabled = await effects.run('systemctl', [...(rootless ? ['--user'] : []), 'is-enabled', 'docker.service']);
34
+ if (enabled.stdout.trim() !== 'enabled') throw new Error('persistent Docker service enablement is unverified');
35
+ if (rootless) {
36
+ const linger = await effects.run('loginctl', ['show-user', String(process.getuid()), '--property=Linger', '--value']);
37
+ if (linger.stdout.trim() !== 'yes') throw new Error('rootless user linger is disabled');
38
+ }
39
+ effects.out(`Docker boot service is enabled${rootless ? ' with user linger' : ''}; managed containers recover when that Engine starts.`);
40
+ } catch {
41
+ effects.out('Docker is reachable, but automatic host boot is not verified. Ask the operator to enable the selected Docker Engine boot service (and user linger for rootless Docker); container restart policy cannot start an unavailable Engine.');
42
+ }
43
+ }
@@ -0,0 +1,55 @@
1
+ import { readFileSync, lstatSync, realpathSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { atomicWriteConfig } from './config.mjs';
4
+ import { engineName, runContainer } from './container-engine.mjs';
5
+
6
+ const assets = new URL('../assets/', import.meta.url);
7
+ const files = ['scripts/runtime/recover.mjs', 'scripts/runtime/dependency-ready.mjs',
8
+ ...['entrypoint.sh', 'start-telegram.sh', 'start-cowork.sh', 'start-messenger.sh'].map(f => `scripts/runtime/${f}`)];
9
+ const owned = (path, directory = false) => {
10
+ const s = lstatSync(path);
11
+ if (!(directory ? s.isDirectory() : s.isFile()) || s.uid !== process.getuid() || (!directory && s.nlink !== 1)
12
+ || realpathSync(path) !== path || (s.mode & 0o7000)) throw new Error('Unsafe retained recovery delivery');
13
+ };
14
+ /** Refresh installer-owned runtime scripts without changing source selection or state. */
15
+ export function refreshDockerRecovery(record) {
16
+ for (const dir of [record.root, record.workDir, join(record.workDir, 'scripts'), join(record.workDir, 'scripts/runtime')]) owned(dir, true);
17
+ const marker = join(record.workDir, '.recovery-rebuild');
18
+ let changed = false;
19
+ const replace = (name, bytes) => {
20
+ const path = join(record.workDir, name);
21
+ if (existsSync(path)) { owned(path); if (readFileSync(path).equals(bytes)) return; }
22
+ // Persist before edits, so interruption cannot reuse an image lacking recovery.
23
+ atomicWriteConfig(marker, 'required\n'); changed = true;
24
+ atomicWriteConfig(path, bytes.toString());
25
+ };
26
+ for (const name of files) replace(name, readFileSync(new URL(name, assets)));
27
+ const dockerfile = join(record.workDir, 'Dockerfile'); owned(dockerfile);
28
+ const text = readFileSync(dockerfile, 'utf8');
29
+ const line = 'COPY --chmod=644 scripts/runtime/recover.mjs scripts/runtime/dependency-ready.mjs /opt/ours/docker/';
30
+ if (!text.includes('scripts/runtime/recover.mjs')) replace('Dockerfile', Buffer.from(text.replace('FROM node:24 AS maintenance', `${line}\nFROM node:24 AS maintenance`)));
31
+ return changed || existsSync(marker);
32
+ }
33
+
34
+ /** An overlay also applies to retained Compose files, with one-shot jobs untouched. */
35
+ export function recoveryCompose(record) {
36
+ return { services: Object.fromEntries(record.services.map(name => [name, { restart: 'unless-stopped' }])) };
37
+ }
38
+
39
+ /** Change only exact project-owned long-running containers; never start/recreate. */
40
+ export async function reconcileDockerRecovery(record, effects) {
41
+ if (engineName(record) !== 'docker') return;
42
+ const query = await runContainer(effects, record, ['ps', '-aq', '--no-trunc', '--filter', `label=com.docker.compose.project=${record.project}`]);
43
+ const ids = query.stdout.split(/\s+/).filter(Boolean);
44
+ if (!ids.length) return;
45
+ const inspected = JSON.parse((await runContainer(effects, record, ['inspect', ...ids])).stdout);
46
+ const selected = [];
47
+ for (const c of inspected) {
48
+ const labels = c.Config?.Labels;
49
+ if (labels?.['com.docker.compose.project'] !== record.project) throw new Error('Recovery container project ownership differs');
50
+ if (!record.services.includes(labels['com.docker.compose.service']) || labels['com.docker.compose.oneoff']?.toLowerCase() !== 'false') continue;
51
+ if (!ids.includes(c.Id)) throw new Error('Recovery container identity differs');
52
+ if (c.HostConfig?.RestartPolicy?.Name !== 'unless-stopped') selected.push(c.Id);
53
+ }
54
+ if (selected.length) await runContainer(effects, record, ['update', '--restart', 'unless-stopped', ...selected]);
55
+ }
package/lib/effects.mjs CHANGED
@@ -30,6 +30,8 @@ import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
30
30
  import { select as selectOnTty, multiselect as multiselectOnTty, askLine as askLineOnTty } from './prompt.mjs';
31
31
  import { classifyHarnessProbe } from './logic.mjs';
32
32
  import { qualifyDockerRuntime, refreshDockerPolicyCopy } from './docker-runtime-repair.mjs';
33
+ import { refreshDockerRecovery, recoveryCompose, reconcileDockerRecovery } from './docker-recovery.mjs';
34
+ import { reportBootReadiness } from './boot-readiness.mjs';
33
35
  import { classifyStateDir } from './detect.mjs';
34
36
  import { BASE_RECORDS, CONTEXT, readBuildRecords, equalBuildRecords, initializeBuildMarker } from '../assets/scripts/maintenance/build-context.mjs';
35
37
  import { hostCliPolicy, releaseBinding, verifyReleaseGraph, verifyRuntimeRelease } from '../assets/scripts/maintenance/release-graph.mjs';
@@ -555,6 +557,7 @@ export function networkEffects(effects) {
555
557
  record.schema === 1 && existsSync(join(record.workDir, 'docker-compose.legacy.yaml'))
556
558
  ? 'docker-compose.legacy.yaml' : 'docker-compose.yaml'),
557
559
  ...(record.gateway ? ['--file', join(record.workDir, 'docker-compose.gateway.yaml')] : []),
560
+ ...(existsSync(join(record.workDir, 'compose.recovery.json')) ? ['--file', join(record.workDir, 'compose.recovery.json')] : []),
558
561
  ...(engineName(record) === 'podman' ? ['--file', join(record.workDir, 'compose.podman.json')] : []),
559
562
  '--project-name', record.project, ...args,
560
563
  ];
@@ -824,6 +827,7 @@ export function networkEffects(effects) {
824
827
  }
825
828
  },
826
829
  async prepareInstallation(record, { runtimeOnly = false } = {}) {
830
+ if (!runtimeOnly) await reportBootReadiness(effects, record);
827
831
  let copied = false;
828
832
  if (!existsSync(record.workDir)) {
829
833
  cpSync(INSTALLER_ASSETS, record.workDir, { recursive: true, errorOnExist: true, force: false });
@@ -838,6 +842,8 @@ export function networkEffects(effects) {
838
842
  if (record.mode === 'docker') {
839
843
  if (engineName(record) === 'podman') atomicWriteConfig(join(record.workDir, 'compose.podman.json'), JSON.stringify({ services: Object.fromEntries(record.services.map(name => [name, { restart: 'unless-stopped' }])) }));
840
844
  const rebuildPrivateScripts = refreshDockerPolicyCopy(record);
845
+ const rebuildRecovery = refreshDockerRecovery(record);
846
+ atomicWriteConfig(join(record.workDir, 'compose.recovery.json'), JSON.stringify(recoveryCompose(record)));
841
847
  if (record.gateway) {
842
848
  atomicWriteConfig(join(record.workDir, 'docker-compose.gateway.yaml'), gatewayCompose(record));
843
849
  atomicWriteConfig(join(record.workDir, 'nginx.conf'), gatewayNginx(record));
@@ -847,9 +853,10 @@ export function networkEffects(effects) {
847
853
  const { dependencies } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
848
854
  writeFileSync(join(record.workDir, 'scripts/maintenance/package.json'), JSON.stringify({ private: true, type: 'module', dependencies }, null, 2) + '\n', { mode: 0o600 });
849
855
  const image = await container(record, ['image', 'inspect', `${record.project}:runtime`], { allowCodes: [1] });
850
- if (image.code !== 0 || rebuildPrivateScripts) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
856
+ if (image.code !== 0 || rebuildPrivateScripts || rebuildRecovery) await compose(record, ['build', 'daemon'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
851
857
  await effects.qualifyDockerRuntime(record);
852
858
  if (rebuildPrivateScripts) rmSync(join(record.workDir, '.script-permissions-rebuild'));
859
+ if (rebuildRecovery) rmSync(join(record.workDir, '.recovery-rebuild'));
853
860
  if (record.gateway) {
854
861
  await effects.qualifyGatewayRuntime(record);
855
862
  await compose(record, ['build', 'gateway'], { stream: true, env: { BUILDKIT_PROGRESS: 'plain' } });
@@ -1250,6 +1257,15 @@ export function networkEffects(effects) {
1250
1257
  }
1251
1258
  await nativeLifecycle(record, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
1252
1259
  },
1260
+ async serverUninstall(record) {
1261
+ if (record.schema !== 2 || record.layoutConversion || record.buildTransition) throw new Error('Complete retained installation transitions before uninstall');
1262
+ if (record.mode === 'docker') {
1263
+ await effects.serverLifecycle(record, 'stop');
1264
+ await requireCleanContainerExit(record, record.services);
1265
+ await compose(record, ['rm', '-f', ...record.services]);
1266
+ } else await nativeLifecycle(record, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
1267
+ effects.out('Managed services removed; installation selection, stored identities, credentials and volumes retained.');
1268
+ },
1253
1269
  async serverLifecycle(record, operation, selected = record.services) {
1254
1270
  record = buildRuntime(record);
1255
1271
  if (record.mode === 'docker') {
@@ -1266,6 +1282,8 @@ export function networkEffects(effects) {
1266
1282
  if ((await effects.serverLifecycle(record, 'status', selected)).length) throw new Error('Writers did not stop');
1267
1283
  return;
1268
1284
  }
1285
+ if (existsSync(record.workDir)) atomicWriteConfig(join(record.workDir, 'compose.recovery.json'), JSON.stringify(recoveryCompose(record)));
1286
+ await reconcileDockerRecovery(record, effects);
1269
1287
  const start = async service => {
1270
1288
  effects.out(`Starting ${service}; waiting for readiness...`);
1271
1289
  // Gateway has no persistent state. Recreate its network endpoint on each
@@ -75,12 +75,6 @@ export async function runUninstall(argv, effects) {
75
75
  if (args.version) { effects.out(`ours-uninstall v${effects.version ?? '?'}`); return EXIT_OK; }
76
76
  const purge = argv.includes('--purge');
77
77
  const dir = args.stateDir;
78
- const config = effects.readJson(join(dir, 'config.json'));
79
- const endpoint = `http://127.0.0.1:${typeof config?.port === 'number' ? config.port : 3050}`;
80
-
81
- effects.out(heading(`ours-uninstall --state-dir ${dir}`));
82
- if (args.dryRun) effects.out(info('dry-run: nothing will be removed or stopped'));
83
-
84
78
  // Read the documented OURS_UNINSTALL_* contract before any
85
79
  // file is opened. A variable this uninstaller cannot deliver stops the run
86
80
  // here, naming itself and naming the replacement, rather than being silently
@@ -91,6 +85,24 @@ export async function runUninstall(argv, effects) {
91
85
  return EXIT_REFUSED;
92
86
  }
93
87
 
88
+ const managed = effects.readJson(join(dir, 'installation.json'));
89
+ if (managed?.schema === 2 && ['docker', 'packages'].includes(managed.mode)) {
90
+ if (contract.engaged || args.config || effects.env.OURS_CONFIG) {
91
+ effects.out(warn('Managed server uninstall does not support legacy component/profile selections. Nothing was changed; use an explicit server uninstall with those selectors cleared.'));
92
+ return EXIT_REFUSED;
93
+ }
94
+ if (purge) { effects.out(warn('Managed server uninstall retains identities and volumes; --purge is not supported.')); return EXIT_REFUSED; }
95
+ if (args.dryRun) { effects.out(info('[dry-run] would stop and remove managed server services, retaining all stored data.')); return EXIT_OK; }
96
+ const { runServerCommand } = await import('./orchestrate.mjs');
97
+ return runServerCommand({ role: 'server', operation: 'uninstall', stateDir: dir }, effects);
98
+ }
99
+ const config = effects.readJson(join(dir, 'config.json'));
100
+ const endpoint = `http://127.0.0.1:${typeof config?.port === 'number' ? config.port : 3050}`;
101
+
102
+ effects.out(heading(`ours-uninstall --state-dir ${dir}`));
103
+ if (args.dryRun) effects.out(info('dry-run: nothing will be removed or stopped'));
104
+
105
+
94
106
  let profileSelection;
95
107
  try {
96
108
  profileSelection = resolveProfileSelection({
@@ -1470,6 +1470,8 @@ async function executeServerCommand(args, effects) {
1470
1470
  }
1471
1471
  if (record.gateway) await effects.verifyGateway(record);
1472
1472
  if (showInstallProgress) effects.out(progress(installStageCount, installStageCount, 'Installation complete', 'The selected services are ready.'));
1473
+ } else if (args.operation === 'uninstall') {
1474
+ await effects.serverUninstall(record);
1473
1475
  } else if (args.operation === 'gateway-enable') {
1474
1476
  await enableGateway(record, args, effects);
1475
1477
  } else if (['backup', 'restore', 'reset'].includes(args.operation)) {
package/lib/setup.mjs CHANGED
@@ -13,7 +13,7 @@ import { validateIdentityName } from './server-onboarding.mjs';
13
13
  import { validateFleetSettings } from './fleet-settings.mjs';
14
14
  import { installerFailure } from './diagnostics.mjs';
15
15
 
16
- const maintenance = new Set(['status', 'start', 'stop', 'restart', 'rebuild', 'gateway-enable', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
16
+ const maintenance = new Set(['status', 'start', 'stop', 'restart', 'uninstall', 'rebuild', 'gateway-enable', 'access-issue', 'access-replace', 'backup', 'restore', 'reset']);
17
17
 
18
18
  export function completeReleasePolicy(retained, supplied) {
19
19
  if (retained?.release) {
package/lib/target.mjs CHANGED
@@ -468,7 +468,7 @@ export function searchFreePort(isTaken, { floor = FREE_PORT_FLOOR, reserved = IN
468
468
  export function parseNetworkArgs(argv) {
469
469
  if (!['server', 'client'].includes(argv[0])) return null;
470
470
  const [role, operation] = argv;
471
- const operations = role === 'client' ? ['install'] : ['install', 'status', 'start', 'stop', 'restart', 'access-issue', 'access-replace', 'backup', 'restore', 'reset', 'update', 'rebuild', 'gateway-enable'];
471
+ const operations = role === 'client' ? ['install'] : ['install', 'status', 'start', 'stop', 'restart', 'uninstall', 'access-issue', 'access-replace', 'backup', 'restore', 'reset', 'update', 'rebuild', 'gateway-enable'];
472
472
  if (!operations.includes(operation)) throw new InstallUsageError(`Unsupported ${role} operation: ${operation ?? '(missing)'}`);
473
473
  const allowed = role === 'client' ? ['config'] : ['state-dir'];
474
474
  if (role === 'server' && operation === 'install') allowed.push('mode', 'sources', 'migrate', 'container-engine');
package/lib/usage.mjs CHANGED
@@ -58,7 +58,7 @@ installer with preparation, identity restoration, update and readiness progress.
58
58
  Fleet is configured but left stopped for operator review.
59
59
 
60
60
  Scoped maintenance:
61
- ours-install server status|start|stop|restart|rebuild --state-dir PATH
61
+ ours-install server status|start|stop|restart|rebuild|uninstall --state-dir PATH
62
62
  ours-install server gateway-enable --state-dir PATH [--server-url URL]
63
63
  ours-install server access-issue --state-dir PATH --output PATH
64
64
  ours-install server access-replace --state-dir PATH --confirm
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/install",
3
- "version": "1.2.1-nightly.38",
3
+ "version": "1.2.1-nightly.39",
4
4
  "private": false,
5
5
  "description": "The all-in-one ours.network installer: one shared daemon, MCP, cowork, Telegram, Fleet initialization, harness plugins, Human identity, progress UI, and guided next steps.",
6
6
  "type": "module",