@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
package/progress.md CHANGED
@@ -1,12 +1,120 @@
1
1
  # AgentPay User progress
2
2
 
3
- ## User 0.3.7 release preparation — 2026-11-11
3
+ ## Official Skill local verification — 2026-11-12
4
+
5
+ User 0.3.9 candidate: update/repair verifies the release-pinned local ZIP and its extracted
6
+ files before using the network. Matching files skip download; edited or incomplete
7
+ extracted files are preserved with guidance. Software-update repair progress streams
8
+ as it happens. Targeted update/package regressions passed; the actual pinned 1.2.1
9
+ archive was installed in temporary storage and verified with network access forbidden.
10
+ Child output streaming was checked with a real process. No live services or payments changed.
11
+
12
+ ## User 0.3.8 published — 2026-11-12
13
+
14
+ User `0.3.8` and tag `v0.3.8` identify release commit `727e547`.
15
+ [Publication workflow](https://github.com/c4rO-0/agentpay-user/actions/runs/43148236738)
16
+ passed the complete behavior suite, standalone package installation and publication.
17
+ The public npm `latest` version and archive integrity were independently verified.
18
+ Dependencies remain Wallet 0.3.3 and Protocol 0.14.3; official test Skill remains 1.2.1.
19
+ The release includes multiple installations with one active Runtime/Wallet pair.
20
+ Real A/B/A switching passed; desktop login/reboot, full guided installation and
21
+ complete interrupted-switch acceptance remain separate pending checks.
22
+ Agent's candidate template is not published by this release.
23
+
24
+ ## Real two-installation switch passed — 2026-11-12
25
+
26
+ Actual user-systemd switching A → B → A passed using source and folder-local npm
27
+ candidate installations with separate data. Cancellation preserved A's process;
28
+ repeated B start preserved its process. B authenticated status passed. Original
29
+ A key/Policy hashes were unchanged, and its existing Orders/Market connection
30
+ remained visible. A is restored with both services running and startup enabled;
31
+ Wallet is locked. Source dependencies now match Wallet 0.3.3 / Protocol 0.14.3;
32
+ old dependencies and inactive B are retained privately. No payment or authorization.
33
+ The initial harness rejected an expected nonzero status for B's unconfigured Wallet;
34
+ corrected full execution passed. This does not establish fresh guided install,
35
+ desktop login/reboot or funded recovery. [Evidence](doc/records/dual-install-systemd-2026-11-12.md).
36
+
37
+ ## Service command recovery — 2026-11-12
38
+
39
+ Service-manager failure recovery is now stage-aware: complete exact target unit
40
+ files are checkpointed before reload/start/stop, so command failure permits retry
41
+ or repair. Older incomplete journals recover only after all target units match;
42
+ partial/conflicting units remain blocked. Sixteen selection checks passed, including
43
+ actual autostart subprocesses with a controlled systemctl executable. No real
44
+ systemd service or transaction was changed (2026-11-12).
45
+
46
+ ## Sequential review fixes — 2026-11-11
47
+
48
+ Shared-data installation switching now relocates the client module only after
49
+ switch confirmation and stopped-service/listener checks. It accepts the previous
50
+ managed installation path or the already-relocated current path; unrelated paths
51
+ remain blocked. Retrying `service start` completes interrupted preparation without
52
+ requiring repair first. Keys, authorization and request records are unchanged.
53
+ Twelve selection tests and both connection/preparation test files passed with
54
+ real temporary storage/native locks and simulated service management. Real systemd
55
+ acceptance is still pending.
56
+
57
+ Default availability now requires authenticated Wallet/Runtime readiness and
58
+ matching running versions; locked Wallets are accepted. Startup preferences and
59
+ preparation preserve existing availability, removal closes it, and readiness failure
60
+ leaves maintenance retryable. Login startup verifies both services under the selection
61
+ lock and checks the selected program/data identity. Transport fixtures and lifecycle
62
+ regressions passed; actual systemd acceptance remains pending.
63
+
64
+ Installation now stages downloaded settings in memory, confirms activation under
65
+ the shared operation lock, then writes settings and binds the installation. Cancel
66
+ preserves the previous deployment. A private per-user state locator preserves the
67
+ initial XDG location across shells, background services and candidate Skill loaders;
68
+ changing XDG does not create another ownership/maintenance lock namespace.
69
+ Isolated installation, transport, process-lock and update/uninstall regressions passed.
70
+ Next: overall review and real two-install systemd acceptance before release.
71
+ systemd is a Linux backend, not the definition of installation identity.
72
+ macOS background startup remains deferred until a test machine is available.
73
+
74
+ ## Multiple installations, one active Runtime — 2026-11-11
75
+
76
+ Working-tree implementation now includes canonical installation/data bindings,
77
+ reuse/new/cancel selection, confirmed service switching, single Runtime lifetime
78
+ ownership, default Skill routing and protection for newly recorded request scopes.
79
+ Agent template 0.1.2 / example Skill 1.2.2 remain unpublished candidates; User still
80
+ pins official Skill 1.2.1. Historical request ownership is now indexed during
81
+ confirmed switches; original files and authorization remain unchanged.
82
+ [Design and remaining acceptance](doc/plans/local-instances-and-install-recovery.md).
83
+
84
+ Installation, update, repair, uninstall, service switching and address maintenance
85
+ now share one operation lock. Inactive update/repair preserves the selected services;
86
+ shared active program/data paths require explicit switching. Short-lived writers and
87
+ administration children inherit the lock, while daemons do not. Recognizable v2 locks
88
+ recover after writers exit; old unknown locks remain blocked for review. Interrupted
89
+ updates retain the confirmed target and finish repair even when npm already installed
90
+ that version. Uninstall rechecks the confirmed scope and supports partial-unit retry.
91
+
92
+ 77 combined targeted checks passed, followed by the marker-publication recovery
93
+ check and its process-lock regressions. These cover process interruption/inheritance, temporary
94
+ storage and controlled npm/service interfaces. A separate offline npm install verified
95
+ Wallet 0.3.3, Protocol 0.14.3 and native-lock loading with hooks disabled. The working
96
+ checkout's older dependencies were not replaced. [Evidence and limits](doc/records/installation-maintenance-2026-11-11.md).
97
+
98
+ Historical routing now preserves known IDs and refuses missing/conflicting original
99
+ selection. Unclassified older history does not prevent startup, but blocks automatic
100
+ replacement calls from another installation; explicit force remains a new purchase.
101
+ 35 focused checks passed with controlled HTTP/service fixtures.
102
+
103
+ Remaining: complete two-install public-entry and
104
+ real systemd switch/login acceptance; release coordination and participant rehearsal.
105
+ Unknown occupied ports still conservatively block uninstall. No live service, key,
106
+ Policy, Order, payment, migration, commit or publication changed in this loop.
107
+
108
+ ## User 0.3.7 published — 2026-11-11
4
109
 
5
110
  Patch release includes installed/target Wallet version preview and clearer Cache ID
6
111
  help with unique short-ID lookup for read-only details. Wallet remains pinned to
7
112
  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.
113
+ 393 behavior tests passed from an isolated lockfile installation; standalone npm
114
+ archive installation and process lifecycle passed. Tag v0.3.7 points to source
115
+ 3d31297dd3baedbd798f51e86c5fa8a1e474bb4a. GitHub run 42976422954 succeeded;
116
+ public registry SHA-512 matches the tested archive and latest is 0.3.7. No local
117
+ participant service or private data was changed by publication.
10
118
 
11
119
 
12
120
  ## Existing client update accepted — 2026-11-11
@@ -17,7 +125,7 @@ startup/Market connection and retained available result cache were observed.
17
125
  Participant confirmed both original Orders remain visible on Market. The older
18
126
  missing result predates this update. No new payment or Policy was created.
19
127
  [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
128
+ published in 0.3.7. Fresh installation and interrupted-update
21
129
  acceptance remain next for discussion; neither has started in this loop.
22
130
 
23
131
 
@@ -29,7 +137,7 @@ accept a unique prefix with at least eight hexadecimal characters; ambiguous,
29
137
  missing or incomplete bounded lookups require a full ID. Export and cleanup keep
30
138
  their existing exact-identity rules. All 19 targeted cache/CLI regressions passed
31
139
  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
140
+ Included in published 0.3.7. There is no input: ID namespace: inputs use the
33
141
  attachment ID shown by list, while results can use result: or runtime-result:0x.
34
142
 
35
143
 
@@ -40,7 +148,7 @@ from update targets. Wallet is read from the installed dependency, not inferred
40
148
  from User's requested dependency. Unchanged versions are explicit; unreadable or
41
149
  missing Wallet metadata shows “not confirmed” without blocking the normal repair
42
150
  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
151
+ service/install mutation. Published in 0.3.7; running-process
44
152
  versions remain the responsibility of status after update/restart.
45
153
 
46
154
 
@@ -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
+ }