@c4roa0/agentpay-user 0.3.6 → 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 (45) hide show
  1. package/README.md +3 -1
  2. package/doc/README.md +12 -0
  3. package/doc/TODO.md +48 -1
  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/records/npm-update-participant-2026-11-11.md +25 -0
  10. package/doc/reference/local-update.md +2 -1
  11. package/doc/runbooks/commands.md +28 -0
  12. package/doc/runbooks/local-update.md +11 -5
  13. package/package.json +1 -1
  14. package/progress.md +136 -6
  15. package/scripts/autostart.mjs +51 -25
  16. package/scripts/install-user.mjs +10 -3
  17. package/scripts/local-service.mjs +1 -1
  18. package/scripts/user-setup.mjs +4 -0
  19. package/src/cache-management-cli.mjs +32 -2
  20. package/src/default-request-owner.mjs +163 -0
  21. package/src/index.mjs +9 -0
  22. package/src/install-and-setup.mjs +6 -1
  23. package/src/install-selection.mjs +41 -0
  24. package/src/install-user.mjs +22 -22
  25. package/src/installation-binding.mjs +62 -0
  26. package/src/installation-operation.mjs +114 -0
  27. package/src/installation-paths.mjs +50 -1
  28. package/src/invocation-store.mjs +1 -1
  29. package/src/local-data-directory.mjs +17 -17
  30. package/src/local-update.mjs +41 -10
  31. package/src/local-wallet-ipc.mjs +2 -2
  32. package/src/npm-update.mjs +68 -20
  33. package/src/platform-endpoints.mjs +5 -8
  34. package/src/runtime-client.mjs +21 -4
  35. package/src/runtime-connection-settings.mjs +2 -1
  36. package/src/runtime-owner.mjs +22 -0
  37. package/src/runtime-selection-ready.mjs +29 -0
  38. package/src/service-readiness.mjs +29 -0
  39. package/src/service-selection.mjs +189 -0
  40. package/src/setup-autostart.mjs +12 -2
  41. package/src/setup-service-selection.mjs +25 -0
  42. package/src/skill-client.mjs +8 -3
  43. package/src/uninstall-local.mjs +69 -15
  44. package/src/user-cli.mjs +1 -2
  45. package/update-pair.json +1 -1
@@ -1,17 +1,19 @@
1
+ import {atomicWritePrivate, assertPrivateFile} from './private-journal-files.mjs';
2
+ import {withInstallationOperation, runInstallationCommand} from './installation-operation.mjs';
3
+ import {maintenanceServiceScope} from './service-selection.mjs';
1
4
  // SPDX-License-Identifier: MPL-2.0
2
5
  // Update real global or project npm installations. Source checkouts/npm links are never
3
6
  // overwritten. npm selects User; User's exact dependencies select Wallet/Protocol.
4
- import { execFile } from 'node:child_process';
5
- import { promisify } from 'node:util';
6
- import { access, appendFile, copyFile, chmod, lstat, mkdir, mkdtemp, open, readFile, realpath, rm, unlink } from 'node:fs/promises';
7
+ import { access, appendFile, copyFile, chmod, lstat, mkdir, mkdtemp, readFile, realpath, rm, unlink } from 'node:fs/promises';
7
8
  import { constants } from 'node:fs';
8
9
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
9
- import { randomUUID } from 'node:crypto';
10
+ import { randomUUID, createHash } from 'node:crypto';
11
+ import { createRequire } from 'node:module';
10
12
  import { createInterface } from 'node:readline/promises';
11
13
  import { installedUserDir, resolveUserDataDir, rememberUserDataDir } from './local-data-directory.mjs';
12
14
  import { installationDirectory } from './installation-paths.mjs';
13
15
  import { prepareOfficialTestUpdate } from './official-test-update.mjs';
14
- const execute = promisify(execFile);
16
+ const execute = runInstallationCommand;
15
17
  const name = '@c4roa0/agentpay-user';
16
18
  const stable = value => typeof value === 'string' && /^\d+\.\d+\.\d+$/.test(value);
17
19
  function newer(next, current) {
@@ -64,15 +66,20 @@ async function installationTarget(userDir, npm) {
64
66
  flags:['--global=false','--workspaces=false','--save-exact',save,'--include=dev','--include=optional']};
65
67
  }
66
68
 
