@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.
@@ -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
 
package/lib/effects.mjs CHANGED
@@ -17,15 +17,16 @@ import { engineName, bindPodman, runContainer, nativeBuildCommands } from './con
17
17
  import { publishClientCli } from './client-cli.mjs';
18
18
  import { commandFailure } from './diagnostics.mjs';
19
19
  import { spawnSync, execFileSync } from 'node:child_process';
20
- import { chmodSync, closeSync, constants, cpSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
20
+ import { chmodSync, closeSync, constants, cpSync, existsSync, fstatSync, lstatSync, openSync, readFileSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
21
21
  import { homedir, userInfo, platform as osPlatform, release as osRelease, arch as osArch } from 'node:os';
22
22
  import { fileURLToPath } from 'node:url';
23
23
  import { randomUUID, createHash } from 'node:crypto';
24
24
  import { dirname, join, resolve } from 'node:path';
25
+ import { connect as netConnect } from 'node:net';
25
26
  import { clientPackageNames, maintenanceServices, installationPaths, validateInstallation, consumerServiceState, unitNameForStateDir, launchdLabelForStateDir, messengerServicePlan, selectSourcePackages, resolveSourcePolicy, SERVER_SERVICES } from './plan.mjs';
26
27
  import { validateHostProfile, validateGatewayClientProfile } from './target.mjs';
27
28
  import { serverBase, validateGatewayDiscovery, gatewayCompose, gatewayNginx, gatewayAddress } from './gateway.mjs';
28
- import { createServerOnboarding } from './server-onboarding.mjs';
29
+ import { createServerOnboarding, retainedCredentialRefused } from './server-onboarding.mjs';
29
30
  import { atomicWriteConfig, snapshotConfig, restoreConfig } from './config.mjs';
30
31
  import { select as selectOnTty, multiselect as multiselectOnTty, askLine as askLineOnTty } from './prompt.mjs';
31
32
  import { classifyHarnessProbe } from './logic.mjs';
@@ -33,10 +34,24 @@ import { qualifyDockerRuntime, refreshDockerPolicyCopy } from './docker-runtime-
33
34
  import { refreshDockerRecovery, recoveryCompose, reconcileDockerRecovery } from './docker-recovery.mjs';
34
35
  import { reportBootReadiness } from './boot-readiness.mjs';
35
36
  import { classifyStateDir } from './detect.mjs';
37
+ import { isPluginOnly, serverRole, pluginOnlyCompose, PLUGIN_ONLY_COMPOSE } from './product.mjs';
38
+ import { OWNERS_FILE, OWNED_IMAGES_FILE, addOwner, ownedImagesRecord, changedImages } from './ownership.mjs';
39
+ import { IMAGE_TARGETS } from './removal-plan.mjs';
36
40
  import { BASE_RECORDS, CONTEXT, readBuildRecords, equalBuildRecords, initializeBuildMarker } from '../assets/scripts/maintenance/build-context.mjs';
37
41
  import { hostCliPolicy, releaseBinding, verifyReleaseGraph, verifyRuntimeRelease } from '../assets/scripts/maintenance/release-graph.mjs';
38
42
 
39
43
  /** GET http://127.0.0.1:<port>/state-dir — the unauthenticated identity probe. */
44
+ /** Whether anything accepts TCP connections on a loopback port: 'refused', 'open', or 'unknown' (timeout or other error). */
45
+ function portState(port, { timeoutMs = 1500 } = {}) {
46
+ return new Promise(done => {
47
+ const socket = netConnect({ host: '127.0.0.1', port });
48
+ const finish = state => { socket.destroy(); done(state); };
49
+ socket.setTimeout(timeoutMs, () => finish('unknown'));
50
+ socket.once('connect', () => finish('open'));
51
+ socket.once('error', error => finish(error?.code === 'ECONNREFUSED' ? 'refused' : 'unknown'));
52
+ });
53
+ }
54
+
40
55
  async function probePort(port, { timeoutMs = 1500 } = {}) {
41
56
  const controller = new AbortController();
42
57
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -338,6 +353,40 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
338
353
  closeSync(fd);
339
354
  }
340
355
  },
356
+ /**
357
+ * Removal holds the installation lock of every root it removes for the
358
+ * whole run, so no installer operation can start inside a root being deleted.
359
+ */
360
+ async holdInstallationLocks(roots) {
361
+ const { tryLock } = await import('../assets/scripts/maintenance/state-native.mjs');
362
+ const held = [];
363
+ try {
364
+ for (const root of roots) {
365
+ if (!existsSync(root)) continue;
366
+ privateDirectory(root);
367
+ const path = join(root, '.operation.lock');
368
+ const fd = openSync(path, constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW, 0o600);
369
+ held.push(fd);
370
+ const stat = fstatSync(fd);
371
+ if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077) || stat.nlink !== 1) throw new Error(`Unsafe installation lock in ${root}`);
372
+ if (!tryLock(fd)) throw new Error(`Another installer operation is active in ${root}; try again when it has finished`);
373
+ }
374
+ } catch (error) {
375
+ for (const fd of held) closeSync(fd);
376
+ throw error;
377
+ }
378
+ return () => { for (const fd of held) { try { closeSync(fd); } catch { /* released */ } } };
379
+ },
380
+ /** lstat as a plain record; null when absent. Never follows a link. */
381
+ stat: (path) => {
382
+ try {
383
+ const st = lstatSync(path, { bigint: true });
384
+ return { type: st.isSymbolicLink() ? 'symlink' : st.isDirectory() ? 'dir' : st.isFile() ? 'file' : 'other', uid: Number(st.uid), mode: Number(st.mode & 0o7777n), nlink: Number(st.nlink), size: Number(st.size), id: `${st.dev}:${st.ino}` };
385
+ } catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null; throw error; }
386
+ },
387
+ realpath: (path) => { try { return realpathSync(path); } catch { return null; } },
388
+ list: (path) => { try { return readdirSync(path); } catch { return null; } },
389
+ uid: process.getuid?.() ?? -1,
341
390
  home,
