@ours.network/install 1.2.1-nightly.42 → 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 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"
@@ -429,9 +429,9 @@
429
429
  }
430
430
  },
431
431
  "node_modules/@agentclientprotocol/sdk": {
432
- "version": "1.7.0",
433
- "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.7.0.tgz",
434
- "integrity": "sha512-ZORuxsuEnLjRly7t6Ad7zAsWitjWsyprswyPUHz/yycPb+tTm3DGa8XheaHhRXqX8LS9snuxaYavXSRA50TlHw==",
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.132.1",
546
- "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.132.1.tgz",
547
- "integrity": "sha512-Dg+Rd0effi+4FyNwgEx6JXl9COefsZ3C7BOPSKYIWboQOEIi9HXtu1WBdND0DIgMF7Qt1vuCSjsbs7sjUkzJuQ==",
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.3",
716
- "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.3.tgz",
717
- "integrity": "sha512-NBJoykKpFwrdX9Ezod1rt30p5NoPFu7vgeuUowSM3JVI5/hFNRPhetqkWMTiF/ifIzctwitbJut1gZ6FDg1mwQ==",
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": "1.1.0",
1430
- "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
1431
- "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
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.2.1",
1757
- "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
1758
- "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
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.2.1",
1763
- "content-disposition": "^1.0.0",
1764
- "content-type": "^1.0.5",
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.7",
1781
- "qs": "^6.14.0",
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.1",
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",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "channel": "nightly",
4
- "installerVersion": "1.2.1-nightly.42",
4
+ "installerVersion": "1.2.1-nightly.43",
5
5
  "packages": {
6
6
  "@ours.network/sdk": {
7
7
  "version": "3.8.1-nightly.17",
@@ -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
- export const SELECTED = new Set((process.env.OURS_BUILD_PACKAGES || ['sdk,cli,daemon,tg-connector,cowork,messenger-server', ...(CONFIG.packages?.['@ours.network/notifications'] ? ['notifications'] : [])].join(',')).split(',').map(name => '@ours.network/' + name));
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 COMPONENTS) {
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', ...COMPONENTS.filter(name => name !== 'daemon').map(name => `credentials/${name}/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' },
@@ -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' ? APPLICATIONS.map(name => join(tree, name)) : [tree];
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 ['telegram', 'cowork', 'messenger']) if (!privateStat(join(credentials, name, 'daemon-token'), false, 0o600).size) fail('current managed credential is empty');
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 = APPLICATIONS.map(name => readRecords(join(live, name, PROVENANCE)));
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
- ...['telegram', 'cowork', 'messenger'].map(name => `/storage/state/credentials/${name}`),
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 ['daemon', 'telegram', 'cowork', 'messenger']) {
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
- const coworkPath = '/storage/state/cowork/config.json';
177
- const cowork = composeConfig('cowork');
178
- if (!existingPrivate(coworkPath, false)) atomicJson(coworkPath, cowork);
179
- else {
180
- const config = jsonObject(coworkPath);
181
- 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');
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') { prepare(); await telegramInput(); }
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();
@@ -2,7 +2,7 @@
2
2
  "release": {
3
3
  "schema": 1,
4
4
  "channel": "nightly",
5
- "installerVersion": "1.2.1-nightly.42",
5
+ "installerVersion": "1.2.1-nightly.43",
6
6
  "packages": {
7
7
  "@ours.network/sdk": {
8
8
  "version": "3.8.1-nightly.17",
@@ -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
- addMount('storage', '/var/lib/ours-cowork', 'state/cowork', false);
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
  },
@@ -2,6 +2,7 @@ import { readFileSync, lstatSync, realpathSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { atomicWriteConfig } from './config.mjs';
4
4
  import { engineName, runContainer } from './container-engine.mjs';
5
+ import { isPluginOnly } from './product.mjs';
5
6
 
6
7
  const assets = new URL('../assets/', import.meta.url);
7
8
  const files = ['scripts/runtime/recover.mjs', 'scripts/runtime/dependency-ready.mjs',
@@ -23,11 +24,15 @@ export function refreshDockerRecovery(record) {
23
24
  atomicWriteConfig(marker, 'required\n'); changed = true;
24
25
  atomicWriteConfig(path, bytes.toString());
25
26
  };
26
- for (const name of files) replace(name, readFileSync(new URL(name, assets)));
27
+ // Plugin-only daemons also serve the client prefix from the image.
28
+ const selected = isPluginOnly(record) ? [...files, 'scripts/runtime/daemon-client-prefix.mjs'] : files;
29
+ for (const name of selected) replace(name, readFileSync(new URL(name, assets)));
27
30
  const dockerfile = join(record.workDir, 'Dockerfile'); owned(dockerfile);
28
31
  const text = readFileSync(dockerfile, 'utf8');
29
32
  const line = 'COPY --chmod=644 scripts/runtime/recover.mjs scripts/runtime/dependency-ready.mjs /opt/ours/docker/';
30
33
  if (!text.includes('scripts/runtime/recover.mjs')) replace('Dockerfile', Buffer.from(text.replace('FROM node:24 AS maintenance', `${line}\nFROM node:24 AS maintenance`)));
34
+ const prefixLine = 'COPY --chmod=644 scripts/runtime/daemon-client-prefix.mjs /opt/ours/docker/daemon-client-prefix.mjs';
35
+ if (isPluginOnly(record) && !readFileSync(dockerfile, 'utf8').includes(prefixLine)) replace('Dockerfile', Buffer.from(readFileSync(dockerfile, 'utf8').replace('FROM node:24 AS maintenance', `${prefixLine}\nFROM node:24 AS maintenance`)));
31
36
  return changed || existsSync(marker);
32
37
  }
33
38