@c4roa0/agentpay-user 0.3.7 → 0.3.8

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.
Files changed (43) hide show
  1. package/README.md +3 -1
  2. package/doc/README.md +10 -0
  3. package/doc/TODO.md +38 -3
  4. package/doc/plans/local-instances-and-install-recovery.md +250 -0
  5. package/doc/records/client-isolated-acceptance-2026-11-11.md +37 -0
  6. package/doc/records/dual-install-systemd-2026-11-12.md +40 -0
  7. package/doc/records/historical-request-routing-2026-11-11.md +47 -0
  8. package/doc/records/installation-maintenance-2026-11-11.md +46 -0
  9. package/doc/reference/local-update.md +2 -1
  10. package/doc/runbooks/commands.md +28 -0
  11. package/doc/runbooks/local-update.md +11 -5
  12. package/package.json +1 -1
  13. package/progress.md +93 -6
  14. package/scripts/autostart.mjs +51 -25
  15. package/scripts/install-user.mjs +10 -3
  16. package/scripts/local-service.mjs +1 -1
  17. package/scripts/user-setup.mjs +4 -0
  18. package/src/default-request-owner.mjs +163 -0
  19. package/src/index.mjs +9 -0
  20. package/src/install-and-setup.mjs +6 -1
  21. package/src/install-selection.mjs +41 -0
  22. package/src/install-user.mjs +22 -22
  23. package/src/installation-binding.mjs +62 -0
  24. package/src/installation-operation.mjs +114 -0
  25. package/src/installation-paths.mjs +50 -1
  26. package/src/invocation-store.mjs +1 -1
  27. package/src/local-data-directory.mjs +17 -17
  28. package/src/local-update.mjs +41 -10
  29. package/src/local-wallet-ipc.mjs +2 -2
  30. package/src/npm-update.mjs +55 -19
  31. package/src/platform-endpoints.mjs +5 -8
  32. package/src/runtime-client.mjs +21 -4
  33. package/src/runtime-connection-settings.mjs +2 -1
  34. package/src/runtime-owner.mjs +22 -0
  35. package/src/runtime-selection-ready.mjs +29 -0
  36. package/src/service-readiness.mjs +29 -0
  37. package/src/service-selection.mjs +189 -0
  38. package/src/setup-autostart.mjs +12 -2
  39. package/src/setup-service-selection.mjs +25 -0
  40. package/src/skill-client.mjs +8 -3
  41. package/src/uninstall-local.mjs +69 -15
  42. package/src/user-cli.mjs +1 -2
  43. package/update-pair.json +1 -1
@@ -1,3 +1,4 @@
1
+ import {withInstallationOperation, installationChildOptions} from './installation-operation.mjs';
1
2
  import { installationDirectory, importSetupFiles } from "./installation-paths.mjs";
2
3
  // SPDX-License-Identifier: MPL-2.0
3
4
  // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
@@ -70,7 +71,10 @@ async function saveJson(path, value) {
70
71
  await unlink(temp).catch(() => {});
71
72
  }
72
73
  }
