@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
package/progress.md CHANGED
@@ -1,12 +1,99 @@
1
1
  # AgentPay User progress
2
2
 
3
- ## User 0.3.7 release preparation — 2026-11-11
3
+ ## Real two-installation switch passed — 2026-11-12
4
+
5
+ Actual user-systemd switching A → B → A passed using source and folder-local npm
6
+ candidate installations with separate data. Cancellation preserved A's process;
7
+ repeated B start preserved its process. B authenticated status passed. Original
8
+ A key/Policy hashes were unchanged, and its existing Orders/Market connection
9
+ remained visible. A is restored with both services running and startup enabled;
10
+ Wallet is locked. Source dependencies now match Wallet 0.3.3 / Protocol 0.14.3;
11
+ old dependencies and inactive B are retained privately. No payment or authorization.
12
+ The initial harness rejected an expected nonzero status for B's unconfigured Wallet;
13
+ corrected full execution passed. This does not establish fresh guided install,
14
+ desktop login/reboot or funded recovery. [Evidence](doc/records/dual-install-systemd-2026-11-12.md).
15
+
16
+ ## Service command recovery — 2026-11-12
17
+
18
+ Service-manager failure recovery is now stage-aware: complete exact target unit
19
+ files are checkpointed before reload/start/stop, so command failure permits retry
20
+ or repair. Older incomplete journals recover only after all target units match;
21
+ partial/conflicting units remain blocked. Sixteen selection checks passed, including
22
+ actual autostart subprocesses with a controlled systemctl executable. No real
23
+ systemd service or transaction was changed (2026-11-12).
24
+
25
+ ## Sequential review fixes — 2026-11-11
26
+
27
+ Shared-data installation switching now relocates the client module only after
28
+ switch confirmation and stopped-service/listener checks. It accepts the previous
29
+ managed installation path or the already-relocated current path; unrelated paths
30
+ remain blocked. Retrying `service start` completes interrupted preparation without
31
+ requiring repair first. Keys, authorization and request records are unchanged.
32
+ Twelve selection tests and both connection/preparation test files passed with
33
+ real temporary storage/native locks and simulated service management. Real systemd
34
+ acceptance is still pending.
35
+
36
+ Default availability now requires authenticated Wallet/Runtime readiness and
37
+ matching running versions; locked Wallets are accepted. Startup preferences and
38
+ preparation preserve existing availability, removal closes it, and readiness failure
39
+ leaves maintenance retryable. Login startup verifies both services under the selection
40
+ lock and checks the selected program/data identity. Transport fixtures and lifecycle
41
+ regressions passed; actual systemd acceptance remains pending.
42
+
43
+ Installation now stages downloaded settings in memory, confirms activation under
44
+ the shared operation lock, then writes settings and binds the installation. Cancel
45
+ preserves the previous deployment. A private per-user state locator preserves the
46
+ initial XDG location across shells, background services and candidate Skill loaders;
47
+ changing XDG does not create another ownership/maintenance lock namespace.
48
+ Isolated installation, transport, process-lock and update/uninstall regressions passed.
49
+ Next: overall review and real two-install systemd acceptance before release.
50
+ systemd is a Linux backend, not the definition of installation identity.
51
+ macOS background startup remains deferred until a test machine is available.
52
+
53
+ ## Multiple installations, one active Runtime — 2026-11-11
54
+
55
+ Working-tree implementation now includes canonical installation/data bindings,
56
+ reuse/new/cancel selection, confirmed service switching, single Runtime lifetime
57
+ ownership, default Skill routing and protection for newly recorded request scopes.
58
+ Agent template 0.1.2 / example Skill 1.2.2 remain unpublished candidates; User still
59
+ pins official Skill 1.2.1. Historical request ownership is now indexed during
60
+ confirmed switches; original files and authorization remain unchanged.
61
+ [Design and remaining acceptance](doc/plans/local-instances-and-install-recovery.md).
62
+
63
+ Installation, update, repair, uninstall, service switching and address maintenance
64
+ now share one operation lock. Inactive update/repair preserves the selected services;
65
+ shared active program/data paths require explicit switching. Short-lived writers and
66
+ administration children inherit the lock, while daemons do not. Recognizable v2 locks
67
+ recover after writers exit; old unknown locks remain blocked for review. Interrupted
68
+ updates retain the confirmed target and finish repair even when npm already installed
69
+ that version. Uninstall rechecks the confirmed scope and supports partial-unit retry.
70
+
71
+ 77 combined targeted checks passed, followed by the marker-publication recovery
72
+ check and its process-lock regressions. These cover process interruption/inheritance, temporary
73
+ storage and controlled npm/service interfaces. A separate offline npm install verified
74
+ Wallet 0.3.3, Protocol 0.14.3 and native-lock loading with hooks disabled. The working
75
+ checkout's older dependencies were not replaced. [Evidence and limits](doc/records/installation-maintenance-2026-11-11.md).
76
+
77
+ Historical routing now preserves known IDs and refuses missing/conflicting original
78
+ selection. Unclassified older history does not prevent startup, but blocks automatic
79
+ replacement calls from another installation; explicit force remains a new purchase.
80
+ 35 focused checks passed with controlled HTTP/service fixtures.
81
+
82
+ Remaining: complete two-install public-entry and
83
+ real systemd switch/login acceptance; release coordination and participant rehearsal.
84
+ Unknown occupied ports still conservatively block uninstall. No live service, key,
85
+ Policy, Order, payment, migration, commit or publication changed in this loop.
86
+
87
+ ## User 0.3.7 published — 2026-11-11
4
88
 
