@c4roa0/agentpay-user 0.3.7 → 0.3.9

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 (46) hide show
  1. package/README.md +3 -1
  2. package/doc/README.md +10 -0
  3. package/doc/TODO.md +43 -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/doc/usage.md +8 -0
  13. package/package.json +1 -1
  14. package/progress.md +114 -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/default-request-owner.mjs +163 -0
  20. package/src/index.mjs +9 -0
  21. package/src/install-and-setup.mjs +6 -1
  22. package/src/install-selection.mjs +41 -0
  23. package/src/install-user.mjs +22 -22
  24. package/src/installation-binding.mjs +62 -0
  25. package/src/installation-operation.mjs +114 -0
  26. package/src/installation-paths.mjs +50 -1
  27. package/src/invocation-store.mjs +1 -1
  28. package/src/local-data-directory.mjs +17 -17
  29. package/src/local-update.mjs +41 -10
  30. package/src/local-wallet-ipc.mjs +2 -2
  31. package/src/npm-update.mjs +66 -23
  32. package/src/official-test-update.mjs +13 -0
  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-bundle.mjs +38 -0
  43. package/src/skill-client.mjs +8 -3
  44. package/src/uninstall-local.mjs +69 -15
  45. package/src/user-cli.mjs +1 -2
  46. package/update-pair.json +1 -1
@@ -1,18 +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';
10
11
  import { createRequire } from 'node:module';
11
12
  import { createInterface } from 'node:readline/promises';
12
13
  import { installedUserDir, resolveUserDataDir, rememberUserDataDir } from './local-data-directory.mjs';
13
14
  import { installationDirectory } from './installation-paths.mjs';
14
15
  import { prepareOfficialTestUpdate } from './official-test-update.mjs';
15
- const execute = promisify(execFile);
16
+ const execute = runInstallationCommand;
16
17
  const name = '@c4roa0/agentpay-user';
17
18
  const stable = value => typeof value === 'string' && /^\d+\.\d+\.\d+$/.test(value);
