@ours.network/install 1.2.1-nightly.41 → 1.2.1-nightly.43
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 +84 -0
- package/assets/Dockerfile +1 -0
- package/assets/release-lock.json +34 -21
- package/assets/release.json +1 -1
- package/assets/scripts/build/build-common.mjs +9 -1
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +15 -4
- package/assets/scripts/maintenance/recover-notifications.mjs +11 -1
- package/assets/scripts/maintenance/state-operation.mjs +15 -4
- package/assets/scripts/runtime/client-setup.mjs +26 -15
- package/assets/scripts/runtime/daemon-client-prefix.mjs +115 -0
- package/assets/sources.json +1 -1
- package/lib/docker-conversion-runtime.mjs +4 -2
- package/lib/docker-recovery.mjs +6 -1
- package/lib/effects.mjs +136 -8
- package/lib/orchestrate-uninstall.mjs +6 -1
- package/lib/orchestrate.mjs +26 -1
- package/lib/ownership.mjs +63 -0
- package/lib/plan.mjs +14 -4
- package/lib/product.mjs +69 -0
- package/lib/removal-plan.mjs +437 -0
- package/lib/removal.mjs +749 -0
- package/lib/server-onboarding.mjs +22 -4
- package/lib/setup-options.mjs +20 -1
- package/lib/setup.mjs +52 -9
- package/lib/usage.mjs +14 -2
- package/lib/welcome.mjs +219 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,87 @@
|
|
|
1
|
+
## Start here: choose what you want
|
|
2
|
+
|
|
3
|
+
Run `ours-install` with no options. It asks what you would like to do, in terms of goals:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
What would you like to do?
|
|
7
|
+
> (●) Add collaboration tools to my agent apps (Claude Code, Codex or another app)
|
|
8
|
+
( ) Install the complete Ours workspace
|
|
9
|
+
( ) Connect my agent apps to an Ours workspace that runs on another computer
|
|
10
|
+
( ) Other setups (server only, custom ports or packages)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Your existing agent configuration, skills, plugins and custom settings stay as they are. Ours adds its own tools next to them; it does not replace or reconfigure your setup.
|
|
14
|
+
|
|
15
|
+
**Collaboration tools** (`--plugin-only`) give the agent apps you already use their own secure Ours identity, so they can message other agents and people, share files and be woken when mail arrives. They install:
|
|
16
|
+
|
|
17
|
+
- one container: the Ours daemon, published only on `127.0.0.1` (port 3050 by default); nothing else runs;
|
|
18
|
+
- the Ours plugin in each selected app that was found on this computer: Claude Code and/or Codex;
|
|
19
|
+
- the Ours command-line tools and the saved connection in `~/.ours-client`.
|
|
20
|
+
|
|
21
|
+
No Cowork, Messenger, Telegram, Fleet or gateway containers are installed. Clients reach the daemon through a small prefix inside the daemon container that serves only `/daemon/…`, passes requests through unchanged (the daemon still checks every credential) and refuses every other path.
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
ours-install --plugin-only --state-dir ~/.ours-install --identity-name "Your Name" --integrations claude-code,codex
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**The complete workspace** adds rooms per task where you invite other agents and people, task and progress tracking, access from your phone through the Ours App, persistent agents that keep working (Fleet), and orchestration of agents from several vendors.
|
|
28
|
+
|
|
29
|
+
**Another agent app.** Choose "Another agent app" in the list. At the end the installer prints exact instructions for the version it installed: the prepared plugin folder, the MCP server command (`node <folder>/bin/proxy.mjs`, stdio), the skill file, and the npm package `@ours.network/claude-code@<version>`. The app must be able to run a local stdio MCP server and must set `CLAUDE_CODE_SESSION_ID` to a value unique to each agent session in that server's environment; without it the Ours tools refuse to act. Apps that cannot run MCP servers cannot use Ours this way. Do not use the public GitHub marketplace `adapt-toolkit/ours-claude-marketplace` with this release: it currently selects an older, incompatible plugin.
|
|
30
|
+
|
|
31
|
+
### Updating
|
|
32
|
+
|
|
33
|
+
When Ours is already installed, `ours-install` shows the installation and offers:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
◆ Ours is already set up on this computer
|
|
37
|
+
• The complete Ours workspace — folder /home/me/.ours-install, running in Docker
|
|
38
|
+
What would you like to do?
|
|
39
|
+
> (●) Update Ours (recommended)
|
|
40
|
+
( ) Repair: run setup again with the same choices
|
|
41
|
+
( ) Remove Ours from this computer
|
|
42
|
+
( ) Cancel
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
An update keeps the installation's product (collaboration tools or workspace), runtime, folder, identities, data, connected apps and their settings. It never adds workspace parts to a collaboration-tools installation; to switch, remove it first. An unfinished update or setup is offered as "Finish the earlier update or setup". Running the same setup again is safe: it keeps existing identities, and if the server's access credentials were replaced (`ours-install server access-replace`), it issues a fresh client credential.
|
|
46
|
+
|
|
47
|
+
### Removing Ours completely
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
ours-install remove # everything Ours created on this computer
|
|
51
|
+
ours-install remove --dry-run # only show the list
|
|
52
|
+
ours-install remove --state-dir ~/.ours-install # one installation
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The command lists everything it found, grouped (services and containers; identities, keys, messages and settings; connections in your agent apps; persistent agents; programs and downloads; earlier installations), and what it keeps and why. Then it asks you to type `remove ours`. It runs only in a terminal; identities and their private keys exist nowhere else, so they are never deleted without a person confirming. Make a backup first if you may need them: `ours-install server backup server <label> --state-dir <folder>`.
|
|
56
|
+
|
|
57
|
+
What it removes, and how it proves each item is Ours:
|
|
58
|
+
|
|
59
|
+
| Item | Proof required |
|
|
60
|
+
| --- | --- |
|
|
61
|
+
| Containers | Compose project of the installation **and** a Compose project folder inside the installation folder (containers inherit image labels, so a label alone is not proof), or the installer's own helper names on its own image |
|
|
62
|
+
| Images | the image ID recorded when this installer built it, or the image an owned container was created from; a name alone is never proof; only the name is removed (`docker image rm <tag>`, never by ID or with force) |
|
|
63
|
+
| Volumes, network | the installation's Compose project label |
|
|
64
|
+
| Build cache | only the record whose description is the installer's own npm cache mount (`id=/ours-dist-2-npm`), pruned by its record ID (`docker buildx prune --filter id=…`); kept while another Ours installation uses the engine |
|
|
65
|
+
| Installation and connection folders | their own record (`installation.json` / `profile.json`) still names the installation; the folder is renamed aside, then deleted |
|
|
66
|
+
| Downloaded programs | the owner record of each download folder; a damaged record keeps the folder |
|
|
67
|
+
| Claude Code / Codex | the Ours plugin and plugin source (through their own CLIs), their Ours entries in `settings.json` / `config.toml`, and their cached copy of the Ours plugin, only when the source points into Ours downloads |
|
|
68
|
+
| Ours tool records `~/.ours-mcp` | only the rows and session folders of the removed installation |
|
|
69
|
+
| Persistent agents (Fleet) | services that reference this Ours connection, Fleet configuration and state set up by this client |
|
|
70
|
+
| Earlier installations | old daemon and connector services, verified stopped first; marked plugin sections and old skills |
|
|
71
|
+
|
|
72
|
+
It keeps Docker, Node.js, npm, Claude Code and Codex, your other settings, skills and plugins, shared images such as `node:24`, other build cache records, containers and images it cannot prove, and the temporary session guards in `/tmp/ours-<uid>` that every Ours tool session of your user shares.
|
|
73
|
+
|
|
74
|
+
If something cannot be removed, the summary says exactly what and why, the command exits with status 1 and keeps a small retry record (`~/.ours-removal.json`, identities only). Fix the reason and run `ours-install remove` again; it continues where it stopped:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
◆ Removal summary
|
|
78
|
+
! Some Ours items could not be removed yet:
|
|
79
|
+
! - Container images of ours-235c4591528dc8a6 — ours-235c4591528dc8a6:runtime is still used by container user-made, which Ours did not create; remove that container if you no longer need it
|
|
80
|
+
• Fix the reason shown, then run ours-install remove again; it continues from where it stopped.
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Running it when nothing is left changes nothing and says so.
|
|
84
|
+
|
|
1
85
|
## Install first, connect the App afterwards
|
|
2
86
|
|
|
3
87
|
```sh
|
package/assets/Dockerfile
CHANGED
|
@@ -28,6 +28,7 @@ COPY --chmod=644 scripts/runtime/legacy-import.mjs /opt/ours/docker/legacy-impor
|
|
|
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
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
|
+
COPY --chmod=644 scripts/runtime/daemon-client-prefix.mjs /opt/ours/docker/daemon-client-prefix.mjs
|
|
31
32
|
RUN chmod 755 /opt/ours/maintenance /opt/ours/docker
|
|
32
33
|
FROM node:24 AS maintenance
|
|
33
34
|
LABEL network.ours.build-context="1"
|
package/assets/release-lock.json
CHANGED
|
@@ -429,9 +429,9 @@
|
|
|
429
429
|
}
|
|
430
430
|
},
|
|
431
431
|
"node_modules/@agentclientprotocol/sdk": {
|
|
432
|
-
"version": "1.
|
|
433
|
-
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.
|
|
434
|
-
"integrity": "sha512-
|
|
432
|
+
"version": "1.8.0",
|
|
433
|
+
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.8.0.tgz",
|
|
434
|
+
"integrity": "sha512-fbP7qHZsFyTJVT0/RPrcWJ8An26qLXHEuGCHHEKDyxD1xpXV+Ejyh2LAE6vBAFePkpVjZeeohnbgvuEhZ7wLMg==",
|
|
435
435
|
"license": "Apache-2.0",
|
|
436
436
|
"peerDependencies": {
|
|
437
437
|
"zod": "^3.25.0 || ^4.0.0"
|
|
@@ -542,9 +542,9 @@
|
|
|
542
542
|
]
|
|
543
543
|
},
|
|
544
544
|
"node_modules/@anthropic-ai/sdk": {
|
|
545
|
-
"version": "0.
|
|
546
|
-
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.
|
|
547
|
-
"integrity": "sha512-
|
|
545
|
+
"version": "0.133.0",
|
|
546
|
+
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.133.0.tgz",
|
|
547
|
+
"integrity": "sha512-jlqUN8p8ESTJj+GJnA4KjzbEfF1waEoGzRT8jwytlYGFFgv+/DUQOxlYGvYw10UzhD1kwLqYzxvA3FrTB2YWuQ==",
|
|
548
548
|
"license": "MIT",
|
|
549
549
|
"optional": true,
|
|
550
550
|
"peer": true,
|
|
@@ -712,9 +712,9 @@
|
|
|
712
712
|
}
|
|
713
713
|
},
|
|
714
714
|
"node_modules/@fastify/websocket": {
|
|
715
|
-
"version": "11.3.
|
|
716
|
-
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.
|
|
717
|
-
"integrity": "sha512-
|
|
715
|
+
"version": "11.3.4",
|
|
716
|
+
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.4.tgz",
|
|
717
|
+
"integrity": "sha512-firnJtEOv6pfdoXIzFDMbd+omgWwFNfLtdM8JejNFr/9lT/9DHkEhjTShuRFeE4phqrevD2B3JRW0v6uyzWhHQ==",
|
|
718
718
|
"funding": [
|
|
719
719
|
{
|
|
720
720
|
"type": "github",
|
|
@@ -1426,9 +1426,9 @@
|
|
|
1426
1426
|
}
|
|
1427
1427
|
},
|
|
1428
1428
|
"node_modules/content-disposition": {
|
|
1429
|
-
"version": "
|
|
1430
|
-
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-
|
|
1431
|
-
"integrity": "sha512-
|
|
1429
|
+
"version": "2.0.1",
|
|
1430
|
+
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
|
|
1431
|
+
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
|
|
1432
1432
|
"license": "MIT",
|
|
1433
1433
|
"engines": {
|
|
1434
1434
|
"node": ">=18"
|
|
@@ -1753,15 +1753,15 @@
|
|
|
1753
1753
|
}
|
|
1754
1754
|
},
|
|
1755
1755
|
"node_modules/express": {
|
|
1756
|
-
"version": "5.
|
|
1757
|
-
"resolved": "https://registry.npmjs.org/express/-/express-5.
|
|
1758
|
-
"integrity": "sha512-
|
|
1756
|
+
"version": "5.3.0",
|
|
1757
|
+
"resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
|
|
1758
|
+
"integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
|
|
1759
1759
|
"license": "MIT",
|
|
1760
1760
|
"dependencies": {
|
|
1761
1761
|
"accepts": "^2.0.0",
|
|
1762
|
-
"body-parser": "^2.
|
|
1763
|
-
"content-disposition": "^
|
|
1764
|
-
"content-type": "^
|
|
1762
|
+
"body-parser": "^2.3.0",
|
|
1763
|
+
"content-disposition": "^2.0.1",
|
|
1764
|
+
"content-type": "^2.0.0",
|
|
1765
1765
|
"cookie": "^0.7.1",
|
|
1766
1766
|
"cookie-signature": "^1.2.1",
|
|
1767
1767
|
"debug": "^4.4.0",
|
|
@@ -1777,14 +1777,14 @@
|
|
|
1777
1777
|
"on-finished": "^2.4.1",
|
|
1778
1778
|
"once": "^1.4.0",
|
|
1779
1779
|
"parseurl": "^1.3.3",
|
|
1780
|
-
"proxy-addr": "^2.0.
|
|
1781
|
-
"qs": "^6.
|
|
1780
|
+
"proxy-addr": "^2.0.8",
|
|
1781
|
+
"qs": "^6.16.0",
|
|
1782
1782
|
"range-parser": "^1.2.1",
|
|
1783
1783
|
"router": "^2.2.0",
|
|
1784
1784
|
"send": "^1.1.0",
|
|
1785
1785
|
"serve-static": "^2.2.0",
|
|
1786
1786
|
"statuses": "^2.0.1",
|
|
1787
|
-
"type-is": "^2.0
|
|
1787
|
+
"type-is": "^2.1.0",
|
|
1788
1788
|
"vary": "^1.1.2"
|
|
1789
1789
|
},
|
|
1790
1790
|
"engines": {
|
|
@@ -1814,6 +1814,19 @@
|
|
|
1814
1814
|
"express": ">= 4.11"
|
|
1815
1815
|
}
|
|
1816
1816
|
},
|
|
1817
|
+
"node_modules/express/node_modules/content-type": {
|
|
1818
|
+
"version": "2.1.0",
|
|
1819
|
+
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
|
1820
|
+
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
|
1821
|
+
"license": "MIT",
|
|
1822
|
+
"engines": {
|
|
1823
|
+
"node": ">=18"
|
|
1824
|
+
},
|
|
1825
|
+
"funding": {
|
|
1826
|
+
"type": "opencollective",
|
|
1827
|
+
"url": "https://opencollective.com/express"
|
|
1828
|
+
}
|
|
1829
|
+
},
|
|
1817
1830
|
"node_modules/fast-decode-uri-component": {
|
|
1818
1831
|
"version": "1.0.1",
|
|
1819
1832
|
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
package/assets/release.json
CHANGED
|
@@ -8,7 +8,15 @@ export const SOURCE_ROOT = resolve(process.env.OURS_SOURCE_ROOT || '/src');
|
|
|
8
8
|
export const OUT = join(ROOT, 'docker/vendor');
|
|
9
9
|
export const CONFIG = JSON.parse(readFileSync(join(ROOT, 'sources.json'), 'utf8'));
|
|
10
10
|
// Notifications joined the server set later; retained selections without it still build.
|
|
11
|
-
|
|
11
|
+
// A plugin-only server selection names only sdk, cli and daemon, and builds only those.
|
|
12
|
+
const APPLICATIONS = ['tg-connector', 'cowork', 'messenger-server', 'notifications'];
|
|
13
|
+
export const SELECTED = new Set((process.env.OURS_BUILD_PACKAGES || ['sdk', 'cli', 'daemon',
|
|
14
|
+
...APPLICATIONS.filter(name => name !== 'notifications' || CONFIG.packages?.['@ours.network/notifications'])
|
|
15
|
+
.filter(name => !isDaemonOnly(CONFIG) || CONFIG.packages?.['@ours.network/' + name])].join(',')).split(',').map(name => '@ours.network/' + name));
|
|
16
|
+
function isDaemonOnly(config) {
|
|
17
|
+
const names = Object.keys(config.packages ?? {}).sort().join(',');
|
|
18
|
+
return names === '@ours.network/cli,@ours.network/daemon,@ours.network/sdk';
|
|
19
|
+
}
|
|
12
20
|
export const inheritedLock = process.env.OURS_INSTALLER_LOCK_FD === '3' ? [3] : [];
|
|
13
21
|
export const run = (args, cwd) => execFileSync(args[0], args.slice(1), { cwd, stdio: ['inherit', 'inherit', 'inherit', ...inheritedLock] });
|
|
14
22
|
export const capture = (args, cwd, env = process.env) => execFileSync(args[0], args.slice(1), { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit', ...inheritedLock] });
|
|
@@ -101,11 +101,21 @@ export function bindDockerLayout(staging, options) {
|
|
|
101
101
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** The installation's applications: all four, or the daemon alone for collaboration tools. */
|
|
105
|
+
export function selectedComponents(env = process.env) {
|
|
106
|
+
const raw = env.OURS_SERVER_APPLICATIONS;
|
|
107
|
+
if (raw === undefined || raw === '') return COMPONENTS;
|
|
108
|
+
const selected = raw.split(',');
|
|
109
|
+
if (selected[0] !== 'daemon' || new Set(selected).size !== selected.length || selected.some(name => !COMPONENTS.includes(name))) throw new Error('invalid OURS_SERVER_APPLICATIONS selection');
|
|
110
|
+
return selected;
|
|
111
|
+
}
|
|
112
|
+
|
|
104
113
|
/** Offline validation also covers components intentionally left stopped. */
|
|
105
114
|
export function validateDockerLayout(tree, options) {
|
|
115
|
+
const components = options.components ?? COMPONENTS;
|
|
106
116
|
validateBuildRecordSet(options.provenance);
|
|
107
117
|
scanSource(tree, options);
|
|
108
|
-
for (const component of
|
|
118
|
+
for (const component of components) {
|
|
109
119
|
if (fs.readdirSync(join(tree, component, '.ours-provenance')).sort().join() !== recordNames(options.provenance).sort().join()) throw new Error('Mixed component provenance');
|
|
110
120
|
for (const name of recordNames(options.provenance)) {
|
|
111
121
|
if (!privateBytes(join(tree, component, '.ours-provenance', name), options).equals(options.provenance[name])) {
|
|
@@ -123,11 +133,11 @@ export function validateDockerLayout(tree, options) {
|
|
|
123
133
|
if (daemon.stateDir !== DAEMON_STATE || daemon.port !== 3050
|
|
124
134
|
|| (daemon.networkMcp !== undefined && (daemon.networkMcp?.applicationConfigPath !== join(MCP_STATE, 'config.json')
|
|
125
135
|
|| JSON.stringify(daemon.networkMcp?.profile) !== JSON.stringify(profile)))
|
|
126
|
-
|| objectAt(join(tree, 'cowork/config.json'), options).stateDir !== '/var/lib/ours-cowork') {
|
|
136
|
+
|| (components.includes('cowork') && objectAt(join(tree, 'cowork/config.json'), options).stateDir !== '/var/lib/ours-cowork')) {
|
|
127
137
|
throw new Error('Converted Docker deployment configuration is inconsistent');
|
|
128
138
|
}
|
|
129
139
|
if (fs.existsSync(join(tree, 'mcp/config.json'))) objectAt(join(tree, 'mcp/config.json'), options);
|
|
130
|
-
for (const path of ['daemon/daemon-token', ...
|
|
140
|
+
for (const path of ['daemon/daemon-token', ...components.filter(name => name !== 'daemon').map(name => `credentials/${name}/daemon-token`)]) {
|
|
131
141
|
if (!privateBytes(join(tree, path), options).length) throw new Error('Converted managed credential is empty');
|
|
132
142
|
}
|
|
133
143
|
}
|
|
@@ -214,13 +224,14 @@ export async function runDockerLayoutCommand(argv, env = process.env) {
|
|
|
214
224
|
cli: daemonOwner({ OURS_DAEMON_BIN_DIR: '/opt/ours/node_modules/.bin', ...env }),
|
|
215
225
|
configPath: env.OURS_DAEMON_CONFIG || '/var/lib/ours/config.json',
|
|
216
226
|
provenance: readBuildRecords(build),
|
|
227
|
+
components: selectedComponents(env),
|
|
217
228
|
};
|
|
218
229
|
if (!options.uid || !options.gid) throw new Error('Conversion requires a non-root owner');
|
|
219
230
|
if (operation === 'cleanup') {
|
|
220
231
|
console.log(JSON.stringify({ emptyVolumes: cleanupDockerSource(source, options) }));
|
|
221
232
|
return;
|
|
222
233
|
}
|
|
223
|
-
try {
|
|
234
|
+
if (options.components.includes('cowork')) try {
|
|
224
235
|
execFileSync(env.OURS_COWORK_CLI_PATH || '/opt/ours/node_modules/.bin/ours-cowork', ['--json', 'prepare-backup'], {
|
|
225
236
|
env: { ...env, OURS_COWORK_CONFIG: env.OURS_COWORK_CONFIG || '/var/lib/ours-cowork/config.json',
|
|
226
237
|
OURS_COWORK_STATE_DIR: env.OURS_COWORK_STATE_DIR || '/var/lib/ours-cowork' },
|
|
@@ -4,7 +4,17 @@ import { tmpdir } from 'node:os';
|
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { runStateOperation } from './state-operation.mjs';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// The shipped state-operation service is read_only without a /tmp tmpfs, and
|
|
8
|
+
// retained Compose definitions cannot gain one. /dev/shm stays a writable
|
|
9
|
+
// in-memory mount there, so evidence never enters the stored-state volume.
|
|
10
|
+
function scratch() {
|
|
11
|
+
for (const base of [tmpdir(), '/dev/shm']) {
|
|
12
|
+
try { return fs.mkdtempSync(join(base, 'ours-notifications-recovery-')); }
|
|
13
|
+
catch (error) { if (!['EROFS', 'EACCES', 'ENOENT'].includes(error.code)) throw error; }
|
|
14
|
+
}
|
|
15
|
+
throw new Error('No writable private scratch directory for recovery evidence');
|
|
16
|
+
}
|
|
17
|
+
const root = scratch();
|
|
8
18
|
fs.chmodSync(root, 0o700);
|
|
9
19
|
try {
|
|
10
20
|
const evidence = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
@@ -13,6 +13,14 @@ import { daemonOwner } from './daemon-owner.mjs';
|
|
|
13
13
|
|
|
14
14
|
const PROVENANCE = '.ours-provenance';
|
|
15
15
|
const APPLICATIONS = ['daemon', 'telegram', 'cowork', 'messenger'];
|
|
16
|
+
/** The installation's applications; plugin-only installations select only the daemon. */
|
|
17
|
+
export function selectedApplications(env = process.env) {
|
|
18
|
+
const raw = env.OURS_SERVER_APPLICATIONS;
|
|
19
|
+
if (raw === undefined || raw === '') return APPLICATIONS;
|
|
20
|
+
const selected = raw.split(',');
|
|
21
|
+
if (selected[0] !== 'daemon' || new Set(selected).size !== selected.length || selected.some(name => !APPLICATIONS.includes(name))) fail('invalid OURS_SERVER_APPLICATIONS selection');
|
|
22
|
+
return selected;
|
|
23
|
+
}
|
|
16
24
|
const exists = path => { try { fs.lstatSync(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } };
|
|
17
25
|
const canonical = path => exists(path) ? fs.realpathSync(path) : join(canonical(dirname(path)), basename(path));
|
|
18
26
|
const sameRecords = (a, b) => recordNames(a).length === recordNames(b).length && recordNames(a).every(name => a[name].equals(b[name]));
|
|
@@ -26,11 +34,13 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
26
34
|
if (!value || resolve(value) !== value || canonical(value) !== value) fail(`${name} must select an absolute canonical path`);
|
|
27
35
|
return value;
|
|
28
36
|
};
|
|
37
|
+
const applications = selectedApplications(env);
|
|
29
38
|
const state = selected('OURS_STATE_ROOT'), live = selected('OURS_LIVE_ROOT'), build = selected('OURS_BUILD_ROOT');
|
|
30
39
|
if ([state, build].some(path => path === live || path.startsWith(live + '/'))) fail('maintenance and build records must be outside live application state');
|
|
31
40
|
const backups = join(state, 'backups'), maintenance = join(state, '.maintenance');
|
|
32
41
|
const domain = argv[1];
|
|
33
42
|
if (![...APPLICATIONS, 'server'].includes(domain)) fail('select server, daemon, telegram, cowork or messenger');
|
|
43
|
+
if (domain !== 'server' && !applications.includes(domain)) fail(`${domain} is not part of this installation`);
|
|
34
44
|
if (Object.hasOwn(env, 'OURS_STATE_DOMAIN') && env.OURS_STATE_DOMAIN !== domain) fail('selected volume domain does not match operation');
|
|
35
45
|
const compatible = argv.at(-1) === '--compatible';
|
|
36
46
|
if (compatible) argv = argv.slice(0, -1);
|
|
@@ -76,6 +86,7 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
76
86
|
}
|
|
77
87
|
function prepare() {
|
|
78
88
|
if (!['server', 'cowork'].includes(domain) && !paired) return;
|
|
89
|
+
if (!applications.includes('cowork')) return;
|
|
79
90
|
if (!env.OURS_COWORK_CLI_PATH || !env.OURS_COWORK_CONFIG) fail('Cowork maintenance requires its selected executable and configuration');
|
|
80
91
|
owner([env.OURS_COWORK_CLI_PATH, '--json', 'prepare-backup'], { OURS_COWORK_CONFIG: env.OURS_COWORK_CONFIG });
|
|
81
92
|
}
|
|
@@ -88,7 +99,7 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
88
99
|
} catch (e) { fs.closeSync(fd); throw e; }
|
|
89
100
|
}
|
|
90
101
|
function validateTree(tree, records) {
|
|
91
|
-
const roots = domain === 'server' ?
|
|
102
|
+
const roots = domain === 'server' ? applications.map(name => join(tree, name)) : [tree];
|
|
92
103
|
// Notifications shares Messenger's service, but owns a separate state directory.
|
|
93
104
|
// Older installations/backups may contain only its empty mount placeholder.
|
|
94
105
|
// Include every retained notification generation in full-server maintenance.
|
|
@@ -152,7 +163,7 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
152
163
|
fs.chmodSync(path, 0o600);
|
|
153
164
|
}
|
|
154
165
|
}
|
|
155
|
-
if (['server', 'cowork'].includes(domain)) bindConfig(join(tree, domain === 'server' ? 'cowork/config.json' : 'config.json'), env.OURS_COWORK_CONFIG, ['version', 'stateDir', 'rest']);
|
|
166
|
+
if (['server', 'cowork'].includes(domain) && applications.includes('cowork')) bindConfig(join(tree, domain === 'server' ? 'cowork/config.json' : 'config.json'), env.OURS_COWORK_CONFIG, ['version', 'stateDir', 'rest']);
|
|
156
167
|
}
|
|
157
168
|
function retainAuthority(staging) {
|
|
158
169
|
if (!['daemon', 'server'].includes(domain)) return;
|
|
@@ -161,7 +172,7 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
161
172
|
owner([daemonOwner(env), 'config', 'access-retain', '--config', env.OURS_DAEMON_CONFIG, '--target-state-dir', daemon, '--json']);
|
|
162
173
|
if (!commonTree) return;
|
|
163
174
|
const credentials = join(live, 'credentials'), destination = join(staging, 'credentials');
|
|
164
|
-
for (const name of
|
|
175
|
+
for (const name of applications.filter(name => name !== 'daemon')) if (!privateStat(join(credentials, name, 'daemon-token'), false, 0o600).size) fail('current managed credential is empty');
|
|
165
176
|
fs.rmSync(destination, { recursive: true }); copyTree(credentials, destination);
|
|
166
177
|
}
|
|
167
178
|
function replace(staging) {
|
|
@@ -191,7 +202,7 @@ export async function runStateOperation(argv, env = process.env, checkpoints = {
|
|
|
191
202
|
const previous = readRecords(selected('OURS_PREVIOUS_BUILD_ROOT'), false);
|
|
192
203
|
const expected = readRecords(selected('OURS_EXPECTED_BUILD_ROOT'), false);
|
|
193
204
|
if (!sameRecords(target, expected)) fail('recovery runtime differs from the retained candidate');
|
|
194
|
-
const core =
|
|
205
|
+
const core = applications.map(name => readRecords(join(live, name, PROVENANCE)));
|
|
195
206
|
const oldCore = core.every(records => sameRecords(records, previous));
|
|
196
207
|
const newCore = core.every(records => sameRecords(records, target));
|
|
197
208
|
if (!newCore && !(env.OURS_RECOVERY_PHASE === 'prepared' && oldCore)) fail('recovery core provenance differs from the retained candidate');
|
|
@@ -7,12 +7,18 @@ import {
|
|
|
7
7
|
realpathSync, renameSync, unlinkSync, writeFileSync, readdirSync, chmodSync, chownSync,
|
|
8
8
|
} from 'node:fs';
|
|
9
9
|
|
|
10
|
-
const DELIVERY_FILES = [
|
|
11
|
-
'/credentials/telegram/daemon-token',
|
|
12
|
-
'/credentials/cowork/daemon-token',
|
|
13
|
-
'/credentials/messenger/daemon-token',
|
|
14
|
-
];
|
|
15
10
|
const fail = (message) => { throw new Error(message); };
|
|
11
|
+
/** Applications of this installation; plugin-only installations have only the daemon. */
|
|
12
|
+
const APPLICATIONS = (() => {
|
|
13
|
+
const all = ['daemon', 'telegram', 'cowork', 'messenger'];
|
|
14
|
+
const raw = process.env.OURS_SERVER_APPLICATIONS;
|
|
15
|
+
if (raw === undefined || raw === '') return all;
|
|
16
|
+
const selected = raw.split(',');
|
|
17
|
+
if (selected[0] !== 'daemon' || new Set(selected).size !== selected.length || selected.some(name => !all.includes(name))) fail('Invalid OURS_SERVER_APPLICATIONS selection');
|
|
18
|
+
return selected;
|
|
19
|
+
})();
|
|
20
|
+
const CONSUMERS = APPLICATIONS.filter(name => name !== 'daemon');
|
|
21
|
+
const DELIVERY_FILES = CONSUMERS.map(name => `/credentials/${name}/daemon-token`);
|
|
16
22
|
let diagnosticStage = 'validation';
|
|
17
23
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
18
24
|
|
|
@@ -153,11 +159,11 @@ function prepare() {
|
|
|
153
159
|
process.umask(0o077);
|
|
154
160
|
validateCommon();
|
|
155
161
|
for (const path of ['/storage/state', '/storage/state/mcp', '/storage/state/credentials',
|
|
156
|
-
...
|
|
162
|
+
...CONSUMERS.map(name => `/storage/state/credentials/${name}`),
|
|
157
163
|
'/storage/backups', '/storage/.maintenance']) ensureDirectory(path);
|
|
158
164
|
const coworkPort = Number(process.env.OURS_COWORK_REST_PORT);
|
|
159
165
|
if (!Number.isSafeInteger(coworkPort) || coworkPort < 1 || coworkPort > 65535) fail('Configure a valid cowork REST port');
|
|
160
|
-
for (const domain of
|
|
166
|
+
for (const domain of APPLICATIONS) {
|
|
161
167
|
const data = `/storage/state/${domain}`;
|
|
162
168
|
ensureDirectory(data);
|
|
163
169
|
// access-init owns the fresh-state check; no installer files enter daemon
|
|
@@ -173,14 +179,16 @@ function prepare() {
|
|
|
173
179
|
fail('Existing daemon configuration differs; use the supported maintenance workflow');
|
|
174
180
|
}
|
|
175
181
|
} else atomicJson(path, expected);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
+
if (APPLICATIONS.includes('cowork')) {
|
|
183
|
+
const coworkPath = '/storage/state/cowork/config.json';
|
|
184
|
+
const cowork = composeConfig('cowork');
|
|
185
|
+
if (!existingPrivate(coworkPath, false)) atomicJson(coworkPath, cowork);
|
|
186
|
+
else {
|
|
187
|
+
const config = jsonObject(coworkPath);
|
|
188
|
+
if (config.version !== 1 || config.stateDir !== cowork.stateDir || config.rest?.enabled !== true || config.rest?.host !== '0.0.0.0') fail('Existing cowork configuration conflicts with Compose');
|
|
189
|
+
}
|
|
182
190
|
}
|
|
183
|
-
prepareNotifications();
|
|
191
|
+
if (APPLICATIONS.includes('messenger')) prepareNotifications();
|
|
184
192
|
console.log('OURS persistent volumes are ready');
|
|
185
193
|
}
|
|
186
194
|
|
|
@@ -258,7 +266,10 @@ function exactKeys(value, allowed, label) {
|
|
|
258
266
|
try {
|
|
259
267
|
const operation = process.argv[2];
|
|
260
268
|
if (operation === 'prepare') prepare();
|
|
261
|
-
else if (operation === 'telegram-input') {
|
|
269
|
+
else if (operation === 'telegram-input') {
|
|
270
|
+
if (!APPLICATIONS.includes('telegram')) fail('This installation has no Telegram connector');
|
|
271
|
+
prepare(); await telegramInput();
|
|
272
|
+
}
|
|
262
273
|
else if (['access-init', 'access-issue', 'access-replace'].includes(operation)) access(operation, process.argv[3]);
|
|
263
274
|
else fail('usage: client-setup.mjs prepare | telegram-input | access-init | access-issue [OUTPUT] | access-replace');
|
|
264
275
|
} catch (error) {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createServer, request } from 'node:http';
|
|
3
|
+
import { connect } from 'node:net';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Plugin-only installations run no gateway container. Current client releases
|
|
8
|
+
* still address the daemon as <serverUrl>/daemon, so the daemon container itself
|
|
9
|
+
* serves that one prefix. Requests and responses pass through unchanged, including
|
|
10
|
+
* the client's own credential and instance checks; nothing is added or injected.
|
|
11
|
+
* Any other path, method form or host-absolute target is refused.
|
|
12
|
+
*/
|
|
13
|
+
export const PREFIX = '/daemon';
|
|
14
|
+
export const CAPABILITY = 'ours.daemon-prefix-v1';
|
|
15
|
+
const HOP = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'te', 'trailer', 'upgrade'];
|
|
16
|
+
|
|
17
|
+
/** Only origin-form targets under the prefix; the remainder is the daemon path. */
|
|
18
|
+
export function daemonPath(target) {
|
|
19
|
+
if (typeof target !== 'string' || !target.startsWith(PREFIX + '/') || /[\s\\]/.test(target)) return null;
|
|
20
|
+
const rest = target.slice(PREFIX.length);
|
|
21
|
+
// A dot segment could step out of the prefix once normalized by the daemon.
|
|
22
|
+
for (const segment of rest.split('?')[0].split('/')) {
|
|
23
|
+
let decoded;
|
|
24
|
+
try { decoded = decodeURIComponent(segment); } catch { return null; }
|
|
25
|
+
if (decoded === '.' || decoded === '..' || decoded.includes('/')) return null;
|
|
26
|
+
}
|
|
27
|
+
return rest;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function discovery(instanceId) {
|
|
31
|
+
return { schema: 1, instanceId, services: { daemon: PREFIX }, capabilities: [CAPABILITY] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Headers as received, minus hop-by-hop ones; repeated headers keep every value. */
|
|
35
|
+
function headersFor(raw) {
|
|
36
|
+
const headers = {};
|
|
37
|
+
for (let i = 0; i < raw.length; i += 2) {
|
|
38
|
+
const name = raw[i].toLowerCase();
|
|
39
|
+
if (HOP.includes(name)) continue;
|
|
40
|
+
if (!Object.hasOwn(headers, name)) headers[name] = raw[i + 1];
|
|
41
|
+
else if (name === 'set-cookie') headers[name] = [].concat(headers[name], raw[i + 1]);
|
|
42
|
+
else headers[name] = `${headers[name]}, ${raw[i + 1]}`;
|
|
43
|
+
}
|
|
44
|
+
return headers;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createPrefixServer({ upstreamPort = 3050, upstreamHost = '127.0.0.1', instanceId }) {
|
|
48
|
+
const reply = (res, status, body) => {
|
|
49
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
50
|
+
res.end(JSON.stringify(body));
|
|
51
|
+
};
|
|
52
|
+
const server = createServer((req, res) => {
|
|
53
|
+
if (req.url === '/.well-known/ours' && req.method === 'GET') return reply(res, 200, discovery(instanceId));
|
|
54
|
+
if (req.url === PREFIX) { res.writeHead(308, { location: PREFIX + '/' }); res.end(); return; }
|
|
55
|
+
const path = daemonPath(req.url);
|
|
56
|
+
if (path === null) return reply(res, 404, { error: 'not found' });
|
|
57
|
+
const upstream = request({ host: upstreamHost, port: upstreamPort, method: req.method, path, headers: headersFor(req.rawHeaders) }, response => {
|
|
58
|
+
res.writeHead(response.statusCode ?? 502, headersFor(response.rawHeaders));
|
|
59
|
+
response.pipe(res);
|
|
60
|
+
response.on('error', () => res.destroy());
|
|
61
|
+
});
|
|
62
|
+
upstream.on('error', () => { if (!res.headersSent) reply(res, 502, { error: 'daemon unavailable' }); else res.destroy(); });
|
|
63
|
+
// A client that goes away cancels its long poll or stream at the daemon too.
|
|
64
|
+
res.on('close', () => { if (!res.writableFinished) upstream.destroy(); });
|
|
65
|
+
req.pipe(upstream);
|
|
66
|
+
});
|
|
67
|
+
server.on('upgrade', (req, socket, head) => {
|
|
68
|
+
const path = daemonPath(req.url);
|
|
69
|
+
if (path === null) { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'); return; }
|
|
70
|
+
const upstream = connect(upstreamPort, upstreamHost, () => {
|
|
71
|
+
const lines = [`${req.method} ${path} HTTP/1.1`];
|
|
72
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`);
|
|
73
|
+
upstream.write(lines.join('\r\n') + '\r\n\r\n');
|
|
74
|
+
if (head?.length) upstream.write(head);
|
|
75
|
+
upstream.pipe(socket); socket.pipe(upstream);
|
|
76
|
+
});
|
|
77
|
+
const close = () => { upstream.destroy(); socket.destroy(); };
|
|
78
|
+
upstream.on('error', close); socket.on('error', close);
|
|
79
|
+
upstream.on('close', () => socket.destroy()); socket.on('close', () => upstream.destroy());
|
|
80
|
+
});
|
|
81
|
+
// Absolute-form ("GET http://host/...") and CONNECT never reach the daemon.
|
|
82
|
+
server.on('connect', (req, socket) => socket.end('HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n'));
|
|
83
|
+
return server;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Run the daemon's own locked, recovering entrypoint as the single child and
|
|
88
|
+
* serve the prefix beside it. Either one ending stops the other, so the
|
|
89
|
+
* container exits and its restart policy recovers both together.
|
|
90
|
+
*/
|
|
91
|
+
async function main() {
|
|
92
|
+
const port = Number(process.env.OURS_DAEMON_CLIENT_PREFIX_PORT);
|
|
93
|
+
const instanceId = process.env.OURS_DAEMON_ID ?? '';
|
|
94
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535 || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(instanceId)) {
|
|
95
|
+
console.error('OURS daemon client prefix requires a port and the daemon UUID'); process.exit(2);
|
|
96
|
+
}
|
|
97
|
+
const runtime = process.env.OURS_RUNTIME_ROOT ?? '/opt/ours';
|
|
98
|
+
const child = spawn('/bin/sh', ['-e', `${runtime}/docker/entrypoint.sh`], { stdio: 'inherit' });
|
|
99
|
+
const server = createPrefixServer({ instanceId });
|
|
100
|
+
let stopping = false;
|
|
101
|
+
const stop = (signal, code) => {
|
|
102
|
+
if (stopping) return;
|
|
103
|
+
stopping = true;
|
|
104
|
+
server.close();
|
|
105
|
+
if (child.exitCode === null && child.signalCode === null) child.kill(signal);
|
|
106
|
+
else process.exit(code);
|
|
107
|
+
};
|
|
108
|
+
child.on('exit', (code, signal) => { server.close(); process.exit(code ?? (signal ? 1 : 0)); });
|
|
109
|
+
child.on('error', () => { server.close(); process.exit(1); });
|
|
110
|
+
server.on('error', error => { console.error(`OURS daemon client prefix stopped: ${error.code ?? error.message}`); stop('SIGTERM', 1); });
|
|
111
|
+
for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => stop(signal, 0));
|
|
112
|
+
server.listen(port, '0.0.0.0', () => console.error(`OURS daemon client prefix ready on ${PREFIX}`));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
|
package/assets/sources.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { runContainer } from './container-engine.mjs';
|
|
2
|
+
import { isPluginOnly } from './product.mjs';
|
|
2
3
|
import * as fs from 'node:fs';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { randomUUID } from 'node:crypto';
|
|
@@ -64,7 +65,8 @@ export async function runDockerConversion(record, selected, operation, label, ef
|
|
|
64
65
|
}
|
|
65
66
|
} else {
|
|
66
67
|
addMount('storage', '/var/lib/ours', 'state/daemon', true);
|
|
67
|
-
|
|
68
|
+
// A collaboration-tools installation has no Cowork state to mount.
|
|
69
|
+
if (!isPluginOnly(record)) addMount('storage', '/var/lib/ours-cowork', 'state/cowork', false);
|
|
68
70
|
}
|
|
69
71
|
const definition = {
|
|
70
72
|
services: {
|
|
@@ -74,7 +76,7 @@ export async function runDockerConversion(record, selected, operation, label, ef
|
|
|
74
76
|
network_mode: 'none', read_only: true, cap_drop: ['ALL'],
|
|
75
77
|
security_opt: ['no-new-privileges:true'], tmpfs: ['/tmp:rw,nosuid,nodev,noexec,size=64m,mode=1777'],
|
|
76
78
|
entrypoint: ['node', '/opt/ours/docker/docker-layout-conversion.mjs'],
|
|
77
|
-
environment: { HOME: '/tmp', OURS_DAEMON_ID: record.instanceId },
|
|
79
|
+
environment: { HOME: '/tmp', OURS_DAEMON_ID: record.instanceId, OURS_SERVER_APPLICATIONS: isPluginOnly(record) ? 'daemon' : '' },
|
|
78
80
|
volumes: mounts,
|
|
79
81
|
},
|
|
80
82
|
},
|