342
391
  env,
343
392
  version,
@@ -352,6 +401,9 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
352
401
  // pure planner already gated four ways, and this deletes exactly that.
353
402
  removeDir: (path) => { rmSync(resolve(path), { recursive: true, force: true }); },
354
403
  removeFile: (path) => { rmSync(resolve(path), { force: true }); },
404
+ removeEmptyDir: (path) => { rmdirSync(resolve(path)); },
405
+ renamePath: (from, to) => { renameSync(resolve(from), resolve(to)); },
406
+ sleep: (ms) => new Promise(done => setTimeout(done, ms)),
355
407
  copyDir: (source, destination) => {
356
408
  mkdirSync(dirname(resolve(destination)), { recursive: true, mode: 0o700 });
357
409
  cpSync(resolve(source), resolve(destination), {
@@ -386,6 +438,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
386
438
  brokerUrl: env.OURS_BROKER_URL ?? 'wss://broker1.ours.network',
387
439
  now: () => Date.now(),
388
440
  probe: (port) => probePort(port),
441
+ portState: (port) => portState(port),
389
442
  isTaken: (port) => portTakenSync(port),
390
443
  readJson: readJsonFile,
391
444
  readProfile: readHostProfileFile,
@@ -463,7 +516,7 @@ export function realEffects({ write, ttyFd, env = process.env, home = homedir(),
463
516
  return Object.assign(effects, networkEffects(effects));
464
517
  }
465
518
 
466
- export const __testables = { probePort, portTakenSync, readJsonFile, readTextFile, readHostProfileFile, verifyHostProfile, installedVersionOf, packageDependenciesOf, resolvePackageVersion, codexMarketplace, hasClaudePlugin, knownStateDirsIn };
519
+ export const __testables = { probePort, portState, portTakenSync, readJsonFile, readTextFile, readHostProfileFile, verifyHostProfile, installedVersionOf, packageDependenciesOf, resolvePackageVersion, codexMarketplace, hasClaudePlugin, knownStateDirsIn };
467
520
 
468
521
  // -----------------------------------------------------------------------------
469
522
  // THE PAIR
@@ -560,6 +613,7 @@ export function networkEffects(effects) {
560
613
  ...(record.gateway ? ['--file', join(record.workDir, 'docker-compose.gateway.yaml')] : []),
561
614
  ...(existsSync(join(record.workDir, 'compose.recovery.json')) ? ['--file', join(record.workDir, 'compose.recovery.json')] : []),
562
615
  ...(engineName(record) === 'podman' ? ['--file', join(record.workDir, 'compose.podman.json')] : []),
616
+ ...(isPluginOnly(record) ? ['--file', join(record.workDir, PLUGIN_ONLY_COMPOSE)] : []),
563
617
  '--project-name', record.project, ...args,
564
618
  ];
565
619
  const container = (record, args, options = {}) => runContainer(effects, record, args, options);
@@ -647,6 +701,7 @@ export function networkEffects(effects) {
647
701
  }
648
702
  };
649
703
  return {
704
+ credentialRefused: profile => retainedCredentialRefused(profile),
650
705
  ...createServerOnboarding(effects, { compose, localEnv, bin }),
651
706
  sourcePolicyHash(path) {
652
707
  return createHash('sha256').update(readFileSync(path)).digest('hex');
@@ -673,7 +728,7 @@ export function networkEffects(effects) {
673
728
  return version;
674
729
  });
675
730
  },
676
- newInstallation(root, mode, { containerEngine } = {}) {
731
+ newInstallation(root, mode, { containerEngine, product } = {}) {
677
732
  if (existsSync(root)) {
678
733
  privateDirectory(root);
679
734
  if (readdirSync(root).some(name => name !== '.operation.lock')) throw new Error('Installation root is not empty and has no selection record');
@@ -681,7 +736,10 @@ export function networkEffects(effects) {
681
736
  const instanceId = env.OURS_DAEMON_ID || randomUUID();
682
737
  if (!/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(instanceId)) throw new Error('OURS_DAEMON_ID must be a lowercase UUID');
683
738
  const project = `ours-${createHash('sha256').update(root).digest('hex').slice(0, 16)}`;
684
- return { schema: 2, root, mode, ...(containerEngine ? { containerEngine } : {}), instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services: [...SERVER_SERVICES, ...(mode === 'docker' ? ['gateway'] : [])], ...(mode === 'docker' ? { gateway: { version: 1 } } : {}), port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
739
+ if (isPluginOnly(product ?? 'workspace') && mode !== 'docker') throw new Error('Collaboration tools for existing agent apps run in Docker or Podman');
740
+ const services = isPluginOnly(product ?? 'workspace') ? ['daemon'] : [...SERVER_SERVICES, ...(mode === 'docker' ? ['gateway'] : [])];
741
+ const gateway = mode === 'docker' && !isPluginOnly(product ?? 'workspace');
742
+ return { schema: 2, root, mode, ...(isPluginOnly(product ?? 'workspace') ? { product } : {}), ...(containerEngine ? { containerEngine } : {}), instanceId, project, workDir: join(root, 'runtime'), configPath: installationPaths({ schema: 2, root }).config, sourcesPath: join(root, 'sources.json'), services, ...(gateway ? { gateway: { version: 1 } } : {}), port: 3050, coworkPort: 3052, messengerPort: 8420, messengerIdentity: env.OURS_MESSENGER_IDENTITY || null, uid: 1000, gid: 1000 };
685
743
  },
686
744
  async serverPreflight(record, operation, { existing, sourcePath = record.sourcesPath, sourceManifest, identityName, explicitPorts } = {}) {
687
745
  if (!existing && operation === 'install' && Number.isInteger(record.port)) selectServerPorts(record, portTakenSync, explicitPorts, effects.out);
@@ -748,7 +806,7 @@ export function networkEffects(effects) {
748
806
  await effects.run(effects.platform.platform === 'linux' ? 'systemctl' : 'launchctl', effects.platform.platform === 'linux' ? ['--user', 'show-environment'] : ['print', `gui/${process.getuid()}`]);
749
807
  if (operation === 'install') {
750
808
  for (const command of ['node', 'npm']) await effects.run(command, ['--version']);
751
- const packages = selectSourcePackages(sourceManifest ?? effects.readJson(existing ? record.sourcesPath : sourcePath), 'server');
809
+ const packages = selectSourcePackages(sourceManifest ?? effects.readJson(existing ? record.sourcesPath : sourcePath), serverRole(record));
752
810
  if (Object.values(packages).some(selection => selection.source)) {
753
811
  // Native dependencies in source builds still use their own toolchains.
754
812
  for (const command of ['python3', 'git', 'make', 'cc']) await effects.run(command, ['--version']);
@@ -768,7 +826,7 @@ export function networkEffects(effects) {
768
826
  },
769
827
  async initializeSelection(record, manifest, { retainConfig = false } = {}) {
770
828
  if (typeof manifest === 'string') manifest = JSON.parse(readFileSync(manifest, 'utf8'));
771
- selectSourcePackages(manifest, 'server');
829
+ selectSourcePackages(manifest, serverRole(record));
772
830
  const bytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
773
831
  ensurePrivateDirectory(record.root);
774
832
  if (record.schema === 2) {
@@ -841,6 +899,9 @@ export function networkEffects(effects) {
841
899
  if (!existsSync(materialized)) writePrivateNew(materialized, retained);
842
900
  else if (!readFileSync(materialized).equals(retained)) throw new Error('Materialized sources differ from retained selection');
843
901
  if (record.mode === 'docker') {
902
+ // Image IDs before this run builds anything: only what it creates or changes becomes proof.
903
+ const imagesBefore = runtimeOnly ? null : await effects.imageIds(record);
904
+ if (isPluginOnly(record)) atomicWriteConfig(join(record.workDir, PLUGIN_ONLY_COMPOSE), pluginOnlyCompose());
844
905
  if (engineName(record) === 'podman') atomicWriteConfig(join(record.workDir, 'compose.podman.json'), JSON.stringify({ services: Object.fromEntries(record.services.map(name => [name, { restart: 'unless-stopped' }])) }));
845
906
  const rebuildPrivateScripts = refreshDockerPolicyCopy(record);
846
907
  const rebuildRecovery = refreshDockerRecovery(record);
@@ -864,6 +925,9 @@ export function networkEffects(effects) {
864
925
  }
865
926
  if (runtimeOnly) return;
866
927
  await effects.prepareServerVolumes(record);
928
+ // Volume preparation may build the maintenance image; record after it.
929
+ const imagesAfter = await effects.imageIds(record);
930
+ await effects.recordOwnedImages(record, changedImages(imagesBefore, imagesAfter));
867
931
  } else {
868
932
  if (!existsSync(join(record.workDir, '.packages-ready'))) {
869
933
  const sourceRoot = join(record.root, `build-${randomUUID()}`);
@@ -903,7 +967,7 @@ export function networkEffects(effects) {
903
967
  ? Buffer.from(`${JSON.stringify(args.resolvedSources, null, 2)}\n`)
904
968
  : readFileSync(args.sources))
905
969
  : readFileSync(record.sourcesPath);
906
- selectSourcePackages(JSON.parse(sources), 'server');
970
+ selectSourcePackages(JSON.parse(sources), serverRole(record));
907
971
  privateDirectory(record.root);
908
972
  const root = mkdtempSync(join(record.root, '.build-'));
909
973
  const candidate = { ...record, root, workDir: join(root, 'runtime'), sourcesPath: join(root, 'sources.json'),
@@ -915,6 +979,9 @@ export function networkEffects(effects) {
915
979
  await effects.prepareInstallation(candidate, { runtimeOnly: true });
916
980
  if (candidate.mode === 'docker') {
917
981
  await compose(candidate, ['build', 'state-operation']);
982
+ // The candidate's project name is new for this build: every image under it was
983
+ // built now. Record them with the installation so removal can prove them.
984
+ await effects.recordOwnedImages(record, await effects.imageIds(candidate));
918
985
  await effects.copyDockerBuildRecords(candidate, candidate.workDir);
919
986
  }
920
987
  // npm emits readable build records; maintenance consumes private copies.
@@ -941,6 +1008,15 @@ export function networkEffects(effects) {
941
1008
  const stage = mkdtempSync(join(directory, '.records-'));
942
1009
  const name = `${record.project}-records`;
943
1010
  let created = false;
1011
+ // An interrupted earlier run can leave its stopped helper behind; clear only that.
1012
+ const stale = await container(record, ['container', 'inspect', name], { allowCodes: [1] });
1013
+ const [value] = stale.code === 0 ? (() => { try { return JSON.parse(stale.stdout); } catch { return []; } })() : [];
1014
+ if (value) {
1015
+ const helper = value?.State?.Running === false && JSON.stringify(value?.Config?.Entrypoint) === JSON.stringify(['/bin/true'])
1016
+ && value?.Config?.Image === `${record.project}:runtime`;
1017
+ if (!helper) throw new Error(`A container named ${name} exists and is not this installer's stopped build-record helper; remove or rename it, then run the installer again`);
1018
+ await container(record, ['rm', name]);
1019
+ }
944
1020
  try {
945
1021
  await container(record, ['create', '--name', name, '--entrypoint', '/bin/true', `${record.project}:runtime`]);
946
1022
  created = true;
@@ -1037,7 +1113,10 @@ export function networkEffects(effects) {
1037
1113
  },
1038
1114
  async publishServerBuild(record, candidate) {
1039
1115
  const previous = join(candidate.root, 'previous-runtime');
1116
+ const built = {};
1040
1117
  if (record.mode === 'docker') {
1118
+ const proven = effects.ownedImages(record);
1119
+ const before = await effects.imageIds(record);
1041
1120
  for (const target of ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]) {
1042
1121
  const retained = `${candidate.project}:previous-${target}`;
1043
1122
  const found = await container(record, ['image', 'inspect', retained], { allowCodes: [1] });
@@ -1045,7 +1124,14 @@ export function networkEffects(effects) {
1045
1124
  const current = await container(record, ['image', 'inspect', `${record.project}:${target}`], { allowCodes: [1] });
1046
1125
  if (current.code === 0) await container(record, ['tag', `${record.project}:${target}`, retained]);
1047
1126
  }
1127
+ // A retained copy is proven only when the image it copies already was.
1128
+ const tag = `${record.project}:${target}`;
1129
+ if (before[tag] && proven[tag] === before[tag]) built[retained] = before[tag];
1048
1130
  }
1131
+ // The candidate project name is a random per-build name kept in the private
1132
+ // record; its images were built by this update.
1133
+ Object.assign(built, Object.fromEntries(Object.entries(await effects.imageIds(candidate, ['runtime', 'maintenance', ...(record.gateway ? ['gateway'] : [])]))
1134
+ .map(([tag, id]) => [`${record.project}:${tag.slice(candidate.project.length + 1)}`, id])));
1049
1135
  }
1050
1136
  if (!existsSync(previous)) {
1051
1137
  privateDirectory(record.workDir);
@@ -1064,6 +1150,27 @@ export function networkEffects(effects) {
1064
1150
  }
1065
1151
  }
1066
1152
  atomicWriteConfig(record.sourcesPath, readFileSync(candidate.sourcesPath));
1153
+ if (record.mode === 'docker') await effects.recordOwnedImages(record, built);
1154
+ },
1155
+ /** Current image IDs of this project's tags (only those that exist). */
1156
+ async imageIds(record, targets = IMAGE_TARGETS) {
1157
+ const images = {};
1158
+ for (const target of targets) {
1159
+ const tag = `${record.project}:${target}`;
1160
+ const found = await container(record, ['image', 'inspect', '--format', '{{.Id}}', tag], { allowCodes: [1] });
1161
+ if (found.code === 0 && /^sha256:[0-9a-f]{64}$/.test(found.stdout.trim())) images[tag] = found.stdout.trim();
1162
+ }
1163
+ return images;
1164
+ },
1165
+ ownedImages(record) {
1166
+ const path = join(record.root, OWNED_IMAGES_FILE);
1167
+ try { const value = JSON.parse(readFileSync(path, 'utf8')); return value?.schema === 1 && value.images && typeof value.images === 'object' ? value.images : {}; } catch { return {}; }
1168
+ },
1169
+ /** Add the IDs of images this run built; never adopts a tag it did not produce. */
1170
+ async recordOwnedImages(record, built) {
1171
+ const path = join(record.root, OWNED_IMAGES_FILE);
1172
+ const next = ownedImagesRecord(existsSync(path) ? readFileSync(path, 'utf8') : null, built);
1173
+ if (next) atomicWriteConfig(path, next);
1067
1174
  },
1068
1175
  async validateServerBuildState(record) {
1069
1176
  if (record.mode === 'docker') {
@@ -1299,6 +1406,19 @@ export function networkEffects(effects) {
1299
1406
  } else await nativeLifecycle(record, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
1300
1407
  effects.out('Managed services removed; installation selection, stored identities, credentials and volumes retained.');
1301
1408
  },
1409
+ /**
1410
+ * Stop a managed installation's services before its complete removal.
1411
+ * Docker writers stop through Compose; containers are removed by project
1412
+ * label afterwards. Native services are retired through their owners, which
1413
+ * also removes their boot registrations.
1414
+ */
1415
+ async serverStopForRemoval(record) {
1416
+ if (record.mode === 'docker') {
1417
+ if (existsSync(record.workDir)) await effects.serverLifecycle(record, 'stop');
1418
+ return;
1419
+ }
1420
+ await nativeLifecycle(record, 'retire', record.services, { effects, localEnv, ownerCommand, bin });
1421
+ },
1302
1422
  async serverLifecycle(record, operation, selected = record.services) {
1303
1423
  record = buildRuntime(record);
1304
1424
  if (record.mode === 'docker') {
@@ -1436,6 +1556,14 @@ export function networkEffects(effects) {
1436
1556
  // This directory retains active executables, not disposable download cache.
1437
1557
  ensurePrivateDirectory(join(home, '.ours-client-install'));
1438
1558
  ensurePrivateDirectory(root);
1559
+ // Record which installation this acquisition serves, for complete removal.
1560
+ let owner = null;
1561
+ try { owner = readHostProfileFile(configPath)?.expectedInstanceId ?? null; } catch { /* not yet imported */ }
1562
+ const ownersPath = join(root, OWNERS_FILE);
1563
+ try {
1564
+ const ownerRecord = owner ? addOwner(existsSync(ownersPath) ? readFileSync(ownersPath, 'utf8') : null, owner) : null;
1565
+ if (ownerRecord) atomicWriteConfig(ownersPath, ownerRecord);
1566
+ } catch (error) { effects.out?.(`! ${error.message}; complete removal will leave ${root} in place and say so.`); }
1439
1567
  const retained = join(root, 'sources.json');
1440
1568
  const bytes = readFileSync(sourcesPath);
1441
1569
  if (!existsSync(retained)) writePrivateNew(retained, bytes);
@@ -91,7 +91,12 @@ export async function runUninstall(argv, effects) {
91
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
92
  return EXIT_REFUSED;
93
93
  }
94
- if (purge) { effects.out(warn('Managed server uninstall retains identities and volumes; --purge is not supported.')); return EXIT_REFUSED; }
94
+ // Complete removal, including identities and volumes, is the separate,
95
+ // confirmed `ours-install remove`; --purge selects exactly that for this root.
96
+ if (purge) {
97
+ const { runRemoval } = await import('./removal.mjs');
98
+ return runRemoval(['--state-dir', dir, ...(args.dryRun ? ['--dry-run'] : [])], effects);
99
+ }
95
100
  if (args.dryRun) { effects.out(info('[dry-run] would stop and remove managed server services, retaining all stored data.')); return EXIT_OK; }
96
101
  const { runServerCommand } = await import('./orchestrate.mjs');
97
102
  return runServerCommand({ role: 'server', operation: 'uninstall', stateDir: dir }, effects);
@@ -41,6 +41,7 @@ import { configJournal, reportRollback } from './journal.mjs';
41
41
  import { detectDaemons, planDaemonSelection, resolveSelection } from './detect.mjs';
42
42
  import { detectPlatform, resolveChannel } from './logic.mjs';
43
43
  import { daemonEnv } from './effects.mjs';
44
+ import { productOf, serverRole, PRODUCT_LABELS } from './product.mjs';
44
45
  import {
45
46
  buildClaudeMarketplace, buildCodexMarketplace, marketplaceJson, marketplacePaths,
46
47
  validateChannelVersion,
@@ -931,6 +932,29 @@ export async function runHarnessPhase(args, effects, { target, isDefaultStateDir
931
932
  let manual = plan.manual;
932
933
  if (exactSuite?.localPackages?.[plan.name]) {
933
934
  const registration = await effects.prepareClientMarketplace(plan.name, exactSuite.localPackages[plan.name]);
935
+ // Codex refuses to re-add a marketplace name from a new folder. An update
936
+ // moves it to a new download folder: replace only a registration that
937
+ // points into an earlier download folder of this installer.
938
+ if (plan.name === 'codex') {
939
+ const current = await effects.codexMarketplace?.();
940
+ const source = current?.marketplaceSource;
941
+ if (current && !(source?.sourceType === 'local' && source?.source === registration)) {
942
+ const earlier = source?.sourceType === 'local' && typeof source.source === 'string'
943
+ && /^[0-9a-f]{16}\/marketplaces\/codex$/.test(source.source.slice(join(effects.home, '.ours-client-install').length + 1))
944
+ && source.source.startsWith(join(effects.home, '.ours-client-install') + '/');
945
+ if (!earlier) {
946
+ const where = source?.source ?? 'another source';
947
+ effects.out(warn(`Codex already has a plugin source named ours-codex-marketplace from ${where}, which this installer did not create. It was left unchanged. Remove it with: codex plugin marketplace remove ours-codex-marketplace, then run the installer again.`));
948
+ rows.push({ ...row, state: 'failed', failedCommand: ['codex', 'plugin', 'marketplace', 'add', registration], failedStep: `Register ${plan.label} marketplace`, detail: `a different ours-codex-marketplace source (${where}) is registered` });
949
+ continue;
950
+ }
951
+ const moved = await attempt(effects, false, 'codex plugin marketplace remove ours-codex-marketplace (earlier download folder)', () => effects.run('codex', ['plugin', 'marketplace', 'remove', 'ours-codex-marketplace'], { env: profileEnv(target) }), clientDiagnostic);
952
+ if (!moved.ok) {
953
+ rows.push({ ...row, state: 'failed', failedCommand: ['codex', 'plugin', 'marketplace', 'remove', 'ours-codex-marketplace'], failedStep: `Move ${plan.label} marketplace`, detail: clientDiagnostic(moved.error) });
954
+ continue;
955
+ }
956
+ }
957
+ }
934
958
  const steps = plan.name === 'codex'
935
959
  ? [['codex', 'plugin', 'marketplace', 'add', registration], ['codex', 'plugin', 'add', 'ours@ours-codex-marketplace']]
936
960
  : [['claude', 'plugin', 'marketplace', 'add', registration], ['claude', 'plugin', await effects.hasClaudePlugin() ? 'update' : 'install', 'ours@ours.network']];
@@ -1377,6 +1401,7 @@ async function executeServerCommand(args, effects) {
1377
1401
  record = validateInstallation(record, args.stateDir);
1378
1402
  if (args.containerEngine && args.containerEngine !== (record.containerEngine ?? 'docker')) throw new Error('Conflicting container engine; explicit migration is required');
1379
1403
  if (args.mode && args.mode !== record.mode) throw new Error('Conflicting runtime mode; retained installation was not changed');
1404
+ if (args.product && args.product !== productOf(record)) throw new Error(`This installation is ${PRODUCT_LABELS[productOf(record)].toLowerCase()}; it is kept as it is. Use a separate empty directory for the other kind of installation.`);
1380
1405
  if (args.operation !== 'update' && args.sources && (record.sourcePolicyHash
1381
1406
  ? effects.sourcePolicyHash(args.sources) !== record.sourcePolicyHash
1382
1407
  : effects.readText(args.sources) !== effects.readText(record.sourcesPath))) throw new Error('Conflicting source selection; use explicit server update');
@@ -1417,7 +1442,7 @@ async function executeServerCommand(args, effects) {
1417
1442
  if ((!existing || args.operation === 'update') && !record.buildTransition) {
1418
1443
  args.resolvedSources = await installStage('Package selection', 'Resolve the selected server packages before installation.', async () => {
1419
1444
  const policy = args.sourcePolicy ?? (args.sources ? effects.readJson(args.sources) : effects.packagedSourcePolicy());
1420
- return effects.resolveSourcePolicy(policy, 'server');
1445
+ return effects.resolveSourcePolicy(policy, serverRole(record));
1421
1446
  });
1422
1447
  }
1423
1448
  if (!existing && args.sources) record.sourcePolicyHash = effects.sourcePolicyHash(args.sources);
@@ -0,0 +1,63 @@
1
+ // Ownership records written at installation time, read by complete removal.
2
+ //
3
+ // A client generation under ~/.ours-client-install can serve more than one
4
+ // managed installation, and identical selections are not proof of who uses it.
5
+ // Each acquisition therefore records the installation instances it was acquired
6
+ // for. Server roots record the image IDs this installer built, so removal never
7
+ // relies on an image name alone.
8
+ import { join } from 'node:path';
9
+
10
+ export const OWNERS_FILE = 'owners.json';
11
+ export const OWNED_IMAGES_FILE = 'owned-images.json';
12
+ const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/;
13
+
14
+ const IMAGE_ID = /^sha256:[0-9a-f]{64}$/;
15
+ const IMAGE_TAG = /^ours-[a-z0-9]+:[a-z-]+$/;
16
+
17
+ /** A generation's owner record: absent, invalid (damaged or unknown format) or the owner IDs. */
18
+ export function readOwners(effects, generation) {
19
+ const text = effects.readText(join(generation, OWNERS_FILE));
20
+ if (text === null) return { state: 'absent' };
21
+ const instances = parseOwners(text);
22
+ return instances ? { state: 'valid', instances } : { state: 'invalid' };
23
+ }
24
+
25
+ function parseOwners(text) {
26
+ try {
27
+ const value = JSON.parse(text);
28
+ if (value?.schema !== 1 || !Array.isArray(value.instances) || value.instances.some(id => !UUID.test(id)) || Object.keys(value).length !== 2) return null;
29
+ return [...new Set(value.instances)];
30
+ } catch { return null; }
31
+ }
32
+
33
+ /**
34
+ * The next owner record for a generation, or null when nothing changes. A
35
+ * damaged record is never replaced: that would forget its earlier owners.
36
+ */
37
+ export function addOwner(text, instanceId) {
38
+ if (!UUID.test(instanceId ?? '')) return null;
39
+ const instances = text === null || text === undefined ? [] : parseOwners(text);
40
+ if (instances === null) throw new Error('The owner record of the downloaded Ours programs is damaged; it was left unchanged');
41
+ if (instances.includes(instanceId)) return null;
42
+ return JSON.stringify({ schema: 1, instances: [...instances, instanceId] }, null, 2) + '\n';
43
+ }
44
+
45
+ /** The next owned-images record for a server root; a damaged record is left as it is. */
46
+ export function ownedImagesRecord(text, images) {
47
+ let current = {};
48
+ if (text !== null && text !== undefined) {
49
+ let value;
50
+ try { value = JSON.parse(text); } catch { return null; }
51
+ if (value?.schema !== 1 || !value.images || typeof value.images !== 'object' || Array.isArray(value.images)
52
+ || Object.entries(value.images).some(([tag, id]) => !IMAGE_TAG.test(tag) || !IMAGE_ID.test(id))) return null;
53
+ current = value.images;
54
+ }
55
+ const added = Object.fromEntries(Object.entries(images ?? {}).filter(([tag, id]) => IMAGE_TAG.test(tag) && IMAGE_ID.test(id)));
56
+ const next = { ...current, ...added };
57
+ return JSON.stringify(current) === JSON.stringify(next) ? null : JSON.stringify({ schema: 1, images: next }, null, 2) + '\n';
58
+ }
59
+
60
+ /** Tags whose image this run created or replaced: a tag it found unchanged is never adopted. */
61
+ export function changedImages(before, after) {
62
+ return Object.fromEntries(Object.entries(after ?? {}).filter(([tag, id]) => (before ?? {})[tag] !== id));
63
+ }
package/lib/plan.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { validateContainerEngine } from './container-engine.mjs';
2
2
  import { gatewayAddress } from './gateway.mjs';
3
+ import { PRODUCTS } from './product.mjs';
3
4
  // ours-install v3 — daemon creation and boot-service installation.
4
5
  //
5
6
  // Pure planning code, like lib/target.mjs: the orchestrator
@@ -283,7 +284,11 @@ export function planDaemonSteps(target, { cliVersionChanged = false, cliStartedI
283
284
  export const SERVER_PACKAGES = ['sdk', 'cli', 'daemon', 'tg-connector', 'cowork', 'messenger-server'].map(n => `@ours.network/${n}`);
284
285
  /** Selected when the policy names it; retained policies from before it existed stay valid. */
285
286
  export const NOTIFICATIONS_PACKAGE = '@ours.network/notifications';
286
- const serverPackages = manifest => [...SERVER_PACKAGES, ...(manifest?.packages?.[NOTIFICATIONS_PACKAGE] ? [NOTIFICATIONS_PACKAGE] : [])];
287
+ /** Plugin-only installations select only these; no application packages. */
288
+ export const DAEMON_PACKAGES = ['sdk', 'cli', 'daemon'].map(n => `@ours.network/${n}`);
289
+ const serverPackages = (manifest, role = 'server') => role === 'daemon' ? DAEMON_PACKAGES
290
+ : [...SERVER_PACKAGES, ...(manifest?.packages?.[NOTIFICATIONS_PACKAGE] ? [NOTIFICATIONS_PACKAGE] : [])];
291
+ const serverRoles = ['server', 'daemon'];
287
292
  export const SERVER_DEPENDENCIES = {
288
293
  daemon: [], telegram: ['daemon'], cowork: ['daemon'], messenger: ['daemon'],
289
294
  };
@@ -299,7 +304,7 @@ export function maintenanceServices(record, domain) {
299
304
 
300
305
  /** Validate exact supplied selections, without rewriting the source authority. */
301
306
  export function selectSourcePackages(manifest, role, clients = []) {
302
- const names = role === 'server' ? serverPackages(manifest) : clients.map(n => `@ours.network/${n}`);
307
+ const names = serverRoles.includes(role) ? serverPackages(manifest, role) : clients.map(n => `@ours.network/${n}`);
303
308
  const result = {};
304
309
  for (const name of names) {
305
310
  const selected = manifest?.packages?.[name];
@@ -317,7 +322,7 @@ export function selectSourcePackages(manifest, role, clients = []) {
317
322
  /** Resolve a packaged compatibility policy into a role-filtered exact selection. */
318
323
  export async function resolveSourcePolicy(manifest, role, clients = [], resolveNpm) {
319
324
  const release = releaseBinding(manifest);
320
- const names = role === 'server' ? serverPackages(manifest) : clients.map(name => `@ours.network/${name}`);
325
+ const names = serverRoles.includes(role) ? serverPackages(manifest, role) : clients.map(name => `@ours.network/${name}`);
321
326
  const packages = {};
322
327
  const sourceNames = new Set();
323
328
  for (const name of names) {
@@ -371,6 +376,11 @@ export function validateInstallation(record, root) {
371
376
  || !Array.isArray(record.services) || record.services[0] !== 'daemon' || new Set(record.services).size !== record.services.length || record.services.some(s => !SERVER_SERVICES.includes(s) && !(s === 'gateway' && record.gateway?.version === 1))) {
372
377
  throw new Error('Invalid or conflicting installation selection');
373
378
  }
379
+ if (record.product !== undefined && (!PRODUCTS.includes(record.product) || record.product === 'workspace'
380
+ || record.schema !== 2 || record.mode !== 'docker' || record.gateway !== undefined
381
+ || JSON.stringify(record.services) !== JSON.stringify(['daemon']))) {
382
+ throw new Error('Invalid plugin-only installation selection');
383
+ }
374
384
  if (record.gateway !== undefined && (record.schema !== 2 || record.mode !== 'docker' || record.gateway?.version !== 1
375
385
  || Object.keys(record.gateway).some(key => !['version', 'serverUrl'].includes(key)) || record.services.at(-1) !== 'gateway')) {
376
386
  throw new Error('Invalid gateway installation selection');
@@ -414,7 +424,7 @@ export function validateInstallation(record, root) {
414
424
  throw new Error('Invalid server build transition');
415
425
  }
416
426
  validateInstallation(candidate, candidate.root);
417
- for (const key of ['schema', 'mode', 'containerEngine', 'containerBinding', 'instanceId', 'services', 'gateway', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
427
+ for (const key of ['schema', 'mode', 'product', 'containerEngine', 'containerBinding', 'instanceId', 'services', 'gateway', 'port', 'coworkPort', 'messengerPort', 'messengerIdentity', 'uid', 'gid']) {
418
428
  if (JSON.stringify(candidate[key]) !== JSON.stringify(record[key])) throw new Error('Conflicting server build candidate');
419
429
  }
420
430
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * What a managed installation is for. Recorded once in installation.json and
3
+ * retained by every later repair, update and removal: neither choice is ever
4
+ * widened or narrowed implicitly.
5
+ *
6
+ * workspace the complete Ours workspace: daemon, Telegram, Cowork,
7
+ * Messenger (with Notifications) and the one-URL gateway, plus
8
+ * Fleet on the host. Records without a product are workspaces.
9
+ * plugin-only collaboration tools for existing agent apps: only the daemon
10
+ * container, reached by Claude Code / Codex through the daemon's
11
+ * own client prefix. No gateway, Cowork, Messenger, Telegram,
12
+ * Notifications or Fleet.
13
+ */
14
+ export const PRODUCTS = ['workspace', 'plugin-only'];
15
+ export const PLUGIN_ONLY = 'plugin-only';
16
+ export const PLUGIN_ONLY_INTEGRATIONS = ['claude-code', 'codex'];
17
+ /** Container port of the daemon's client prefix; the raw daemon port stays internal. */
18
+ export const CLIENT_PREFIX_PORT = 3049;
19
+
20
+ export const productOf = record => record?.product ?? 'workspace';
21
+ export const isPluginOnly = value => (typeof value === 'string' ? value : productOf(value)) === PLUGIN_ONLY;
22
+ /** Package selection role: plugin-only selects only the SDK, CLI and daemon. */
23
+ export const serverRole = value => isPluginOnly(value) ? 'daemon' : 'server';
24
+ /** Applications whose state, credentials and services the installation owns. */
25
+ export const applicationsOf = record => record.services.filter(service => service !== 'gateway');
26
+
27
+ export const PRODUCT_LABELS = {
28
+ 'plugin-only': 'Collaboration tools for my existing agent apps',
29
+ workspace: 'The complete Ours workspace',
30
+ };
31
+
32
+ /**
33
+ * Compose overlay for a plugin-only installation. The daemon container serves
34
+ * the client prefix itself and publishes only that port, on loopback; the raw
35
+ * daemon API is not published. Administrative jobs see only daemon state, so no
36
+ * application state, configuration or credential is created for services this
37
+ * installation does not run.
38
+ */
39
+ export function pluginOnlyCompose() {
40
+ const volume = (target, subpath) => ` - {type: volume, source: server-storage, target: ${target}, volume: {nocopy: true${subpath ? `, subpath: ${subpath}` : ''}}}`;
41
+ const locks = ' - {type: volume, source: owner-locks, target: "/tmp/ours-${OURS_UID:-1000}", volume: {nocopy: true}}';
42
+ return `services:
43
+ prepare:
44
+ environment:
45
+ OURS_SERVER_APPLICATIONS: daemon
46
+ daemon:
47
+ entrypoint: [node, /opt/ours/docker/daemon-client-prefix.mjs]
48
+ environment:
49
+ OURS_SERVER_APPLICATIONS: daemon
50
+ OURS_DAEMON_CLIENT_PREFIX_PORT: "${CLIENT_PREFIX_PORT}"
51
+ ports: !override
52
+ - {target: ${CLIENT_PREFIX_PORT}, published: "\${OURS_HOST_PORT:-3050}", host_ip: 127.0.0.1}
53
+ access:
54
+ environment:
55
+ OURS_SERVER_APPLICATIONS: daemon
56
+ volumes: !override
57
+ ${volume('/var/lib/ours', 'state/daemon')}
58
+ ${volume('/var/lib/ours-mcp', 'state/mcp')}
59
+ ${locks}
60
+ state-operation:
61
+ environment:
62
+ OURS_SERVER_APPLICATIONS: daemon
63
+ volumes: !override
64
+ ${volume('/storage')}
65
+ ${volume('/var/lib/ours', 'state/daemon')}
66
+ ${volume('/var/lib/ours-mcp', 'state/mcp')}
67
+ `;
68
+ }
69
+ export const PLUGIN_ONLY_COMPOSE = 'compose.plugin-only.yaml';