18
19
  function newer(next, current) {
@@ -20,12 +21,12 @@ function newer(next, current) {
20
21
  for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
21
22
  return false;
22
23
  }
23
- async function command(file, args, { logPath, timeout = 120000, env = process.env, cwd = process.cwd() } = {}) {
24
+ async function command(file, args, { logPath, timeout = 120000, env = process.env, cwd = process.cwd(), onStdout } = {}) {
24
25
  try {
25
- const result = await execute(file, args, { timeout, cwd, maxBuffer: 2 * 1024 * 1024,
26
+ const result = await execute(file, args, { timeout, cwd, onStdout, maxBuffer: 2 * 1024 * 1024,
26
27
  env: { ...env, PATH: dirname(process.execPath) + ':' + (env.PATH || '') } });
27
28
  if (logPath) await appendFile(logPath, result.stdout + result.stderr, {mode:0o600});
28
- return result.stdout.trim();
29
+ return onStdout ? '' : result.stdout.trim();
29
30
  } catch (error) {
30
31
  if (logPath) await appendFile(logPath, String(error.stdout || '') + String(error.stderr || ''), {mode:0o600});
31
32
  throw Error(logPath ? 'The command did not complete. Check the update log.' : 'Could not query npm. Check your network and npm configuration, then retry.');
@@ -65,15 +66,27 @@ async function installationTarget(userDir, npm) {
65
66
  flags:['--global=false','--workspaces=false','--save-exact',save,'--include=dev','--include=optional']};
66
67
  }
67
68
 
68
- 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({
69
74
  userDir = installedUserDir, dataDir, force = false, write = console.log,
70
75
  ask = askConfirmation,
71
76
  npm = (args, options) => command('npm', args, options),
72
77
  service = (action, options) => command(process.execPath, [join(userDir,'scripts/autostart.mjs'), action], options),
73
- repair = options => command(process.execPath, [join(userDir,'scripts/repair.mjs'), '-f'], options),
78
+ repair = options => {
79
+ let pending = '';
80
+ return command(process.execPath, [join(userDir,'scripts/repair.mjs'), '-f'], {...options, onStdout(chunk) {
81
+ pending += chunk;
82
+ const lines = pending.split('\n'); pending = lines.pop();
83
+ for (const line of lines) write(line);
84
+ }}).finally(() => { if (pending) write(pending); });
85
+ },
74
86
  officialTest = prepareOfficialTestUpdate,
87
+ serviceScope = maintenanceServiceScope,
75
88
  } = {}) {
76
- let lock, lockPath, logPath, installing = false;
89
+ let logPath, installing = false;
77
90
  try {
78
91
  userDir = resolve(userDir);
79
92
  const current = JSON.parse(await readFile(join(userDir, 'package.json'), 'utf8'));
@@ -81,12 +94,43 @@ export async function updateFromNpm({
81
94
  write('Checking for a User update…');
82
95
  const target = await installationTarget(userDir, npm);
83
96
  await access(dirname(target.directory), constants.W_OK);
84
- const metadata = JSON.parse(await npm(['view',name + '@latest','--json'], {cwd:target.prefix}));
97
+ const progressPath = join(installationDirectory(), 'update-' + createHash('sha256').update(userDir).digest('hex') + '.json');
98
+ let pending;
99
+ try {
100
+ await assertPrivateFile(progressPath);
101
+ if ((await lstat(progressPath)).size > 16384) throw Error('Invalid update recovery record.');
102
+ pending = JSON.parse(await readFile(progressPath,'utf8'));
103
+ if (pending.version !== 1 || pending.installation !== userDir || !stable(pending.targetVersion) ||
104
+ !stable(pending.walletVersion) || !stable(pending.protocolVersion) || !isAbsolute(pending.dataDir))
105
+ throw Error('Invalid update recovery record. Keep it for review.');
106
+ } catch(error) { if(error.code !== 'ENOENT') throw error; }
107
+ dataDir ??= await resolveUserDataDir({userDir});
108
+ if (pending && pending.dataDir !== dataDir) throw Error('The interrupted update belongs to another data folder. Keep its recovery record for review.');
109
+ const scope = await serviceScope({userDir,dataDir});
110
+ if (pending && current.version === pending.targetVersion) {
111
+ installing = true;
112
+ if (current.dependencies?.['@c4roa0/agentpay-wallet'] !== pending.walletVersion ||
113
+ current.dependencies?.['@c4roa0/agentpay-protocol'] !== pending.protocolVersion)
114
+ throw Error('The interrupted update has mismatched dependencies. Reinstall the confirmed User version, then retry.');
115
+ write('Continuing the interrupted update. Checking installed packages and service settings.');
116
+ const configured = await lstat(join(dataDir,'runtime/local-services.json')).then(s=>s.isFile(),e=>{if(e.code==='ENOENT')return false;throw e;});
117
+ if (configured) {
118
+ const output = await repair({env:{...process.env,AGENTPAY_USER_DATA_DIR:dataDir},cwd:target.prefix,timeout:600000});
119
+ if(output)write(output);
120
+ } else await officialTest({userDir,dataDir,write});
121
+ await unlink(progressPath);
122
+ write(`User ${current.version} update completed. No paid test was started.`);
123
+ return 0;
124
+ }
125
+ if(pending && newer(current.version,pending.targetVersion))
126
+ throw Error('Installed User is newer than the interrupted update. Keep the recovery record for review; no downgrade was attempted.');
127
+ const metadata = JSON.parse(await npm(['view',name + '@' + (pending?.targetVersion || 'latest'),'--json'], {cwd:target.prefix}));
85
128
  if (metadata.name !== name || !stable(metadata.version) ||
86
129
  !stable(metadata.dependencies?.['@c4roa0/agentpay-wallet']) ||
87
130
  !stable(metadata.dependencies?.['@c4roa0/agentpay-protocol']))
88
131
  throw Error('The published User release does not declare reviewed package versions.');
89
- dataDir ??= await resolveUserDataDir({userDir});
132
+ if (pending && (metadata.version !== pending.targetVersion || metadata.dependencies["@c4roa0/agentpay-wallet"] !== pending.walletVersion || metadata.dependencies["@c4roa0/agentpay-protocol"] !== pending.protocolVersion))
133
+ throw Error("The published package differs from the interrupted update. No program files were changed.");
90
134
  if (!newer(metadata.version,current.version)) {
91
135
  write(`User ${current.version} is current. Checking the official test Skill.`);
92
136
  await officialTest({userDir,dataDir,write});
@@ -105,7 +149,7 @@ export async function updateFromNpm({
105
149
  write(`User ${current.version} → ${metadata.version}`);
106
150
  write(`Wallet ${walletVersion === targetWallet ? `${walletVersion} (unchanged)` : `${walletVersion || 'not confirmed'} → ${targetWallet}`}`);
107
151
  write(`Installation folder: ${target.prefix}`);
108
- write('Background services will pause. Keys, permissions, Orders and cache are kept.');
152
+ 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.');
109
153
  if (!force && (await ask("Type 'confirm' to update, or 'q' to cancel")).trim().toLowerCase() !== 'confirm') {
110
154
  write('Update cancelled. Nothing was changed.'); return 0;
111
155
  }
@@ -114,8 +158,6 @@ export async function updateFromNpm({
114
158
  const stateDir = installationDirectory();
115
159
  await mkdir(stateDir,{recursive:true,mode:0o700});
116
160
  logPath = join(stateDir, `update-${Date.now()}-${randomUUID()}.log`);
117
- lockPath = join(stateDir,'install.lock');
118
- lock = await open(lockPath,'wx',0o600);
119
161
  const rechecked = JSON.parse(await readFile(join(userDir,'package.json'),'utf8'));
120
162
  if (rechecked.version !== current.version || rechecked.name !== name)
121
163
  throw Error('Program files changed while reviewing the update. Run agentpay update again.');
@@ -144,8 +186,13 @@ export async function updateFromNpm({
144
186
  let configured = false;
145
187
  try { configured = (await lstat(join(dataDir,'runtime/local-services.json'))).isFile(); }
146
188
  catch(e) { if(e.code !== 'ENOENT') throw e; }
189
+ // Record the confirmed version before stopping or replacing anything. A retry
190
+ // must finish repair even when npm already installed the target version.
191
+ await atomicWritePrivate(progressPath, JSON.stringify({version:1,installation:userDir,dataDir,
192
+ targetVersion:metadata.version,walletVersion:metadata.dependencies['@c4roa0/agentpay-wallet'],
193
+ protocolVersion:metadata.dependencies['@c4roa0/agentpay-protocol']}) + '\n');
147
194
  // All imports used by this updater are loaded before npm replaces its files.
148
- if (configured) await service('stop',options);
195
+ if (configured && scope.active) await service('stop',options);
149
196
  installing = true;
150
197
  write('2/3 Install User and its matching Wallet. This may take a few minutes.');
151
198
  await npm(args,options);
@@ -154,11 +201,8 @@ export async function updateFromNpm({
154
201
  installed.dependencies?.['@c4roa0/agentpay-wallet'] !== metadata.dependencies['@c4roa0/agentpay-wallet'] ||
155
202
  installed.dependencies?.['@c4roa0/agentpay-protocol'] !== metadata.dependencies['@c4roa0/agentpay-protocol'])
156
203
  throw Error('The installed version does not match the confirmed update.');
157
- // Release before the new version's repair acquires the same lock. Another
158
- // operation may win that lock; repair then refuses safely and can be retried.
159
- await lock.close(); lock = null; await unlink(lockPath); lockPath = null;
160
204
  if (configured) {
161
- write('3/3 Restore services and verify the official test Skill.');
205
+ write(scope.active ? '3/3 Restore services and verify the official test Skill.' : '3/3 Verify the inactive installation and official test Skill.');
162
206
  // A fresh Node process loads the NEW repair/client/official digest, not old imports.
163
207
  const output = await repair(options);
164
208
  if(output) write(output);
@@ -166,6 +210,7 @@ export async function updateFromNpm({
166
210
  write('3/3 Program files are ready. Local setup has not been configured.');
167
211
  write('Next: agentpay install --market YOUR_MARKET_URL');
168
212
  }
213
+ await unlink(progressPath);
169
214
  write(`User ${metadata.version} installed. No paid test was started.`);
170
215
  return 0;
171
216
  } catch(error) {
@@ -174,7 +219,5 @@ export async function updateFromNpm({
174
219
  : 'Update stopped before replacing program files. ' + (error.code === 'EEXIST' ? 'Another installation is running.' : error.message));
175
220
  if(logPath) write('Log: ' + logPath);
176
221
  return 1;
177
- } finally {
178
- if(lock) { await lock.close(); await unlink(lockPath); }
179
222
  }
180
223
  }
@@ -70,6 +70,19 @@ export async function prepareOfficialTestUpdate({
70
70
  } = {}) {
71
71
  write("\n> Official test Skill");
72
72
  try {
73
+ // Only the release-pinned archive can establish the expected installed files.
74
+ // Never trust installation.json or a Market-provided digest for this shortcut.
75
+ const { inspectSavedSkillBundle } = await import("./skill-bundle.mjs");
76
+ const local = await inspectSavedSkillBundle(dataDir, OFFICIAL_TEST_PACKAGE);
77
+ if (local.status === "ready") {
78
+ write(`Version ${OFFICIAL_TEST_PACKAGE.version} verified locally. No download needed.`);
79
+ return local;
80
+ }
81
+ if (local.status === "local_edits") {
82
+ write("Local test Skill files are changed or incomplete. Existing files were kept; review them before testing.");
83
+ return local;
84
+ }
85
+ write("Downloading the official test Skill…");
73
86
  let source;
74
87
  for (const name of ["anvil-onboarding.json", "user-deployment.json"]) {
75
88
  const saved = await readJson(await deploymentFilePath(dataDir, userDir, name));
@@ -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(