5
89
  Patch release includes installed/target Wallet version preview and clearer Cache ID
6
90
  help with unique short-ID lookup for read-only details. Wallet remains pinned to
7
91
  0.3.3 and Protocol to 0.14.3. Manifest, lockfile and update-pair versions are aligned.
8
- Publication and registry verification are pending; no local participant service or
9
- private data is changed by preparing the release.
92
+ 393 behavior tests passed from an isolated lockfile installation; standalone npm
93
+ archive installation and process lifecycle passed. Tag v0.3.7 points to source
94
+ 3d31297dd3baedbd798f51e86c5fa8a1e474bb4a. GitHub run 42976422954 succeeded;
95
+ public registry SHA-512 matches the tested archive and latest is 0.3.7. No local
96
+ participant service or private data was changed by publication.
10
97
 
11
98
 
12
99
  ## Existing client update accepted — 2026-11-11
@@ -17,7 +104,7 @@ startup/Market connection and retained available result cache were observed.
17
104
  Participant confirmed both original Orders remain visible on Market. The older
18
105
  missing result predates this update. No new payment or Policy was created.
19
106
  [Evidence and limits](doc/records/npm-update-participant-2026-11-11.md). Update-preview and Cache ID guidance fixes are
20
- verified in source, not yet published. Fresh installation and interrupted-update
107
+ published in 0.3.7. Fresh installation and interrupted-update
21
108
  acceptance remain next for discussion; neither has started in this loop.
22
109
 
23
110
 
@@ -29,7 +116,7 @@ accept a unique prefix with at least eight hexadecimal characters; ambiguous,
29
116
  missing or incomplete bounded lookups require a full ID. Export and cleanup keep
30
117
  their existing exact-identity rules. All 19 targeted cache/CLI regressions passed
31
118
  with isolated process tests outside the restricted sandbox; no live cache changed.
32
- Source only, pending publication. There is no input: ID namespace: inputs use the
119
+ Included in published 0.3.7. There is no input: ID namespace: inputs use the
33
120
  attachment ID shown by list, while results can use result: or runtime-result:0x.
34
121
 
35
122
 
@@ -40,7 +127,7 @@ from update targets. Wallet is read from the installed dependency, not inferred
40
127
  from User's requested dependency. Unchanged versions are explicit; unreadable or
41
128
  missing Wallet metadata shows “not confirmed” without blocking the normal repair
42
129
  through npm. Controlled update tests cover all three cases and cancellation without
43
- service/install mutation. This source change is not yet published; running-process
130
+ service/install mutation. Published in 0.3.7; running-process
44
131
  versions remain the responsibility of status after update/restart.
45
132
 
46
133
 
@@ -2,8 +2,10 @@
2
2
  // SPDX-License-Identifier: MPL-2.0
3
3
  // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
4
4
  // If a copy was not distributed with this file, see https://mozilla.org/MPL/2.0/.
5
+ import { withServiceSelection } from "../src/service-selection.mjs";
6
+ import { createInterface } from "node:readline/promises";
5
7
  import { randomUUID } from "node:crypto";
6
- import { execFileSync } from "node:child_process";
8
+ import {runInstallationCommand,withInstallationOperation} from "../src/installation-operation.mjs";
7
9
  import { lstat, mkdir, readFile, writeFile, unlink, rename } from "node:fs/promises";
8
10
  import { join } from "node:path";
9
11
  import { homedir } from "node:os";
@@ -22,15 +24,17 @@ import {
22
24
  saveServiceSettings,
23
25
  } from "../src/local-services.mjs";
24
26
  import { ensureLocalServices } from "../src/prepare-local-services.mjs";