73
- export async function runInstall({
74
+ export async function runInstall(options) {
75
+ return withInstallationOperation(() => installUnderLock(options));
76
+ }
77
+ async function installUnderLock({
74
78
  userDir,
75
79
  market,
76
80
  dataDir,
@@ -81,6 +85,7 @@ export async function runInstall({
81
85
  prepareOnly = false,
82
86
  beforePrepare,
83
87
  afterPrepare,
88
+ beforeApplySettings,
84
89
  }) {
85
90
  userDir = resolve(userDir);
86
91
 
@@ -100,17 +105,6 @@ export async function runInstall({
100
105
  const stateInfo = await lstat(stateDir);
101
106
  if (!stateInfo.isDirectory() || stateInfo.isSymbolicLink() || stateInfo.uid !== process.getuid() || stateInfo.mode & 0o077)
102
107
  throw Error('Installation state directory must be owner-held and private.');
103
- const lockPath = join(stateDir, "install.lock");
104
- let lock;
105
- try {
106
- lock = await open(lockPath, "wx", 0o600);
107
- } catch (e) {
108
- if (e.code === "EEXIST")
109
- throw Error(
110
- `Another installation may be running. Check ${lockPath} before removing a stale lock.`,
111
- );
112
- throw e;
113
- }
114
108
  const logPath = join(stateDir, `install-${Date.now()}-${randomUUID()}.log`),
115
109
  statePath = join(stateDir, "state.json");
116
110
  let state = { version: 1 };
@@ -162,7 +156,7 @@ export async function runInstall({
162
156
  const inputs = sourceInstall ? await fingerprint(userDir, ['package.json', 'package-lock.json']) : null;
163
157
  if (sourceInstall && (state.userDependencies !== inputs || !initial.ok)) {
164
158
  write('1/2 Install the pinned User and Wallet dependencies. This may take a few minutes.');
165
- await run('npm', ['ci'], {cwd:userDir, logPath});
159
+ await run('npm', ['ci', '--ignore-scripts'], {cwd:userDir, logPath});
166
160
  state.userDependencies = inputs;
167
161
  } else write('1/2 Check installed User and Wallet packages.');
168
162
  const checked = await checkLocalInstallation({
@@ -175,10 +169,10 @@ export async function runInstall({
175
169
  throw Error(`${failed.message}. ${failed.fix || ""}`);
176
170
  }
177
171
  await mkdir(privateDir, {recursive:true, mode:0o700});
178
- await importSetupFiles(privateDir, userDir);
179
- state.dataDir = privateDir;
180
- await saveJson(statePath, state);
181
172
  if (prepareOnly) {
173
+ await importSetupFiles(privateDir, userDir);
174
+ state.dataDir = privateDir;
175
+ await saveJson(statePath, state);
182
176
  if (afterPrepare) await afterPrepare(preparationContext);
183
177
  return { completed: true, logPath };
184
178
  }
@@ -220,7 +214,16 @@ export async function runInstall({
220
214
  "./prepare-onboarding.mjs"
221
215
  );
222
216
  const config = validatePublicDeployment(JSON.parse(raw));
217
+ // Keep downloaded settings in memory until the serialized service switch is
218
+ // approved. A cancelled switch must not alter the other installation's data.
219
+ if (beforeApplySettings) {
220
+ const code = await beforeApplySettings();
221
+ if (code) return {completed:false,cancelled:true,exitCode:code,logPath};
222
+ }
223
+ await importSetupFiles(privateDir, userDir);
223
224
  await saveJson(join(privateDir, "user-deployment.json"), config);
225
+ state.dataDir = privateDir;
226
+ await saveJson(statePath, state);
224
227
  write(
225
228
  "User, Wallet and Market settings are ready. Continuing to setup.",
226
229
  );
@@ -235,9 +238,6 @@ export async function runInstall({
235
238
  error.messageDisplayed = true;
236
239
  error.logPath = logPath;
237
240
  throw error;
238
- } finally {
239
- await lock.close();
240
- await unlink(lockPath);
241
241
  }
242
242
  }
243
243
  async function runCommand(command, args, { cwd, logPath }) {
@@ -246,7 +246,7 @@ async function runCommand(command, args, { cwd, logPath }) {
246
246
  await new Promise((resolve, reject) => {
247
247
  const child = spawn(command, args, {
248
248
  cwd,
249
- env: {
249
+ ...installationChildOptions({env: {
250
250
  ...process.env,
251
251
  PATH:
252
252
  dirname(process.execPath) +
@@ -254,7 +254,7 @@ async function runCommand(command, args, { cwd, logPath }) {
254
254
  (process.env.PATH || ""),
255
255
  COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
256
256
  },
257
- stdio: ["ignore", log.fd, log.fd],
257
+ stdio: ["ignore", log.fd, log.fd]}),
258
258
  });
259
259
  child.once("error", (e) =>
260
260
  reject(
@@ -263,7 +263,7 @@ async function runCommand(command, args, { cwd, logPath }) {
263
263
  ),
264
264
  ),
265
265
  );
266
- child.once("exit", (code) =>
266
+ child.once("close", (code) =>
267
267
  code === 0
268
268
  ? resolve()
269
269
  : reject(
@@ -0,0 +1,62 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ // Installation routing is local metadata, never Wallet or payment identity.
3
+ import { constants } from 'node:fs';
4
+ import { open, realpath, mkdir, link, unlink, lstat } from 'node:fs/promises';
5
+ import { join, resolve, isAbsolute } from 'node:path';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { installationDirectory } from './installation-paths.mjs';
8
+ import { atomicWritePrivate } from './private-journal-files.mjs';
9
+
10
+ async function location(userDir) {
11
+ const root = await realpath(userDir);
12
+ const directory = join(installationDirectory(), 'bindings');
13
+ const key = createHash('sha256').update(root).digest('hex');
14
+ return {root, directory, path: join(directory, key + '.json')};
15
+ }
16
+ export async function readInstallationBinding(userDir) {
17
+ let place;
18
+ try { place = await location(userDir); }
19
+ catch (e) { if (e.code === 'ENOENT') return null; throw e; }
20
+ let handle;
21
+ try {
22
+ const directory = await lstat(place.directory);
23
+ if (!directory.isDirectory() || directory.isSymbolicLink() || directory.uid !== process.getuid() || directory.mode & 0o077)
24
+ throw Error('Installation bindings directory must be private and owned by you.');
25
+ handle = await open(place.path, constants.O_RDONLY | constants.O_NOFOLLOW);
26
+ const info = await handle.stat();
27
+ if (!info.isFile() || info.nlink !== 1 || info.uid !== process.getuid() || info.mode & 0o077 || info.size > 16384)
28
+ throw Error('Installation binding must be a private file owned by you.');
29
+ const value = JSON.parse(await handle.readFile('utf8'));
30
+ if (value.version !== 1 || value.installation !== place.root || typeof value.dataDir !== 'string' || !isAbsolute(value.dataDir) || value.dataDir.includes('\0'))
31
+ throw Error('Installation binding is invalid. Keep it for review.');
32
+ return value;
33
+ } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
34
+ finally { await handle?.close(); }
35
+ }
36
+ export async function rememberInstallationBinding(userDir, dataDir) {
37
+ const place = await location(userDir);
38
+ // Missing data is valid before setup. Existing paths use their canonical location.
39
+ const selected = await realpath(resolve(dataDir)).catch(e => {
40
+ if (e.code === 'ENOENT') return resolve(dataDir);
41
+ throw e;
42
+ });
43
+ const saved = await readInstallationBinding(userDir);
44
+ if (saved) {
45
+ if (saved.dataDir !== selected) throw Error('This installation uses another data folder. Explicit adoption is required before changing it.');
46
+ return saved;
47
+ }
48
+ await mkdir(place.directory, {recursive:true, mode:0o700});
49
+ const value = {version:1, installation:place.root, dataDir:selected};
50
+ const temporary = join(place.directory, '.binding-' + randomUUID());
51
+ try {
52
+ await atomicWritePrivate(temporary, JSON.stringify(value) + '\n');
53
+ // Publish without replacing a concurrently established binding.
54
+ try { await link(temporary, place.path); }
55
+ catch (e) { if (e.code !== 'EEXIST') throw e; }
56
+ } finally { await unlink(temporary).catch(e => { if (e.code !== 'ENOENT') throw e; }); }
57
+ const actual = await readInstallationBinding(userDir);
58
+ if (actual.dataDir !== selected) throw Error('Another command selected a different data folder. No binding was replaced.');
59
+ return actual;
60
+ }
61
+
62
+ export async function installationBindingPath(userDir) { return (await location(userDir)).path; }
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ // Serialize installation writes and managed-service changes. Descriptor locks are
3
+ // inherited by npm/repair children: killing the caller does not unlock a live writer.
4
+ import {AsyncLocalStorage} from 'node:async_hooks';
5
+ import {constants, fstatSync} from 'node:fs';
6
+ import {open, mkdir, lstat, unlink, link} from 'node:fs/promises';
7
+ import {join} from 'node:path';
8
+ import {randomUUID} from 'node:crypto';
9
+ import {ensureInstallationDirectory} from './installation-paths.mjs';
10
+
11
+ const context = new AsyncLocalStorage();
12
+ export const installationLockMarker = 'agentpay.install-lock.v2\n';
13
+ const marker = installationLockMarker;
14
+ const fdVariable = 'AGENTPAY_INSTALLATION_LOCK_FD';
15
+ const busy = () => Error('Another AgentPay installation or service change is running. Wait for it to finish, then retry.');
16
+ function checkFile(info, allowLinks = false) {
17
+ if (!info.isFile() || (!allowLinks && info.nlink !== 1) || info.uid !== process.getuid() || info.mode & 0o077)
18
+ throw Error('Installation lock must be a private regular file owned by you.');
19
+ }
20
+
21
+ export async function withInstallationOperation(operation) {
22
+ const directory = await ensureInstallationDirectory();
23
+ const existing = context.getStore();
24
+ if (existing?.directory === directory) return operation();
25
+ await mkdir(directory,{recursive:true,mode:0o700});
26
+ const info = await lstat(directory);
27
+ if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid() || info.mode & 0o077)
28
+ throw Error('Installation state directory must be private and owned by you.');
29
+ let tryLock;
30
+ try { ({tryLock} = await import('fs-native-extensions')); }
31
+ catch { throw Error('Installation tools are missing. Reinstall the User npm package, or run npm ci in your User source folder, then retry.'); }
32
+ const path = join(directory,'service-selection.lock');
33
+ const file = await open(path,constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW,0o600);
34
+ let ownsMarker = false;
35
+ try {
36
+ const stat = await file.stat(); checkFile(stat);
37
+ const inherited = process.env[fdVariable];
38
+ if (inherited !== undefined) {
39
+ if (!/^\d+$/.test(inherited) || Number(inherited) < 3) throw Error('Invalid installation lock handoff.');
40
+ const fd = Number(inherited), held = fstatSync(fd); checkFile(held);
41
+ if (held.dev !== stat.dev || held.ino !== stat.ino || !tryLock(fd)) throw Error('Invalid installation lock handoff.');
42
+ // A separately opened descriptor must be excluded, proving the inherited
43
+ // descriptor really protects this file; an environment flag alone is not a bypass.
44
+ if (tryLock(file.fd)) throw Error('Installation lock handoff did not preserve exclusion.');
45
+ return await context.run({directory,fd},operation);
46
+ }
47
+ if (!tryLock(file.fd)) throw busy();
48
+ // Preserve interoperability with older installers. Only our recognizable v2
49
+ // marker can be reclaimed after acquiring the native lock. Unknown/empty old
50
+ // markers need operator review, never PID-age based deletion.
51
+ const legacyPath = join(directory,'install.lock');
52
+ const temporary = join(directory, 'install-marker-' + randomUUID());
53
+ let legacy;
54
+ try {
55
+ legacy = await open(temporary,'wx',0o600);
56
+ await legacy.writeFile(marker); await legacy.sync(); await legacy.close(); legacy=undefined;
57
+ await link(temporary,legacyPath);
58
+ } catch(error) {
59
+ await legacy?.close(); legacy = undefined;
60
+ if (error.code !== 'EEXIST') throw error;
61
+ legacy = await open(legacyPath,constants.O_RDONLY | constants.O_NOFOLLOW);
62
+ // A crash between link publication and temporary-name cleanup can leave
63
+ // two names for this marker. We only read/unlink it; the actual native
64
+ // lock file still requires exactly one link and provides exclusion.
65
+ const saved = await legacy.stat(); checkFile(saved, true);
66
+ if (saved.size !== Buffer.byteLength(marker) || await legacy.readFile('utf8') !== marker)
67
+ throw Error('An older installer lock exists. Keep it for review; automatic recovery cannot confirm whether its writer has stopped.');
68
+ } finally {
69
+ await legacy?.close();
70
+ await unlink(temporary).catch(error=>{if(error.code!=='ENOENT')throw error;});
71
+ }
72
+ ownsMarker = true;
73
+ return await context.run({directory,fd:file.fd},operation);
74
+ } finally {
75
+ // Do not unlock explicitly: inherited children may still hold the same open
76
+ // description. Production command runners wait for child close before returning.
77
+ try {
78
+ if (ownsMarker) await unlink(join(directory,'install.lock')).catch(e=>{if(e.code!=='ENOENT')throw e;});
79
+ } finally { await file.close(); }
80
+ }
81
+ }
82
+
83
+ // Only short-lived administration/writer children inherit the lock. Never pass it
84
+ // to the Wallet/Runtime daemons, which would keep maintenance blocked indefinitely.
85
+ export function installationChildOptions({env = process.env, stdio = ['ignore','pipe','pipe']} = {}) {
86
+ const held = context.getStore();
87
+ const clean = {...env}; delete clean[fdVariable];
88
+ if (!held) return {env:clean,stdio};
89
+ return {env:{...clean,[fdVariable]:String(stdio.length)},stdio:[...stdio,held.fd]};
90
+ }
91
+
92
+ // Wait for close even on timeout/error: returning on a timer while npm still writes
93
+ // would release the caller's lock too early. Writer children retain the descriptor.
94
+ export async function runInstallationCommand(file, args, {env = process.env, timeout = 120000, maxBuffer = 2 * 1024 * 1024, cwd} = {}) {
95
+ const {spawn} = await import('node:child_process');
96
+ return new Promise((resolve,reject) => {
97
+ const child = spawn(file,args,{cwd,...installationChildOptions({env})});
98
+ let stdout='',stderr='',failure,killTimer;
99
+ const stop = error => {
100
+ failure ||= error;
101
+ child.kill('SIGTERM');
102
+ killTimer ||= setTimeout(()=>child.kill('SIGKILL'),1000);
103
+ };
104
+ const timer = setTimeout(()=>stop(Error('Command timed out.')),timeout);
105
+ child.stdout.on('data',chunk=>{if(failure)return;stdout+=chunk;if(Buffer.byteLength(stdout)>maxBuffer)stop(Error('Command output exceeded its limit.'));});
106
+ child.stderr.on('data',chunk=>{if(failure)return;stderr+=chunk;if(Buffer.byteLength(stderr)>maxBuffer)stop(Error('Command output exceeded its limit.'));});
107
+ child.once('error',error=>{failure=error;});
108
+ child.once('close',(code,signal)=>{
109
+ clearTimeout(timer); clearTimeout(killTimer);
110
+ if(failure || code!==0) reject(Object.assign(failure || Error(`Command failed (${signal || code}).`),{stdout,stderr}));
111
+ else resolve({stdout,stderr});
112
+ });
113
+ });
114
+ }
@@ -3,12 +3,61 @@
3
3
  // per-user state storage; explicit data-directory settings still select key/history data.
4
4
  import { homedir } from 'node:os';
5
5
  import { isAbsolute, join, dirname } from 'node:path';
6
- import { lstat, mkdir, copyFile, constants } from 'node:fs/promises';
6
+ import { lstat, mkdir, copyFile, constants, link, unlink, realpath } from 'node:fs/promises';
7
+ import {openSync,closeSync,fstatSync,readFileSync,lstatSync} from 'node:fs';
8
+ import {randomUUID} from 'node:crypto';
9
+ import {atomicWritePrivate} from './private-journal-files.mjs';
10
+ export function stateLocationFile() {
11
+ return join(homedir(),'.local/state/agentpay/state-location.json');
12
+ }
7
13
  export function installationDirectory() {
14
+ // A fixed, non-secret locator makes login services and Skills independent of
15
+ // shell-only XDG settings. XDG chooses the initial location, not a second instance.
16
+ let handle;
17
+ try {
18
+ const path=stateLocationFile(),parent=lstatSync(dirname(path));
19
+ if(!parent.isDirectory() || parent.isSymbolicLink() || parent.uid!==process.getuid() || parent.mode & 0o022)
20
+ throw Error('AgentPay state locator directory needs ownership review.');
21
+ handle=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW);
22
+ const st=fstatSync(handle);
23
+ if(!st.isFile() || st.uid!==process.getuid() || st.mode & 0o077 || st.size>16384)
24
+ throw Error('AgentPay state locator must be private and owned by you.');
25
+ const saved=JSON.parse(readFileSync(handle,'utf8'));
26
+ if(saved.version!==1 || typeof saved.directory!=='string' || !isAbsolute(saved.directory) || /[\x00-\x1f\x7f]/.test(saved.directory))
27
+ throw Error('Invalid AgentPay state locator. Preserve it for review.');
28
+ return saved.directory;
29
+ } catch(error) {if(error.code!=='ENOENT')throw error;}
30
+ finally {if(handle!==undefined)closeSync(handle);}
8
31
  const base = process.env.XDG_STATE_HOME || join(homedir(), '.local/state');
9
32
  if (!isAbsolute(base)) throw Error('XDG_STATE_HOME must be an absolute path.');
10
33
  return join(base, 'agentpay/user');
11
34
  }
35
+ export async function ensureInstallationDirectory() {
36
+ const requested=installationDirectory();
37
+ await mkdir(requested,{recursive:true,mode:0o700});
38
+ const info=await lstat(requested);
39
+ if(!info.isDirectory() || info.isSymbolicLink() || info.uid!==process.getuid() || info.mode & 0o077)
40
+ throw Error('Installation state directory must be private and owned by you.');
41
+ const directory=await realpath(requested),path=stateLocationFile();
42
+ try {
43
+ await lstat(path);
44
+ if(installationDirectory()!==directory)
45
+ throw Error('Another command selected the AgentPay state directory. Retry using its saved location.');
46
+ return directory;
47
+ } catch(error) { if(error.code!=='ENOENT')throw error; }
48
+ await mkdir(dirname(path),{recursive:true,mode:0o700});
49
+ const parent=await lstat(dirname(path));
50
+ if(!parent.isDirectory() || parent.isSymbolicLink() || parent.uid!==process.getuid() || parent.mode & 0o022)
51
+ throw Error('AgentPay state locator directory needs ownership review.');
52
+ const temporary=path+'.'+randomUUID();
53
+ try {
54
+ await atomicWritePrivate(temporary,JSON.stringify({version:1,directory})+'\n');
55
+ try {await link(temporary,path);}catch(error){if(error.code!=='EEXIST')throw error;}
56
+ } finally {await unlink(temporary).catch(error=>{if(error.code!=='ENOENT')throw error;});}
57
+ if(installationDirectory()!==directory)
58
+ throw Error('Another command selected the AgentPay state directory. Retry using its saved location.');
59
+ return directory;
60
+ }
12
61
  export function legacyInstallationDirectory(userDir) { return join(dirname(userDir), '.agentpay-install'); }
13
62
  export async function deploymentFilePath(dataDir, userDir, name) {
14
63
  if (!['anvil-onboarding.json', 'user-deployment.json'].includes(name)) throw Error('Invalid setup filename.');
@@ -367,7 +367,7 @@ export function createInvocationStore(path, serviceBinding = null) {
367
367
  });
368
368
  }
369
369
 
370
- function hashInvocationKey(value) {
370
+ export function hashInvocationKey(value) {
371
371
  const normalized = String(value || "").trim();
372
372
  if (!/^[A-Za-z0-9._:-]{8,128}$/.test(normalized))
373
373
  throw coded(
@@ -1,9 +1,11 @@
1
+ import {withInstallationOperation} from './installation-operation.mjs';
2
+ import { readInstallationBinding, rememberInstallationBinding } from "./installation-binding.mjs";
1
3
  import { installationDirectory, legacyInstallationDirectory } from "./installation-paths.mjs";
2
4
  // SPDX-License-Identifier: MPL-2.0
3
5
  // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
4
6
  // If a copy was not distributed with this file, see https://mozilla.org/MPL/2.0/.
5
7
  import { constants } from "node:fs";
6
- import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
8
+ import { lstat, mkdir, open, rename, unlink, realpath } from "node:fs/promises";
7
9
  import { dirname, isAbsolute, join, resolve } from "node:path";
8
10
  import { homedir } from "node:os";
9
11
  import { fileURLToPath } from "node:url";
@@ -65,13 +67,23 @@ export async function resolveUserDataDir({
65
67
  env = process.env,
66
68
  userDir = installedUserDir,
67
69
  } = {}) {
68
- if (argument || env.AGENTPAY_USER_DATA_DIR)
69
- return resolve(argument || env.AGENTPAY_USER_DATA_DIR);
70
+ const binding = await readInstallationBinding(userDir);
71
+ if (argument || env.AGENTPAY_USER_DATA_DIR) {
72
+ const selected = resolve(argument || env.AGENTPAY_USER_DATA_DIR);
73
+ const canonical = await realpath(selected).catch(e => { if (e.code === 'ENOENT') return selected; throw e; });
74
+ if (binding && canonical !== binding.dataDir)
75
+ throw Error('This installation is bound to another data folder. Use its configured folder; explicit adoption is required to change it.');
76
+ return selected;
77
+ }
78
+ if (binding) return binding.dataDir;
70
79
  const state = await readState(userDir);
71
80
  return state.dataDir || join(homedir(), ".local/share/agentpay/user");
72
81
  }
73
82
  // Uses the existing installer lock and preserves dependency/build checkpoints.
74
- export async function rememberUserDataDir({
83
+ export async function rememberUserDataDir(options) {
84
+ return withInstallationOperation(() => rememberUnderLock(options));
85
+ }
86
+ async function rememberUnderLock({
75
87
  userDir = installedUserDir,
76
88
  dataDir,
77
89
  }) {
@@ -84,21 +96,11 @@ export async function rememberUserDataDir({
84
96
  throw Error(
85
97
  "Installation record directory must be private and owned by you.",
86
98
  );
87
- const lockPath = join(directory, "install.lock");
88
- let lock;
89
- try {
90
- lock = await open(lockPath, "wx", 0o600);
91
- } catch (error) {
92
- if (error.code === "EEXIST")
93
- throw Error(
94
- "Another installation may be running. Retry after it finishes.",
95
- );
96
- throw error;
97
- }
98
99
  const temp = join(directory, `location-${randomUUID()}.tmp`);
99
100
  let file;
100
101
  try {
101
102
  const state = await readState(userDir);
103
+ await rememberInstallationBinding(userDir, dataDir);
102
104
  file = await open(temp, "wx", 0o600);
103
105
  await file.writeFile(
104
106
  JSON.stringify({ ...state, version: 1, dataDir: resolve(dataDir) }) +
@@ -111,7 +113,5 @@ export async function rememberUserDataDir({
111
113
  } finally {
112
114
  await file?.close();
113
115
  await unlink(temp).catch(() => {});
114
- await lock.close();
115
- await unlink(lockPath);
116
116
  }
117
117
  }
@@ -1,3 +1,5 @@
1
+ import {withInstallationOperation, installationChildOptions} from './installation-operation.mjs';
2
+ import {maintenanceServiceScope} from './service-selection.mjs';
1
3
  import { requireRunningSoftware } from "./software-versions.mjs";
2
4
  import { walletPackage } from "./wallet-package.mjs";
3
5
  // Apply installed packages. Preserve identity/history and startup preferences;
@@ -112,12 +114,12 @@ async function manage(action, { userDir, dataDir, logPath }) {
112
114
  [join(userDir, "scripts/autostart.mjs"), action],
113
115
  {
114
116
  shell: false,
115
- env: { ...process.env, AGENTPAY_USER_DATA_DIR: dataDir },
116
- stdio: ["ignore", file.fd, file.fd],
117
+ ...installationChildOptions({env: { ...process.env, AGENTPAY_USER_DATA_DIR: dataDir },
118
+ stdio: ["ignore", file.fd, file.fd]}),
117
119
  },
118
120
  );
119
121
  child.once("error", reject);
120
- child.once("exit", (code) =>
122
+ child.once("close", (code) =>
121
123
  code === 0
122
124
  ? done()
123
125
  : reject(Error("Background service operation failed.")),
@@ -127,7 +129,11 @@ async function manage(action, { userDir, dataDir, logPath }) {
127
129
  await file.close();
128
130
  }
129
131
  }
130
- export async function applyLocalUpdate({
132
+ export async function applyLocalUpdate(options = {}) {
133
+ try { return await withInstallationOperation(() => repairUnderLock(options)); }
134
+ catch(error) { (options.write || console.log)("Repair stopped. " + error.message); return 1; }
135
+ }
136
+ async function repairUnderLock({
131
137
  userDir = installedUserDir,
132
138
  dataDir,
133
139
  write = console.log,
@@ -143,13 +149,15 @@ export async function applyLocalUpdate({
143
149
  connection = ensureRuntimeConnection,
144
150
  prepare = runInstall,
145
151
  service = manage,
152
+ serviceScope = maintenanceServiceScope,
146
153
  wallet = localWalletAdmin,
147
154
  runtime = readRuntimeStatus,
148
155
  pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
149
156
  } = {}) {
150
157
  let phase = "preflight",
151
158
  logPath,
152
- selected;
159
+ selected,
160
+ activeServices = true;
153
161
  try {
154
162
  write(
155
163
  "\n------------------------------------------------------------\n> Repair User and Wallet\n------------------------------------------------------------",
@@ -165,6 +173,8 @@ export async function applyLocalUpdate({
165
173
  const pair = await pairCheck(userDir);
166
174
  await checkDirectory(dataDir);
167
175
  await settings(dataDir);
176
+ const scope = await serviceScope({userDir,dataDir});
177
+ activeServices = scope.active;
168
178
  write(
169
179
  `1/3 Check local files · User ${pair.userVersion} / Wallet ${pair.walletVersion}`,
170
180
  );
@@ -186,12 +196,18 @@ export async function applyLocalUpdate({
186
196
  applied.inputs === selected.inputs &&
187
197
  applied.outputs === selected.outputs
188
198
  ) {
199
+ if (!scope.active) {
200
+ await connection({dataDir,write,refreshClientModule:true});
201
+ phase = "unchanged";
202
+ write("This installation is up to date and remains inactive. Running services were not changed.");
203
+ return false;
204
+ }
189
205
  let runningMatches = false;
190
206
  try {
191
207
  requireRunningSoftware(await wallet(dataDir, "status"), await runtime(dataDir), { ...pair, protocolVersion: pair.typesDependency });
192
208
  runningMatches = true;
193
209
  } catch { /* Keep repair available for old, stopped or unreported processes. */ }
194
- if (runningMatches) {
210
+ if (runningMatches && scope.active) {
195
211
  await connection({ dataDir, write });
196
212
  phase = "unchanged";
197
213
  write("Local installation and running versions are up to date. Background services were not changed.");
@@ -205,7 +221,8 @@ export async function applyLocalUpdate({
205
221
  : "Installed program changes need to be applied.",
206
222
  );
207
223
  write(
208
- "Wallet and Runtime will pause, verify installed files and restart. Wallet will need unlocking afterwards.",
224
+ scope.active ? "Wallet and Runtime will pause, verify installed files and restart. Wallet will need unlocking afterwards."
225
+ : "This inactive installation will be repaired. The running Wallet and Runtime will not change.",
209
226
  );
210
227
  if (!force) {
211
228
  while (true) {
@@ -234,6 +251,11 @@ export async function applyLocalUpdate({
234
251
  inputs: selected.inputs,
235
252
  };
236
253
  await context.saveState();
254
+ if (!scope.active) {
255
+ phase = "building";
256
+ write("2/3 Check this installation's packages. Active services are unchanged.");
257
+ return;
258
+ }
237
259
  await service("prepare-repair", { userDir, dataDir, logPath });
238
260
  phase = "stopping";
239
261
  write(
@@ -248,6 +270,13 @@ export async function applyLocalUpdate({
248
270
  if (built.inputs !== selected.inputs)
249
271
  throw Error("Installed files changed during repair.");
250
272
  await connection({ dataDir, write, refreshClientModule: true });
273
+ if (!scope.active) {
274
+ context.state.localUpdate = {status:"complete", ...built};
275
+ await context.saveState();
276
+ phase = "ready";
277
+ write("3/3 Inactive installation checked. It has not been started.");
278
+ return;
279
+ }
251
280
  phase = "starting";
252
281
  write("3/3 Start Wallet and Runtime.");
253
282
  await service("start", { userDir, dataDir, logPath });
@@ -288,20 +317,22 @@ export async function applyLocalUpdate({
288
317
  if (!result?.completed || phase !== "ready")
289
318
  throw Error("Repair did not finish.");
290
319
  write(
291
- "\nRepair complete. Wallet and Runtime are running in the background.",
320
+ scope.active ? "\nRepair complete. Wallet and Runtime are running in the background."
321
+ : "\nRepair complete. This installation remains inactive; existing services were kept.",
292
322
  );
293
323
  write(
294
324
  "Keys, spending permission and Order records were kept. Login startup preference is unchanged.",
295
325
  );
296
326
  await officialTest({ userDir, dataDir, write });
297
- write("Next: agentpay wallet unlock\nThen: agentpay status");
327
+ write(scope.active ? "Next: agentpay wallet unlock\nThen: agentpay status" : "To switch to this installation: agentpay service start");
298
328
  return 0;
299
329
  } catch (error) {
300
330
  if (phase === "preflight")
301
331
  write("Repair stopped before pausing services. " + error.message);
302
332
  else if (phase === "building")
303
333
  write(
304
- "Repair could not finish. Wallet and Runtime remain stopped; your local data was kept.",
334
+ activeServices ? "Repair could not finish. Wallet and Runtime remain stopped; your local data was kept."
335
+ : "Repair could not finish. The active installation was not changed; your local data was kept.",
305
336
  );
306
337
  else
307
338
  write(
@@ -7,7 +7,7 @@ import { open } from "node:fs/promises";
7
7
  import { createConnection } from "node:net";
8
8
  import { join } from "node:path";
9
9
  import { randomBytes } from "node:crypto";
10
- export async function localWalletAdmin(dataDir, method, params = {}) {
10
+ export async function localWalletAdmin(dataDir, method, params = {}, {timeoutMs = 30000} = {}) {
11
11
  await checkServiceDirectory(dataDir);
12
12
  const h = await open(
13
13
  join(dataDir, "wallet/admin.token"),
@@ -40,7 +40,7 @@ export async function localWalletAdmin(dataDir, method, params = {}) {
40
40
  };
41
41
  const timer = setTimeout(
42
42
  () => finish(Error("Wallet did not respond. Run agentpay status.")),
43
- 30000,
43
+ timeoutMs,
44
44
  );
45
45
  socket.setEncoding("utf8");
46
46
  socket.once("error", (e) =>