67
- export async function updateFromNpm({
69
+ export async function updateFromNpm(options = {}) {
70
+ try { return await withInstallationOperation(() => updateUnderLock(options)); }
71
+ catch(error) { (options.write || console.log)("Update stopped. " + error.message); return 1; }
72
+ }
73
+ async function updateUnderLock({
68
74
  userDir = installedUserDir, dataDir, force = false, write = console.log,
69
75
  ask = askConfirmation,
70
76
  npm = (args, options) => command('npm', args, options),
71
77
  service = (action, options) => command(process.execPath, [join(userDir,'scripts/autostart.mjs'), action], options),
72
78
  repair = options => command(process.execPath, [join(userDir,'scripts/repair.mjs'), '-f'], options),
73
79
  officialTest = prepareOfficialTestUpdate,
80
+ serviceScope = maintenanceServiceScope,
74
81
  } = {}) {
75
- let lock, lockPath, logPath, installing = false;
82
+ let logPath, installing = false;
76
83
  try {
77
84
  userDir = resolve(userDir);
78
85
  const current = JSON.parse(await readFile(join(userDir, 'package.json'), 'utf8'));
@@ -80,20 +87,62 @@ export async function updateFromNpm({
80
87
  write('Checking for a User update…');
81
88
  const target = await installationTarget(userDir, npm);
82
89
  await access(dirname(target.directory), constants.W_OK);
83
- const metadata = JSON.parse(await npm(['view',name + '@latest','--json'], {cwd:target.prefix}));
90
+ const progressPath = join(installationDirectory(), 'update-' + createHash('sha256').update(userDir).digest('hex') + '.json');
91
+ let pending;
92
+ try {
93
+ await assertPrivateFile(progressPath);
94
+ if ((await lstat(progressPath)).size > 16384) throw Error('Invalid update recovery record.');
95
+ pending = JSON.parse(await readFile(progressPath,'utf8'));
96
+ if (pending.version !== 1 || pending.installation !== userDir || !stable(pending.targetVersion) ||
97
+ !stable(pending.walletVersion) || !stable(pending.protocolVersion) || !isAbsolute(pending.dataDir))
98
+ throw Error('Invalid update recovery record. Keep it for review.');
99
+ } catch(error) { if(error.code !== 'ENOENT') throw error; }
100
+ dataDir ??= await resolveUserDataDir({userDir});
101
+ if (pending && pending.dataDir !== dataDir) throw Error('The interrupted update belongs to another data folder. Keep its recovery record for review.');
102
+ const scope = await serviceScope({userDir,dataDir});
103
+ if (pending && current.version === pending.targetVersion) {
104
+ installing = true;
105
+ if (current.dependencies?.['@c4roa0/agentpay-wallet'] !== pending.walletVersion ||
106
+ current.dependencies?.['@c4roa0/agentpay-protocol'] !== pending.protocolVersion)
107
+ throw Error('The interrupted update has mismatched dependencies. Reinstall the confirmed User version, then retry.');
108
+ write('Continuing the interrupted update. Checking installed packages and service settings.');
109
+ const configured = await lstat(join(dataDir,'runtime/local-services.json')).then(s=>s.isFile(),e=>{if(e.code==='ENOENT')return false;throw e;});
110
+ if (configured) {
111
+ const output = await repair({env:{...process.env,AGENTPAY_USER_DATA_DIR:dataDir},cwd:target.prefix,timeout:600000});
112
+ if(output)write(output);
113
+ } else await officialTest({userDir,dataDir,write});
114
+ await unlink(progressPath);
115
+ write(`User ${current.version} update completed. No paid test was started.`);
116
+ return 0;
117
+ }
118
+ if(pending && newer(current.version,pending.targetVersion))
119
+ throw Error('Installed User is newer than the interrupted update. Keep the recovery record for review; no downgrade was attempted.');
120
+ const metadata = JSON.parse(await npm(['view',name + '@' + (pending?.targetVersion || 'latest'),'--json'], {cwd:target.prefix}));
84
121
  if (metadata.name !== name || !stable(metadata.version) ||
85
122
  !stable(metadata.dependencies?.['@c4roa0/agentpay-wallet']) ||
86
123
  !stable(metadata.dependencies?.['@c4roa0/agentpay-protocol']))
87
124
  throw Error('The published User release does not declare reviewed package versions.');
88
- dataDir ??= await resolveUserDataDir({userDir});
125
+ if (pending && (metadata.version !== pending.targetVersion || metadata.dependencies["@c4roa0/agentpay-wallet"] !== pending.walletVersion || metadata.dependencies["@c4roa0/agentpay-protocol"] !== pending.protocolVersion))
126
+ throw Error("The published package differs from the interrupted update. No program files were changed.");
89
127
  if (!newer(metadata.version,current.version)) {
90
128
  write(`User ${current.version} is current. Checking the official test Skill.`);
91
129
  await officialTest({userDir,dataDir,write});
92
130
  return 0;
93
131
  }
94
- write(`User ${current.version} → ${metadata.version} · Wallet ${metadata.dependencies['@c4roa0/agentpay-wallet']}`);
132
+ // Read the installed dependency, not the version requested by User's manifest.
133
+ // Missing/broken packages must not prevent npm from repairing the installation.
134
+ let walletVersion;
135
+ try {
136
+ const file = createRequire(join(userDir,'package.json')).resolve('@c4roa0/agentpay-wallet/package.json');
137
+ const wallet = JSON.parse(await readFile(file,'utf8'));
138
+ if(wallet.name === '@c4roa0/agentpay-wallet' && stable(wallet.version)) walletVersion = wallet.version;
139
+ } catch {}
140
+ const targetWallet = metadata.dependencies['@c4roa0/agentpay-wallet'];
141
+ write('Installed packages → update target');
142
+ write(`User ${current.version} → ${metadata.version}`);
143
+ write(`Wallet ${walletVersion === targetWallet ? `${walletVersion} (unchanged)` : `${walletVersion || 'not confirmed'} → ${targetWallet}`}`);
95
144
  write(`Installation folder: ${target.prefix}`);
96
- write('Background services will pause. Keys, permissions, Orders and cache are kept.');
145
+ write(scope.active ? 'Background services will pause. Keys, permissions, Orders and cache are kept.' : 'This installation is inactive. The running Wallet and Runtime will not change.');
97
146
  if (!force && (await ask("Type 'confirm' to update, or 'q' to cancel")).trim().toLowerCase() !== 'confirm') {
98
147
  write('Update cancelled. Nothing was changed.'); return 0;
99
148
  }
@@ -102,8 +151,6 @@ export async function updateFromNpm({
102
151
  const stateDir = installationDirectory();
103
152
  await mkdir(stateDir,{recursive:true,mode:0o700});
104
153
  logPath = join(stateDir, `update-${Date.now()}-${randomUUID()}.log`);
105
- lockPath = join(stateDir,'install.lock');
106
- lock = await open(lockPath,'wx',0o600);
107
154
  const rechecked = JSON.parse(await readFile(join(userDir,'package.json'),'utf8'));
108
155
  if (rechecked.version !== current.version || rechecked.name !== name)
109
156
  throw Error('Program files changed while reviewing the update. Run agentpay update again.');
@@ -132,8 +179,13 @@ export async function updateFromNpm({
132
179
  let configured = false;
133
180
  try { configured = (await lstat(join(dataDir,'runtime/local-services.json'))).isFile(); }
134
181
  catch(e) { if(e.code !== 'ENOENT') throw e; }
182
+ // Record the confirmed version before stopping or replacing anything. A retry
183
+ // must finish repair even when npm already installed the target version.
184
+ await atomicWritePrivate(progressPath, JSON.stringify({version:1,installation:userDir,dataDir,
185
+ targetVersion:metadata.version,walletVersion:metadata.dependencies['@c4roa0/agentpay-wallet'],
186
+ protocolVersion:metadata.dependencies['@c4roa0/agentpay-protocol']}) + '\n');
135
187
  // All imports used by this updater are loaded before npm replaces its files.
136
- if (configured) await service('stop',options);
188
+ if (configured && scope.active) await service('stop',options);
137
189
  installing = true;
138
190
  write('2/3 Install User and its matching Wallet. This may take a few minutes.');
139
191
  await npm(args,options);
@@ -142,11 +194,8 @@ export async function updateFromNpm({
142
194
  installed.dependencies?.['@c4roa0/agentpay-wallet'] !== metadata.dependencies['@c4roa0/agentpay-wallet'] ||
143
195
  installed.dependencies?.['@c4roa0/agentpay-protocol'] !== metadata.dependencies['@c4roa0/agentpay-protocol'])
144
196
  throw Error('The installed version does not match the confirmed update.');
145
- // Release before the new version's repair acquires the same lock. Another
146
- // operation may win that lock; repair then refuses safely and can be retried.
147
- await lock.close(); lock = null; await unlink(lockPath); lockPath = null;
148
197
  if (configured) {
149
- write('3/3 Restore services and verify the official test Skill.');
198
+ write(scope.active ? '3/3 Restore services and verify the official test Skill.' : '3/3 Verify the inactive installation and official test Skill.');
150
199
  // A fresh Node process loads the NEW repair/client/official digest, not old imports.
151
200
  const output = await repair(options);
152
201
  if(output) write(output);
@@ -154,6 +203,7 @@ export async function updateFromNpm({
154
203
  write('3/3 Program files are ready. Local setup has not been configured.');
155
204
  write('Next: agentpay install --market YOUR_MARKET_URL');
156
205
  }
206
+ await unlink(progressPath);
157
207
  write(`User ${metadata.version} installed. No paid test was started.`);
158
208
  return 0;
159
209
  } catch(error) {
@@ -162,7 +212,5 @@ export async function updateFromNpm({
162
212
  : 'Update stopped before replacing program files. ' + (error.code === 'EEXIST' ? 'Another installation is running.' : error.message));
163
213
  if(logPath) write('Log: ' + logPath);
164
214
  return 1;
165
- } finally {
166
- if(lock) { await lock.close(); await unlink(lockPath); }
167
215
  }
168
216
  }
@@ -1,3 +1,4 @@
1
+ import {withInstallationOperation,runInstallationCommand} from './installation-operation.mjs';
1
2
  // SPDX-License-Identifier: MPL-2.0
2
3
  // Deliberate transport changes only. Never replace a Policy, request, or result.
3
4
  import {open,mkdir,lstat,rename,unlink,readFile} from 'node:fs/promises';
@@ -5,8 +6,6 @@ import {constants} from 'node:fs';
5
6
  import {join,dirname} from 'node:path';
6
7
  import {homedir} from 'node:os';
7
8
  import {randomUUID} from 'node:crypto';
8
- import {execFile} from 'node:child_process';
9
- import {promisify} from 'node:util';
10
9
  import {installedUserDir} from './local-data-directory.mjs';
11
10
  import {deploymentFilePath,installationDirectory} from './installation-paths.mjs';
12
11
  import {readPrivateJson,deploymentChanges} from './deployment-status.mjs';
@@ -18,7 +17,7 @@ import {settlementProfileBlocker} from './readiness-profile.mjs';
18
17
  import {readRuntimeStatus} from './runtime-status.mjs';
19
18
  import {readSettlementAddresses} from './settlement-address-history.mjs';
20
19
 
21
- const execute=promisify(execFile);
20
+ const execute=runInstallationCommand;
22
21
  const normalize=value=>new URL(value).href.replace(/\/+$/,'');
23
22
  const same=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
24
23
  const pendingPath=dir=>join(dir,'runtime/endpoint-update.pending.json');
@@ -102,12 +101,11 @@ export async function preparePlatformEndpoints({dataDir,origin,scope,userDir=ins
102
101
  if(!metadata.response.ok||!metadata.result?.ok||!/^[a-f0-9]{64}$/.test(metadata.result.result?.sha256||''))
103
102
  throw Error('The new package address could not be checked. Current addresses are kept.');
104
103
  if(!changed)return {changed:false,apply:async()=>{}};
105
- return {changed:true,urls,async apply({write=console.log,manage=runtimeControl,stateDir=installationDirectory(),saveConnection=async()=>{}}={}){
104
+ return {changed:true,urls,async apply(options={}){return withInstallationOperation(()=>applyAddresses(options));}};
105
+ async function applyAddresses({write=console.log,manage=runtimeControl,stateDir=installationDirectory(),saveConnection=async()=>{}}={}){
106
106
  await mkdir(stateDir,{recursive:true,mode:0o700});
107
107
  const stat=await lstat(stateDir);
108
108
  if(!stat.isDirectory()||stat.isSymbolicLink()||stat.uid!==process.getuid()||(stat.mode&0o077))throw Error('Unsafe installation state directory.');
109
- const lockPath=join(stateDir,'install.lock');let lock;
110
- try{lock=await open(lockPath,'wx',0o600);}catch(e){if(e.code==='EEXIST')throw Error('Another setup or update is running. Retry market connect when it finishes.');throw e;}
111
109
  let updating=false;
112
110
  try{
113
111
  if(!same(await readPrivateJson(join(dataDir,'market-connection.json')),connectionBefore)||!same(await readPrivateJson(join(dataDir,'wallet/policy.json')),policy)||!same(await readServiceSettings(dataDir),settings)||!same(await readPrivateJson(publicPath),previousPublic)||!same(await readPrivateJson(await deploymentFilePath(dataDir,userDir,'anvil-onboarding.json')),setup))
@@ -133,6 +131,5 @@ export async function preparePlatformEndpoints({dataDir,origin,scope,userDir=ins
133
131
  await unlink(pendingPath(dataDir));
134
132
  write('Platform addresses updated. Your Wallet, permission and Order records were kept.');
135
133
  }catch(e){throw Error('Address update did not finish.\n'+e.message+'\n'+(updating?'Some connection settings may already be saved. Wallet keys, Policies and Orders were kept.':'No connection addresses were changed.')+'\nNext: agentpay market connect --market '+origin);}
136
- finally{await lock.close();await unlink(lockPath);}
137
- }};
134
+ }
138
135
  }
@@ -1,3 +1,5 @@
1
+ import { readFileSync, lstatSync } from "node:fs";
2
+ import { installationDirectory } from "./installation-paths.mjs";
1
3
  // Shared Skill transport and recovery. Resolve local credentials privately; endpoint
2
4
  // changes must not replace the original purchase identity or bypass retained checkpoints.
3
5
  // SPDX-License-Identifier: MPL-2.0
@@ -38,6 +40,17 @@ export class RuntimeClientError extends Error {
38
40
  }
39
41
  const fail = (code, message) => new RuntimeClientError(code, message);
40
42
  export function userDataDirectory(env = process.env) {
43
+ if (!env.AGENTPAY_USER_DATA_DIR) {
44
+ const path=join(installationDirectory(),'active-services.json');
45
+ try {
46
+ const st=lstatSync(path);
47
+ if (!st.isFile() || st.isSymbolicLink() || st.uid!==process.getuid() || st.mode & 0o077 || st.size>32768) throw Error('Unsafe default Runtime selection.');
48
+ const selection=JSON.parse(readFileSync(path,'utf8'));
49
+ if (selection.version!==1 || selection.phase!=='selected' || typeof selection.target?.dataDir!=='string' || !isAbsolute(selection.target.dataDir))
50
+ throw Error('The default Runtime is unavailable. Start the selected installation before using a Skill.');
51
+ return selection.target.dataDir;
52
+ } catch(e) {if(e.code!=='ENOENT')throw e;}
53
+ }
41
54
  return resolve(
42
55
  env.AGENTPAY_USER_DATA_DIR || join(homedir(), ".local/share/agentpay/user"),
43
56
  );
@@ -852,7 +865,7 @@ export async function selectInvocation(
852
865
  connection,
853
866
  service,
854
867
  input,
855
- { force = false, requestId } = {},
868
+ { force = false, requestId, knownRequestIds } = {},
856
869
  ) {
857
870
  if (force && requestId !== undefined)
858
871
  throw fail(
@@ -871,9 +884,13 @@ export async function selectInvocation(
871
884
  );
872
885
  const id =
873
886
  requestId || (!force ? state.currentId || state.pending[0] : undefined);
874
- const checkpoint = id
875
- ? await prepareInvocation(connection, service, input, id)
876
- : await prepareInvocation(connection, service, input);
887
+ if (!force && knownRequestIds?.length && (!id || !knownRequestIds.includes(id)))
888
+ throw fail("REQUEST_HISTORY_REVIEW_REQUIRED",
889
+ "The original request selection is missing or conflicting. Use --resume with the saved request ID in the original installation; do not start a replacement purchase.");
890
+ const retained = id && (state.currentId === id || state.pending.includes(id) || knownRequestIds?.includes(id));
891
+ const checkpoint = retained
892
+ ? await resumeInvocation(connection, service, id, input)
893
+ : await prepareInvocation(connection, service, input, id);
877
894
  if (!state.pending.includes(checkpoint.requestId)) {
878
895
  const cached = await cachedResult(connection, checkpoint);
879
896
  if (!cached) state.pending.push(checkpoint.requestId);
@@ -12,7 +12,7 @@ import { fileURLToPath } from "node:url";
12
12
  import { randomUUID } from "node:crypto";
13
13
  import { readServiceSettings } from "./local-services.mjs";
14
14
 
15
- export async function ensureRuntimeConnection({ dataDir, write = () => {}, refreshClientModule = false, checkOnly = false }) {
15
+ export async function ensureRuntimeConnection({ dataDir, write = () => {}, refreshClientModule = false, previousClientModule, checkOnly = false }) {
16
16
  const settings = await readServiceSettings(dataDir);
17
17
  const directory = join(dataDir, "runtime"),
18
18
  path = join(directory, "connection.json");
@@ -45,6 +45,7 @@ export async function ensureRuntimeConnection({ dataDir, write = () => {}, refre
45
45
  throw Error("conflict");
46
46
  if (saved.clientModule !== expected.clientModule) {
47
47
  if (!refreshClientModule || typeof saved.clientModule !== 'string' || !isAbsolute(saved.clientModule) || !saved.clientModule.endsWith('/src/runtime-client.mjs')) throw Error('conflict');
48
+ if (previousClientModule !== undefined && saved.clientModule !== previousClientModule) throw Error('conflict');
48
49
  // Explicit repair after stopping services can relocate trusted client code.
49
50
  // URL, credential, request identities and authorization remain unchanged.
50
51
  if (!checkOnly) {
@@ -0,0 +1,22 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ // A descriptor lock prevents different product entry points from running two
3
+ // Runtimes for the same OS user, even when their ports and data folders differ.
4
+ import { constants } from 'node:fs';
5
+ import { open, mkdir, lstat } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { ensureInstallationDirectory } from './installation-paths.mjs';
8
+ export async function acquireRuntimeOwner() {
9
+ const directory=await ensureInstallationDirectory();
10
+ await mkdir(directory,{recursive:true,mode:0o700});
11
+ const info=await lstat(directory);
12
+ if(!info.isDirectory() || info.isSymbolicLink() || info.uid!==process.getuid() || info.mode & 0o077)
13
+ throw Error('Runtime ownership directory must be private and owned by you.');
14
+ const handle=await open(join(directory,'runtime-owner.lock'),constants.O_CREAT|constants.O_RDWR|constants.O_NOFOLLOW,0o600);
15
+ try {
16
+ const info=await handle.stat();
17
+ if(!info.isFile() || info.nlink!==1 || info.uid!==process.getuid() || info.mode & 0o077) throw Error('Unsafe Runtime ownership lock.');
18
+ const {tryLock}=await import('fs-native-extensions');
19
+ if(!tryLock(handle.fd)) throw Error('Another AgentPay Runtime is running. Switch installations through agentpay service start; do not start a second Runtime.');
20
+ return handle;
21
+ } catch(e) { await handle.close(); throw e; }
22
+ }
@@ -0,0 +1,29 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ import {readFile,realpath} from 'node:fs/promises';
3
+ import {join} from 'node:path';
4
+ import {installationDirectory} from './installation-paths.mjs';
5
+ import {assertPrivateFile,atomicWritePrivate} from './private-journal-files.mjs';
6
+ import {fileURLToPath} from 'node:url';
7
+ import {waitForServiceReadiness} from './service-readiness.mjs';
8
+ import {withClientRequestLock} from './client-request-lock.mjs';
9
+ // Called after bind. Login startup may revive a stopped selection, but must never
10
+ // overwrite an unfinished switch or select a different data directory.
11
+ export async function publishRuntimeReady(dataDir, {verifyReady = waitForServiceReadiness} = {}) {
12
+ const state=installationDirectory(),path=join(state,'active-services.json');
13
+ const userDir=await realpath(fileURLToPath(new URL('..',import.meta.url)));
14
+ for (let attempt=0;attempt<60;attempt++) {
15
+ try {return await withClientRequestLock(join(state,'service-selection.lock'),async()=>{
16
+ let selected;
17
+ try {await assertPrivateFile(path);selected=JSON.parse(await readFile(path,'utf8'));}
18
+ catch(e){if(e.code==='ENOENT')return;throw e;}
19
+ if(selected.version!==1 || selected.target?.dataDir!==dataDir || selected.target?.userDir!==userDir || !['stopped','selected'].includes(selected.phase))return;
20
+ // Recheck after acquiring the transition lock; an earlier probe could become
21
+ // stale while a concurrent stop/switch runs. Login also verifies both services.
22
+ await verifyReady(dataDir);
23
+ await atomicWritePrivate(path,JSON.stringify({...selected,phase:'selected'})+'\n');
24
+ });} catch(error) {
25
+ if(error.code!=='REQUEST_BUSY' || attempt===59)throw error;
26
+ await new Promise(resolve=>setTimeout(resolve,300));
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,29 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ // Authenticated local status, not an open port or systemctl's start result,
3
+ // establishes readiness. A locked Wallet and unfinished Order checks are normal.
4
+ import {localWalletAdmin} from './local-wallet-ipc.mjs';
5
+ import {readRuntimeStatus} from './runtime-status.mjs';
6
+ import {requireRunningSoftware} from './software-versions.mjs';
7
+
8
+ export async function waitForServiceReadiness(dataDir, {
9
+ wallet = dir => localWalletAdmin(dir, 'status', {}, {timeoutMs:2500}),
10
+ runtime = readRuntimeStatus,
11
+ timeoutMs = 15000,
12
+ pause = ms => new Promise(resolve => setTimeout(resolve, ms)),
13
+ } = {}) {
14
+ const deadline = Date.now() + timeoutMs;
15
+ do {
16
+ try {
17
+ const [w,r] = await Promise.all([wallet(dataDir), runtime(dataDir)]);
18
+ if (typeof w?.locked !== 'boolean' || r?.role !== 'user-runtime')
19
+ throw Error('Invalid local service response.');
20
+ requireRunningSoftware(w,r);
21
+ return;
22
+ } catch (error) {
23
+ if (error.code === 'RUNNING_VERSION_UNVERIFIED') throw error;
24
+ if (Date.now() >= deadline) break;
25
+ await pause(300);
26
+ }
27
+ } while (Date.now() < deadline);
28
+ throw Error('Wallet and Runtime readiness could not be confirmed. Default Skill access stays paused. Run agentpay status, then retry agentpay service start.');
29
+ }
@@ -0,0 +1,189 @@
1
+ import {importHistoricalRequests} from './default-request-owner.mjs';
2
+ // SPDX-License-Identifier: MPL-2.0
3
+ // One managed service pair per OS user. A switch never changes Wallet identity.
4
+ import { lstat, readFile, realpath } from 'node:fs/promises';
5
+ import { join, resolve } from 'node:path';
6
+ import { homedir } from 'node:os';
7
+ import { autostartFiles, units } from './autostart-files.mjs';
8
+ import { installationDirectory } from './installation-paths.mjs';
9
+ import { withInstallationOperation } from './installation-operation.mjs';
10
+ import { readServiceSettings, listenerPresent } from './local-services.mjs';
11
+ import { atomicWritePrivate } from './private-journal-files.mjs';
12
+ import { ensureRuntimeConnection } from './runtime-connection-settings.mjs';
13
+ import { waitForServiceReadiness } from './service-readiness.mjs';
14
+
15
+ export async function installedServiceSelection(unitDir = join(homedir(), '.config/systemd/user'), recovery, {allowPartial = false} = {}) {
16
+ let identity;
17
+ let count = 0;
18
+ for (const name of units) {
19
+ const path = join(unitDir,name);
20
+ let info;
21
+ try { info = await lstat(path); } catch (e) { if (e.code === 'ENOENT') continue; throw e; }
22
+ if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid() || info.mode & 0o022)
23
+ throw Error('Existing service files need ownership review before switching.');
24
+ const body = await readFile(path,'utf8');
25
+ const line = body.split('\n').find(s => s.startsWith('ExecStart='));
26
+ const args = [...(line || '').matchAll(/"(?:\\.|[^"\\])*"/g)].map(([s]) => JSON.parse(s).replaceAll('%%','%').replaceAll('$$','$'));
27
+ const suffix = name.includes('reminder') ? '/scripts/login-notify.mjs' : '/scripts/local-service.mjs';
28
+ if (!args[1]?.endsWith(suffix) || !args[2]) throw Error('Unrecognized startup file. No service was changed.');
29
+ const found = {node:args[0], userDir:args[1].slice(0,-suffix.length), dataDir:args[2]};
30
+ const recoverable = recovery && [recovery.previous,recovery.target].filter(Boolean).some(value=>autostartFiles(value)[name]===body);
31
+ if ((recovery && !recoverable) || autostartFiles(found)[name] !== body || (!recoverable && identity && JSON.stringify(found) !== JSON.stringify(identity)))
32
+ throw Error('Startup files belong to conflicting configurations. No service was changed.');
33
+ identity = found; count++;
34
+ }
35
+ if (!recovery && !allowPartial && count && count !== units.length) throw Error('Startup files are incomplete. Repair the original installation before switching.');
36
+ return recovery ? (recovery.previous || recovery.target) : identity;
37
+ }
38
+
39
+ // Full, exact target units distinguish a failed service command from interrupted
40
+ // file replacement. Never infer completion from only one surviving unit.
41
+ async function targetUnitsPrepared(unitDir, target) {
42
+ try {
43
+ const found = await installedServiceSelection(unitDir);
44
+ return Boolean(found && ['node','userDir','dataDir'].every(key => found[key] === target?.[key]));
45
+ } catch { return false; }
46
+ }
47
+
48
+ // The caller holds this lock through unit replacement and start/stop. No prompts
49
+ // can grant approval to a different target while the current transition writes.
50
+ export async function withServiceSelection({target, action, ask, write = console.log, ctl, unitDir, operation, verifyReady = waitForServiceReadiness}) {
51
+ return withServiceLifecycleLock(async () => {
52
+ const canonical = path => realpath(path).catch(error=>{if(error.code==='ENOENT')return resolve(path);throw error;});
53
+ target = {...target,userDir:await canonical(target.userDir),dataDir:await canonical(target.dataDir)};
54
+ const state = installationDirectory();
55
+ const journalPath = join(state,'active-services.json');
56
+ let recovery, savedSelection;
57
+ try {
58
+ const st=await lstat(journalPath);
59
+ if (!st.isFile() || st.isSymbolicLink() || st.uid!==process.getuid() || st.mode & 0o077 || st.size>32768) throw Error('Unsafe service transition record.');
60
+ const saved=JSON.parse(await readFile(journalPath,'utf8'));
61
+ savedSelection=saved;
62
+ if (saved.version!==1) throw Error('Invalid service transition record.');
63
+ if (['stopping','preparing'].includes(saved.phase)) {
64
+ if (!saved.target || await canonical(saved.target.dataDir)!==target.dataDir || await canonical(saved.target.userDir)!==target.userDir) throw Error('A switch was interrupted. Retry from the originally selected installation before choosing another one.');
65
+ recovery=saved;
66
+ }
67
+ } catch(e) { if(e.code!=='ENOENT')throw e; }
68
+ if (recovery && await targetUnitsPrepared(unitDir,recovery.target)) {
69
+ // Also recover journals written before the explicit units-prepared checkpoint.
70
+ await atomicWritePrivate(journalPath,JSON.stringify({version:1,phase:'stopped',target:recovery.target,startupChoice:recovery.startupChoice})+'\n');
71
+ savedSelection={...recovery,phase:'stopped'};
72
+ recovery=undefined;
73
+ }
74
+ const previous = await installedServiceSelection(unitDir,recovery);
75
+ if (action === 'status') return operation({previous,switched:false});
76
+ if (recovery && !['start','restart','activate-setup'].includes(action))
77
+ throw Error('A service switch is unfinished. Retry agentpay service start before changing startup preferences.');
78
+ let startupChoice=recovery?.startupChoice || (savedSelection?.target?.dataDir===target.dataDir ? savedSelection.startupChoice : undefined);
79
+ const different = previous && (previous.dataDir !== target.dataDir || previous.userDir !== target.userDir);
80
+ if (different) {
81
+ if (!['start','restart','activate-setup'].includes(action))
82
+ throw Error('Another installation owns the background services. Run service start from this installation to switch explicitly.');
83
+ write('\nSwitch AgentPay installation');
84
+ write('Current data: ' + previous.dataDir);
85
+ write('Selected data: ' + target.dataDir);
86
+ write('Wallet and Runtime will stop. Keys, permissions and Order records stay in their own folders.');
87
+ if (!ask || String(await ask("Type 'confirm' to switch, or 'q' to cancel")).trim().toLowerCase() !== 'confirm') {
88
+ write('Switch cancelled. Existing services were kept.');
89
+ return {cancelled:true};
90
+ }
91
+ if (!startupChoice) {
92
+ const states=await Promise.all(units.slice(0,2).map(async name=>String((await ctl('show',name,'--property=UnitFileState','--value')).stdout || '').trim()));
93
+ if (states.every(s=>s==='enabled')) startupChoice='enabled';
94
+ else if (states.every(s=>s==='disabled')) startupChoice='disabled';
95
+ }
96
+ await atomicWritePrivate(join(state,'active-services.json'),JSON.stringify({version:1,phase:'stopping',previous,target,startupChoice})+'\n');
97
+ await ctl('stop',...units);
98
+ // is-active returning nonzero alone is not evidence of a successful stop:
99
+ // inspect explicit ActiveState values, including failed/inactive only.
100
+ for (const name of units) {
101
+ const result = await ctl('show',name,'--property=ActiveState','--value');
102
+ if (!['inactive','failed'].includes(String(result.stdout || '').trim()))
103
+ throw Error('Previous services have not stopped. The selected installation was not started.');
104
+ }
105
+ }
106
+ if (different) {
107
+ const settings = await readServiceSettings(previous.dataDir);
108
+ if (await listenerPresent(join(previous.dataDir,'wallet/wallet.sock')) ||
109
+ await listenerPresent({host:'127.0.0.1',port:Number(new URL(settings.runtimeUrl).port)}))
110
+ throw Error('An original Wallet or Runtime listener is still running. Stop it before switching; no new service was started.');
111
+ }
112
+ if (different) {
113
+ for (const dataDir of new Set([previous.dataDir,target.dataDir])) {
114
+ const history = await importHistoricalRequests(dataDir);
115
+ if (history?.unclassified)
116
+ write('Some older requests need review. Their original files are kept; automatic replacement purchases are blocked.');
117
+ }
118
+ }
119
+ const journal = join(state,'active-services.json');
120
+ const starts = ['start','restart'].includes(action);
121
+ const preservesRunning = !different && !recovery && !['stop','remove'].includes(action) &&
122
+ savedSelection?.phase === 'selected' && savedSelection.target?.dataDir === target.dataDir &&
123
+ savedSelection.target?.userDir === target.userDir;
124
+ // Preference-only operations must not revive a stopped default connection.
125
+ if (starts || different || recovery || ['stop','remove'].includes(action))
126
+ await atomicWritePrivate(journal,JSON.stringify({version:1,phase:'preparing',previous,target,startupChoice})+'\n');
127
+ // Reusing data across program installations moves only the client locator.
128
+ // Consent and stopped-listener checks above must precede this write. On retry
129
+ // the locator may already point here; unrelated paths still require review.
130
+ if (different && previous.dataDir === target.dataDir) {
131
+ await ensureRuntimeConnection({
132
+ dataDir:target.dataDir, write, refreshClientModule:true,
133
+ previousClientModule:join(previous.userDir,'src/runtime-client.mjs'),
134
+ });
135
+ }
136
+ // Exact old unit bodies may be replaced only inside this confirmed transition.
137
+ const unitsPrepared = async () => {
138
+ if (!await targetUnitsPrepared(unitDir,target))
139
+ throw Error('Service files are incomplete. Retry agentpay service start from this installation.');
140
+ // "stopped" closes the default route; it is not a claim about process state.
141
+ // Persist before invoking systemctl so a crash/timeout still permits repair.
142
+ await atomicWritePrivate(journal,JSON.stringify({version:1,phase:'stopped',target,startupChoice})+'\n');
143
+ };
144
+ const result = await operation({previous, switched:Boolean(different), unitsPrepared});
145
+ if (starts) {
146
+ write('Checking Wallet and Runtime readiness…');
147
+ try { await verifyReady(target.dataDir); }
148
+ catch (error) {
149
+ // Unit installation completed. Keep the default closed, but allow repair
150
+ // or a later start instead of stranding it in a unit-write transition.
151
+ await atomicWritePrivate(journal,JSON.stringify({version:1,phase:'stopped',target,startupChoice})+'\n');
152
+ throw error;
153
+ }
154
+ }
155
+ if(action==='enable')startupChoice='enabled';
156
+ if(action==='disable' || action==='remove')startupChoice='disabled';
157
+ const phase = starts || preservesRunning ? 'selected' : 'stopped';
158
+ await atomicWritePrivate(journal,JSON.stringify({version:1,phase,target,startupChoice})+'\n');
159
+ return result;
160
+ });
161
+ }
162
+
163
+ // Uninstall shares the service transition lock. Keep confirmation outside this
164
+ // section, then re-read the selected scope before making destructive changes.
165
+ export async function withServiceLifecycleLock(operation) {
166
+ return withInstallationOperation(operation);
167
+ }
168
+
169
+ // Called while holding the installation-operation lock. Maintenance of an inactive
170
+ // program tree must never claim the fixed service pair or rewrite active data.
171
+ export async function maintenanceServiceScope({userDir,dataDir,unitDir}) {
172
+ const canonical = async path => realpath(path).catch(error=>{if(error.code==='ENOENT')return resolve(path);throw error;});
173
+ userDir = await canonical(userDir); dataDir = await canonical(dataDir);
174
+ let saved;
175
+ try {
176
+ const path=join(installationDirectory(),'active-services.json');
177
+ const {assertPrivateFile}=await import('./private-journal-files.mjs');
178
+ await assertPrivateFile(path); saved=JSON.parse(await readFile(path,'utf8'));
179
+ if(saved.version!==1)throw Error('Unrecognized service selection. Keep it for review.');
180
+ if(['stopping','preparing'].includes(saved.phase) && !await targetUnitsPrepared(unitDir,saved.target))
181
+ throw Error('A service switch is unfinished. Retry service start from its selected installation before maintenance.');
182
+ } catch(error) {if(error.code!=='ENOENT')throw error;}
183
+ const owner=await installedServiceSelection(unitDir) || saved?.target;
184
+ if(!owner)return {active:true}; // Existing single-install setup/repair behavior.
185
+ if(await canonical(owner.userDir)===userDir && await canonical(owner.dataDir)===dataDir)return {active:true};
186
+ if(await canonical(owner.userDir)===userDir || await canonical(owner.dataDir)===dataDir)
187
+ throw Error('Another service selection shares these program or data files. Switch explicitly before repairing or updating this installation.');
188
+ return {active:false};
189
+ }
@@ -1,3 +1,5 @@
1
+ import { installationDirectory } from "./installation-paths.mjs";
2
+ import { assertPrivateFile } from "./private-journal-files.mjs";
1
3
  import { walletCliPath } from "./wallet-package.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.
@@ -14,7 +16,6 @@ import {
14
16
  } from "./local-services.mjs";
15
17
  const execute = promisify(execFile);
16
18
  const userDir = fileURLToPath(new URL("..", import.meta.url));
17
- const walletCli = walletCliPath();
18
19
  async function ctl(...args) {
19
20
  return execute("systemctl", ["--user", ...args], {
20
21
  timeout: 10000,
@@ -83,7 +84,7 @@ async function ownership(dataDir, settings) {
83
84
  } catch {}
84
85
  const expected =
85
86
  role === "wallet"
86
- ? [process.execPath, walletCli, "serve", join(dataDir, "wallet")]
87
+ ? [process.execPath, walletCliPath(), "serve", join(dataDir, "wallet")]
87
88
  : [process.execPath, join(userDir, "src/index.mjs")];
88
89
  if (
89
90
  !(await guideOwnsProcess({
@@ -143,6 +144,15 @@ export async function finishLocalSetup({
143
144
  settlementUrl: config.settlementUrl,
144
145
  });
145
146
  const settings = await readSettings(dataDir);
147
+ const selectionPath=join(installationDirectory(),'active-services.json');
148
+ try {
149
+ await assertPrivateFile(selectionPath);
150
+ const selection=JSON.parse(await readFile(selectionPath,'utf8'));
151
+ if (selection.target?.dataDir===dataDir && ['enabled','disabled'].includes(selection.startupChoice)) {
152
+ settings.startupChoice=selection.startupChoice;
153
+ await save({dataDir,...settings});
154
+ }
155
+ } catch(e) { if(e.code!=='ENOENT')throw e; }
146
156
  await available();
147
157
  if (!(await owns(dataDir, settings))) {
148
158
  write(
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ // Manual setup remains available when the OS has no user service manager.
3
+ import { execFile, spawn } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { installedServiceSelection } from './service-selection.mjs';
7
+ import {installationChildOptions} from './installation-operation.mjs';
8
+ export async function activateSetup({dataDir, platform=process.platform,
9
+ available=()=>promisify(execFile)('systemctl',['--user','show-environment'],{timeout:10000}),
10
+ inspect=installedServiceSelection, write=console.log,
11
+ launch=()=>new Promise((resolve,reject)=>{
12
+ const child=spawn(process.execPath,[fileURLToPath(new URL('../scripts/autostart.mjs',import.meta.url)),'activate-setup'],{
13
+ ...installationChildOptions({stdio:['inherit','inherit','inherit'],env:{...process.env,AGENTPAY_USER_DATA_DIR:dataDir}})
14
+ });
15
+ child.once('error',reject);child.once('close',code=>resolve(code ?? 1));
16
+ })}={}) {
17
+ let managed=platform==='linux';
18
+ if (managed) { try {await available();} catch {managed=false;} }
19
+ if (!managed) {
20
+ if (await inspect()) throw Error('Existing background service files were found, but the user service manager is unavailable. Restore it before switching installations.');
21
+ write('User service manager unavailable. Continuing with manual setup; login startup is not enabled.');
22
+ return 0;
23
+ }
24
+ return launch();
25
+ }