25
- const ctl = (...args) =>
26
- execFileSync("systemctl", ["--user", ...args], {
27
- stdio: "inherit",
28
- timeout: 30000,
29
- });
27
+ const ctl = async (...args) => {
28
+ const result = await runInstallationCommand("systemctl", ["--user", ...args], {timeout:30000});
29
+ if (result.stdout) process.stdout.write(result.stdout);
30
+ if (result.stderr) process.stderr.write(result.stderr);
31
+ return result;
32
+ };
30
33
  try {
31
34
  const [action, ...args] = process.argv.slice(2);
32
35
  if (
33
36
  ![
37
+ "activate-setup",
34
38
  "prepare",
35
39
  "prepare-repair",
36
40
  "enable",
@@ -53,13 +57,21 @@ try {
53
57
  );
54
58
  const dataDir = await resolveUserDataDir();
55
59
  if (action === "configure") {
56
- await saveServiceSettings({
60
+ await withInstallationOperation(() => saveServiceSettings({
57
61
  dataDir,
58
62
  runtimeUrl: args[0],
59
63
  settlementUrl: args[1],
60
- });
64
+ }));
61
65
  console.log("Local startup settings saved. No service was started.");
62
66
  } else {
67
+ const selectionResult = await withServiceSelection({
68
+ target:{node:process.execPath,userDir:installedUserDir,dataDir}, action,
69
+ ctl:(...args) => runInstallationCommand('systemctl',['--user',...args],{timeout:30000}),
70
+ ask:process.stdin.isTTY ? async message => {
71
+ const terminal = createInterface({input:process.stdin,output:process.stdout});
72
+ try { return await terminal.question(message + '\n> '); } finally { terminal.close(); }
73
+ } : undefined,
74
+ operation:async ({previous,switched,unitsPrepared}) => {
63
75
  const files = autostartFiles({
64
76
  node: process.execPath,
65
77
  userDir: installedUserDir,
@@ -68,7 +80,7 @@ try {
68
80
  const config = join(homedir(), ".config"),
69
81
  unitDir = join(config, "systemd/user");
70
82
  for (const directory of [config, join(config, "systemd"), unitDir]) {
71
- if (["enable", "prepare", "prepare-repair"].includes(action))
83
+ if (["enable", "prepare", "prepare-repair", "start", "restart", "activate-setup"].includes(action))
72
84
  await mkdir(directory, { mode: 0o700 }).catch((e) => {
73
85
  if (e.code !== "EEXIST") throw e;
74
86
  });
@@ -96,7 +108,7 @@ try {
96
108
  info.isSymbolicLink() ||
97
109
  info.uid !== process.getuid() ||
98
110
  info.mode & 0o022 ||
99
- !isManagedUnit(await readFile(path, "utf8"), name, dataDir)
111
+ !(isManagedUnit(await readFile(path, "utf8"), name, dataDir) || (switched && previous && autostartFiles(previous)[name] === await readFile(path,"utf8")))
100
112
  )
101
113
  throw Error(
102
114
  "An existing startup file belongs to another configuration. Preserve it and review before switching.",
@@ -106,21 +118,27 @@ try {
106
118
  }
107
119
  }
108
120
  if (action === "status") {
109
- ctl("status", "--no-pager", ...runtimeUnits);
121
+ await ctl("status", "--no-pager", ...runtimeUnits);
110
122
  console.log(
111
123
  "The login reminder runs once per graphical session; it can be inactive outside a desktop login.",
112
124
  );
113
125
  } else if (["start", "restart", "stop"].includes(action)) {
114
- if (action !== "stop") await readServiceSettings(dataDir);
115
- for (const { path } of paths) await lstat(path);
116
- ctl(action, ...(action !== "stop" ? runtimeUnits : units));
117
- console.log(
118
- action !== "stop"
119
- ? `${action === "restart" ? "Restart" : "Start"} requested through systemd. This does not confirm readiness.\nNext: agentpay status`
120
- : "Stop requested through systemd. Login startup and saved Wallet data are unchanged.\nNext: agentpay status",
126
+ if (action !== 'stop') {
127
+ await ensureLocalServices({dataDir});
128
+ for (const {path,body} of paths) {
129
+ const temporary = path + '.' + randomUUID();
130
+ try { await writeFile(temporary,body,{flag:'wx',mode:0o600}); await rename(temporary,path); }
131
+ finally { await unlink(temporary).catch(e=>{if(e.code!=='ENOENT')throw e;}); }
132
+ }
133
+ }
134
+ await unitsPrepared();
135
+ if (action !== 'stop') await ctl('daemon-reload');
136
+ await ctl(action, ...(action !== "stop" ? runtimeUnits : units));
137
+ if (action === 'stop') console.log(
138
+ 'Stop requested through systemd. Login startup and saved Wallet data are unchanged.\nNext: agentpay status',
121
139
  );
122
- } else if (["enable", "prepare", "prepare-repair"].includes(action)) {
123
- await ensureLocalServices({ dataDir, connectionOptions: action === "prepare-repair" ? { refreshClientModule: true, checkOnly: true } : {} });
140
+ } else if (["enable", "prepare", "prepare-repair", "activate-setup"].includes(action)) {
141
+ if (action !== "activate-setup") await ensureLocalServices({ dataDir, connectionOptions: action === "prepare-repair" ? { refreshClientModule: true, checkOnly: true } : {} });
124
142
  // No --now: avoid replacing a Wallet currently owned by terminal setup/system services.
125
143
  for (const { path, body } of paths)
126
144
  try {
@@ -133,9 +151,9 @@ try {
133
151
  await rename(temporary,path);
134
152
  } finally { await unlink(temporary).catch(e => { if(e.code !== 'ENOENT') throw e; }); }
135
153
  }
136
- ctl("daemon-reload");
154
+ await ctl("daemon-reload");
137
155
  if (action === "enable") {
138
- ctl("enable", ...units);
156
+ await ctl("enable", ...units);
139
157
  await saveServiceSettings({
140
158
  dataDir,
141
159
  ...(await readServiceSettings(dataDir)),
@@ -150,7 +168,7 @@ try {
150
168
  );
151
169
  } else if (action === "disable") {
152
170
  await readServiceSettings(dataDir);
153
- ctl("disable", ...units);
171
+ await ctl("disable", ...units);
154
172
  await saveServiceSettings({
155
173
  dataDir,
156
174
  ...(await readServiceSettings(dataDir)),
@@ -165,16 +183,21 @@ try {
165
183
  ...(await readServiceSettings(dataDir)),
166
184
  startupChoice: "disabled",
167
185
  });
168
- ctl("disable", "--now", ...units);
186
+ await ctl("disable", "--now", ...units);
169
187
  for (const { path } of paths)
170
188
  await unlink(path).catch((e) => {
171
189
  if (e.code !== "ENOENT") throw e;
172
190
  });
173
- ctl("daemon-reload");
191
+ await ctl("daemon-reload");
174
192
  console.log(
175
193
  "Automatic startup and its reminder removed. Wallet data, Policy and request history are kept.",
176
194
  );
177
195
  }
196
+ }});
197
+ if (selectionResult?.cancelled) process.exitCode = 2;
198
+ else if (['start','restart'].includes(action)) console.log(
199
+ 'Wallet and Runtime are ready. Default Skill access is available.\nNext: agentpay status',
200
+ );
178
201
  }
179
202
  } catch (error) {
180
203
  console.error(
@@ -182,5 +205,8 @@ try {
182
205
  ? error.message
183
206
  : "The systemd command failed; some service changes may already have applied. Wallet keys and request history were kept. Run agentpay status before retrying.",
184
207
  );
208
+ if (process.argv[2] === 'stop') console.error('Retry: agentpay service stop');
209
+ else if (['start','restart'].includes(process.argv[2]))
210
+ console.error('Check: agentpay status\nRetry: agentpay service start');
185
211
  process.exitCode = 1;
186
212
  }
@@ -3,7 +3,8 @@
3
3
  // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
4
4
  // If a copy was not distributed with this file, see https://mozilla.org/MPL/2.0/.
5
5
  import { fileURLToPath } from "node:url";
6
- import { resolveUserDataDir } from "../src/local-data-directory.mjs";
6
+ import { selectInstallationData } from "../src/install-selection.mjs";
7
+ import { createInterface } from "node:readline/promises";
7
8
  import { installAndSetup } from "../src/install-and-setup.mjs";
8
9
  try {
9
10
  const args = process.argv.slice(2);
@@ -13,10 +14,16 @@ try {
13
14
  throw Error(
14
15
  "Run Install in an interactive terminal so setup can continue.",
15
16
  );
16
- process.exitCode = await installAndSetup({
17
+ const userDir = fileURLToPath(new URL("..", import.meta.url));
18
+ const terminal = createInterface({input:process.stdin,output:process.stdout});
19
+ let selection;
20
+ try { selection = await selectInstallationData({userDir,ask:message=>terminal.question(message+'\n> ')}); }
21
+ finally { terminal.close(); }
22
+ if (!selection) console.log('Installation cancelled. Existing services were kept.');
23
+ else process.exitCode = await installAndSetup({
17
24
  userDir: fileURLToPath(new URL("..", import.meta.url)),
18
25
  market: args[1],
19
- dataDir: await resolveUserDataDir(),
26
+ dataDir: selection.dataDir,
20
27
  });
21
28
  } catch (error) {
22
29
  if (!error.messageDisplayed) console.error(error.message);
@@ -31,7 +31,7 @@ try {
31
31
  ? walletCliPath()
32
32
  : join(userDir, "src/index.mjs");
33
33
  const env = Object.fromEntries(
34
- ["HOME", "PATH", "LANG", "LC_ALL", "TZ", "TMPDIR"]
34
+ ["HOME", "PATH", "LANG", "LC_ALL", "TZ", "TMPDIR", "XDG_STATE_HOME"]
35
35
  .filter((k) => process.env[k])
36
36
  .map((k) => [k, process.env[k]]),
37
37
  );
@@ -28,6 +28,10 @@ try {
28
28
  "../src/local-data-directory.mjs"
29
29
  );
30
30
  const { runGuidedEntry } = await import("../src/guided-setup.mjs");
31
+ const dataDir = await resolveUserDataDir({argument:args[1]});
32
+ const { activateSetup } = await import('../src/setup-service-selection.mjs');
33
+ const activation = await activateSetup({dataDir});
34
+ if (activation) process.exit(activation);
31
35
  process.exit(
32
36
  await runGuidedEntry({
33
37
  userDir: fileURLToPath(new URL("..", import.meta.url)),
@@ -0,0 +1,163 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ // A local routing index, not payment authority. Preserve old records in place;
3
+ // only service/input hashes and originating data folders enter this index.
4
+ import {constants} from 'node:fs';
5
+ import {mkdir,open,readdir,lstat,realpath} from 'node:fs/promises';
6
+ import {join,resolve,isAbsolute} from 'node:path';
7
+ import {createHash} from 'node:crypto';
8
+ import {hashPrivateRequest} from '@c4roa0/agentpay-protocol';
9
+ import {installationDirectory} from './installation-paths.mjs';
10
+ import {atomicWritePrivate} from './private-journal-files.mjs';
11
+ import {withInstallationOperation} from './installation-operation.mjs';
12
+ import {hashInvocationKey} from './invocation-store.mjs';
13
+ import {parseServiceReference} from './service-reference.mjs';
14
+
15
+ const digest = value => createHash('sha256').update(value).digest('hex');
16
+ const scopeKey = (service,requestHash) => digest(JSON.stringify([service.skillId,service.profileHash,requestHash]));
17
+ const codeError = (code,message) => Object.assign(Error(message),{code});
18
+ const requestIdPattern = /^[A-Za-z0-9._:-]{16,128}$/;
19
+ const review = () => codeError('REQUEST_HISTORY_REVIEW_REQUIRED','Original request history could not be identified safely. Keep the old data folder and inspect agentpay orders list; no replacement purchase was started.');
20
+ const canonical = path => realpath(path).catch(error=>{if(error.code==='ENOENT')return resolve(path);throw error;});
21
+ async function privateDirectory(path) {
22
+ try {
23
+ const info=await lstat(path);
24
+ if(!info.isDirectory() || info.isSymbolicLink() || info.uid!==process.getuid() || info.mode & 0o077)throw review();
25
+ return true;
26
+ }catch(error){if(error.code==='ENOENT')return false;throw error;}
27
+ }
28
+ async function json(path,max=2*1048576) {
29
+ let file;
30
+ try {
31
+ file=await open(path,constants.O_RDONLY|constants.O_NOFOLLOW);
32
+ const info=await file.stat();
33
+ if(!info.isFile() || info.uid!==process.getuid() || info.mode & 0o077 || info.size>max)throw review();
34
+ const value=JSON.parse(await file.readFile('utf8'));
35
+ if(value===null)throw review();
36
+ return value;
37
+ }catch(error){if(error.code==='ENOENT')return null;throw review();}
38
+ finally{await file?.close();}
39
+ }
40
+ async function indexDirectory() {
41
+ const directory=join(installationDirectory(),'request-owners');
42
+ await mkdir(directory,{recursive:true,mode:0o700});await privateDirectory(directory);
43
+ return directory;
44
+ }
45
+ function validOwner(saved) {
46
+ if(saved && (saved.version!==1 || typeof saved.dataDir!=='string' || !isAbsolute(saved.dataDir) ||
47
+ (saved.requestIds!==undefined && (!Array.isArray(saved.requestIds) || saved.requestIds.some(id=>!requestIdPattern.test(id)))) ||
48
+ (saved.conflicts!==undefined && (!Array.isArray(saved.conflicts) || saved.conflicts.some(path=>typeof path!=='string' || !isAbsolute(path))))))throw review();
49
+ }
50
+ async function recordHistory(directory,key,dataDir,requestIds=[]) {
51
+ const path=join(directory,key+'.json'),saved=await json(path,32768);validOwner(saved);
52
+ // Explicit force is a deliberate later purchase, not a migration decision.
53
+ if(saved?.deliberate && saved.dataDir!==dataDir)return;
54
+ if(saved?.dataDir===dataDir) {
55
+ const ids=[...new Set([...(saved.requestIds || []),...requestIds])];
56
+ if(ids.length)await atomicWritePrivate(path,JSON.stringify({...saved,requestIds:ids})+'\n');
57
+ return;
58
+ }
59
+ const conflicts=saved ? [...new Set([...(saved.conflicts || []),saved.dataDir,dataDir])] : undefined;
60
+ await atomicWritePrivate(path,JSON.stringify(saved ? {...saved,conflicts} : {version:1,dataDir,...(requestIds.length?{requestIds}: {})})+'\n');
61
+ }
62
+
63
+ // Called with services stopped during a confirmed switch. Scan only these known
64
+ // private stores, once per switch, never arbitrary folders or remote services.
65
+ export async function importHistoricalRequests(dataDir) {
66
+ return withInstallationOperation(async()=>{
67
+ dataDir=await canonical(dataDir);
68
+ try { return await scanHistoricalRequests(dataDir); }
69
+ catch {
70
+ // Incomplete old history must not prevent Runtime startup. Retain a
71
+ // conservative routing barrier; never turn unreadable records into new calls.
72
+ const directory=await indexDirectory();
73
+ await recordHistory(directory,'unclassified',dataDir);
74
+ return {indexed:0,unclassified:true,reviewRequired:true};
75
+ }
76
+ });
77
+ }
78
+ async function scanHistoricalRequests(dataDir) {
79
+ if(!await privateDirectory(dataDir))return;
80
+ const runtime=join(dataDir,'runtime');if(!await privateDirectory(runtime))return;
81
+ const directory=await indexDirectory(),keys=new Map(),clientRequests=new Map();
82
+ let bytes=0,count=0;
83
+ for(const kind of ['client-requests','client-pending']) {
84
+ const parent=join(runtime,kind);if(!await privateDirectory(parent))continue;
85
+ const names=await readdir(parent);
86
+ if(names.length>20000 || (kind==='client-pending' && names.some(name=>name.endsWith('.lock'))))throw review();
87
+ for(const name of names) {
88
+ if(!/^[a-f0-9]{64}\.json$/.test(name))continue;
89
+ const path=join(parent,name),info=await lstat(path);bytes+=info.size;
90
+ if(++count>20000 || bytes>64*1048576)throw review();
91
+ const saved=await json(path);if(!saved)throw review();
92
+ let service,input,ids;
93
+ if(kind==='client-requests') {
94
+ if(saved.version!=='agentpay.runtime-client-request.v1' || !requestIdPattern.test(saved.requestId || ''))throw review();
95
+ service=parseServiceReference(saved.service);input=saved.input?.request;ids=[saved.requestId];
96
+ clientRequests.set(hashInvocationKey(saved.requestId),{requestHash:hashPrivateRequest(saved.preparedRequest===undefined ? input : saved.preparedRequest),serviceKey:scopeKey(service,hashPrivateRequest(saved.preparedRequest===undefined ? input : saved.preparedRequest))});
97
+ }else {
98
+ if(!['agentpay.runtime-client-pending.v1','agentpay.runtime-client-pending.v2'].includes(saved.version) || !saved.binding?.service)throw review();
99
+ // Pending records intentionally omit the business adapter. The full
100
+ // checkpoint remains the authority for resume; only scope IDs are used here.
101
+ service=saved.binding.service;input=saved.binding.input;
102
+ if(!Array.isArray(saved.pending))throw review();
103
+ ids=[...saved.pending,...(saved.currentId ? [saved.currentId]:[])];
104
+ if(ids.some(id=>!requestIdPattern.test(id)))throw review();
105
+ if(!/^0x[a-fA-F0-9]{64}$/.test(service.skillId || '') || !/^0x[a-fA-F0-9]{64}$/.test(service.profileHash || ''))throw review();
106
+ }
107
+ const key=scopeKey(service,hashPrivateRequest(input));
108
+ keys.set(key,[...new Set([...(keys.get(key) || []),...ids])]);
109
+ }
110
+ }
111
+ const history=await json(join(runtime,'invocations.json'),32*1048576);
112
+ let unclassified=false;
113
+ if(history) {
114
+ if(history.version!=='agentpay.user-invocations.v1' || !history.invocations || typeof history.invocations!=='object' || Array.isArray(history.invocations))throw review();
115
+ for(const [id,entry] of Object.entries(history.invocations)) {
116
+ if(!/^0x[a-f0-9]{64}$/.test(entry?.requestHash || '') || entry.invocationId!==id)throw review();
117
+ // Runtime-only records cannot prove the caller's original input (a Skill
118
+ // may prepare/upload files first). Do not guess a cross-installation match.
119
+ const client=clientRequests.get(id);
120
+ let linked=false;
121
+ if(client && client.requestHash===entry.requestHash) {
122
+ try {
123
+ const binding=JSON.parse(entry.serviceBinding);
124
+ linked=binding.version==='agentpay.runtime-service.v1' &&
125
+ scopeKey(parseServiceReference(binding.reference),entry.requestHash)===client.serviceKey;
126
+ }catch { /* Old or incomplete service identity stays unclassified. */ }
127
+ }
128
+ if(!linked)unclassified=true;
129
+ }
130
+ }
131
+ for(const [key,ids] of keys)await recordHistory(directory,key,dataDir,ids);
132
+ if(unclassified)await recordHistory(directory,'unclassified',dataDir);
133
+ return {indexed:keys.size,unclassified};
134
+ }
135
+
136
+ // Keep ownership selection and local request creation in one maintenance section.
137
+ // Network/payment calls run after it, always against the pinned original connection.
138
+ export async function withDefaultRequest({service,input,dataDir,force=false},operation) {
139
+ return withInstallationOperation(async()=>{
140
+ dataDir=await canonical(dataDir);
141
+ const directory=await indexDirectory();
142
+ const path=join(directory,scopeKey(service,hashPrivateRequest(input.request))+'.json');
143
+ const saved=await json(path,32768);validOwner(saved);
144
+ const unknown=await json(join(directory,'unclassified.json'),32768);validOwner(unknown);
145
+ if(!force && !(saved?.deliberate && saved.dataDir===dataDir) && (unknown?.conflicts || (unknown && unknown.dataDir!==dataDir)))
146
+ throw codeError('REQUEST_HISTORY_REVIEW_REQUIRED','Another installation has older requests whose original input cannot be identified. Review them in the original installation. Use --force only if you deliberately want a new purchase.');
147
+ if(!force && saved?.conflicts)
148
+ throw codeError('ORIGINAL_REQUEST_AMBIGUOUS','Several local installations contain this request. Return to the intended installation and use --resume with its saved request ID. No new purchase was started.');
149
+ if(!force && saved && saved.dataDir!==dataDir)
150
+ throw codeError('ORIGINAL_INSTALLATION_REQUIRED','This input was used by another local Wallet. Return to that installation to recover it. Use --force only for a deliberate new purchase.');
151
+ const next = !saved || force ? {version:1,dataDir,...(force?{deliberate:true}:{})} : saved;
152
+ if(!saved || force)await atomicWritePrivate(path,JSON.stringify(next)+'\n');
153
+ const result = await operation(!force ? next.requestIds : undefined);
154
+ if(result?.requestId) {
155
+ if(!requestIdPattern.test(result.requestId))throw review();
156
+ await atomicWritePrivate(path,JSON.stringify({...next,requestIds:[...new Set([...(next.requestIds || []),result.requestId])]})+'\n');
157
+ }
158
+ return result;
159
+ });
160
+ }
161
+ export async function claimDefaultRequest(options) {
162
+ return withDefaultRequest(options,async()=>{});
163
+ }
package/src/index.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { publishRuntimeReady } from "./runtime-selection-ready.mjs";
2
+ import { acquireRuntimeOwner } from "./runtime-owner.mjs";
1
3
  import { runtimeSoftware } from "./software-versions.mjs";
2
4
  // Authenticated local invocation engine. Preserve durable request/payment checkpoints before
3
5
  // network calls; startup reconciliation observes history without creating replacement purchases.
@@ -15,6 +17,7 @@ import {
15
17
  import { serviceUrl } from "./service-url.mjs";
16
18
  import { createRuntimeRecovery } from "./runtime-recovery.mjs";
17
19
  import { constants } from "node:fs";
20
+ import { dirname } from "node:path";
18
21
  import { open } from "node:fs/promises";
19
22
  import { assertOrderCanContinue } from "./order-closure.mjs";
20
23
  import { invocationResults } from "./invocation-result.mjs";
@@ -37,6 +40,9 @@ import { fetchJsonWithTimeout } from "./transport.mjs";
37
40
  import { readFinalizationDisclosure } from "./finalization-disclosure.mjs";
38
41
  import { walletIpcError } from "./wallet-ipc-error.mjs";
39
42
 
43
+ // Retain the FileHandle for the entire server lifetime; process exit releases it.
44
+ const runtimeOwner = await acquireRuntimeOwner();
45
+ process.once('exit', () => { void runtimeOwner; });
40
46
  const host = String(process.env.AGENTPAY_USER_HOST || "127.0.0.1").trim();
41
47
  if (!["127.0.0.1", "localhost", "::1"].includes(host))
42
48
  throw Error("User Runtime must listen on loopback");
@@ -785,6 +791,9 @@ const server = createServer(async (req, res) => {
785
791
  });
786
792
 
787
793
  server.listen(port, host, () => {
794
+ void publishRuntimeReady(dirname(dirname(callerCredentialFile))).catch(error=>{
795
+ console.error('Default Runtime connection could not be refreshed:',error.message);
796
+ });
788
797
  process.stdout.write(
789
798
  `${JSON.stringify({
790
799
  ready: true,
@@ -5,6 +5,7 @@ import { rememberUserDataDir } from "./local-data-directory.mjs";
5
5
  import { spawn } from "node:child_process";
6
6
  import { join } from "node:path";
7
7
  import { runInstall } from "./install-user.mjs";
8
+ import { activateSetup } from './setup-service-selection.mjs';
8
9
 
9
10
  export async function installAndSetup({
10
11
  userDir,
@@ -13,9 +14,13 @@ export async function installAndSetup({
13
14
  install = runInstall,
14
15
  start = startGuide,
15
16
  remember = rememberUserDataDir,
17
+ activate = activateSetup,
16
18
  write = console.log,
17
19
  }) {
18
- const result = await install({ userDir, market, dataDir, write });
20
+ const result = await install({ userDir, market, dataDir, write,
21
+ beforeApplySettings: () => activate({dataDir,write}),
22
+ });
23
+ if (result?.cancelled) return result.exitCode;
19
24
  if (!result?.completed)
20
25
  throw Error(
21
26
  "Installation did not complete. Run the same Install command to resume.",
@@ -0,0 +1,41 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ // Choose storage before dependencies or services are changed. Selection itself
3
+ // never creates a Wallet, copies a key, or activates another installation.
4
+ import { lstat, mkdir } from 'node:fs/promises';
5
+ import { dirname, join, resolve } from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { readInstallationBinding } from './installation-binding.mjs';
8
+ import { resolveUserDataDir } from './local-data-directory.mjs';
9
+
10
+ export async function selectInstallationData({userDir, ask, write = console.log, env = process.env}) {
11
+ const binding = await readInstallationBinding(userDir);
12
+ if (binding) {
13
+ if (env.AGENTPAY_USER_DATA_DIR && resolve(env.AGENTPAY_USER_DATA_DIR) !== binding.dataDir)
14
+ throw Error('This installation already uses another data folder. Remove the conflicting AGENTPAY_USER_DATA_DIR setting before installing.');
15
+ return {dataDir:binding.dataDir, requiresActivation:true};
16
+ }
17
+ const existing = await resolveUserDataDir({userDir, env});
18
+ const info = await lstat(existing).catch(e => { if (e.code === 'ENOENT') return null; throw e; });
19
+ if (!info) return {dataDir:existing, requiresActivation:true};
20
+ if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid() || info.mode & 0o077)
21
+ throw Error('The existing User data folder needs ownership and permission review. No installation was changed.');
22
+ write('\nExisting AgentPay data');
23
+ write(existing);
24
+ write('Use it to keep your Wallet and Orders, or create separate data for this installation.');
25
+ while (true) {
26
+ const choice = String(await ask("Enter 'reuse', 'new', or 'q' to cancel")).trim().toLowerCase();
27
+ if (choice === 'q') return null;
28
+ if (choice === 'reuse') {
29
+ // Activation belongs to the serialized service-switch flow, not storage choice.
30
+ write('Existing Wallet and Order data will be kept. This does not switch running services.');
31
+ return {dataDir:existing, requiresActivation:true, selection:'reuse'};
32
+ }
33
+ if (choice === 'new') {
34
+ const dataDir = join(dirname(existing), 'user-' + randomUUID());
35
+ await mkdir(dataDir, {mode:0o700});
36
+ write('Separate data folder: ' + dataDir);
37
+ return {dataDir, requiresActivation:true, selection:'new'};
38
+ }
39
+ write("Choose 'reuse' to keep the existing data, 'new' for a separate Wallet setup, or 'q'.");
40
+ }
41
+ }