@c4roa0/agentpay-user 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +3 -1
  2. package/doc/README.md +12 -0
  3. package/doc/TODO.md +48 -1
  4. package/doc/plans/local-instances-and-install-recovery.md +250 -0
  5. package/doc/records/client-isolated-acceptance-2026-11-11.md +37 -0
  6. package/doc/records/dual-install-systemd-2026-11-12.md +40 -0
  7. package/doc/records/historical-request-routing-2026-11-11.md +47 -0
  8. package/doc/records/installation-maintenance-2026-11-11.md +46 -0
  9. package/doc/records/npm-update-participant-2026-11-11.md +25 -0
  10. package/doc/reference/local-update.md +2 -1
  11. package/doc/runbooks/commands.md +28 -0
  12. package/doc/runbooks/local-update.md +11 -5
  13. package/package.json +1 -1
  14. package/progress.md +136 -6
  15. package/scripts/autostart.mjs +51 -25
  16. package/scripts/install-user.mjs +10 -3
  17. package/scripts/local-service.mjs +1 -1
  18. package/scripts/user-setup.mjs +4 -0
  19. package/src/cache-management-cli.mjs +32 -2
  20. package/src/default-request-owner.mjs +163 -0
  21. package/src/index.mjs +9 -0
  22. package/src/install-and-setup.mjs +6 -1
  23. package/src/install-selection.mjs +41 -0
  24. package/src/install-user.mjs +22 -22
  25. package/src/installation-binding.mjs +62 -0
  26. package/src/installation-operation.mjs +114 -0
  27. package/src/installation-paths.mjs +50 -1
  28. package/src/invocation-store.mjs +1 -1
  29. package/src/local-data-directory.mjs +17 -17
  30. package/src/local-update.mjs +41 -10
  31. package/src/local-wallet-ipc.mjs +2 -2
  32. package/src/npm-update.mjs +68 -20
  33. package/src/platform-endpoints.mjs +5 -8
  34. package/src/runtime-client.mjs +21 -4
  35. package/src/runtime-connection-settings.mjs +2 -1
  36. package/src/runtime-owner.mjs +22 -0
  37. package/src/runtime-selection-ready.mjs +29 -0
  38. package/src/service-readiness.mjs +29 -0
  39. package/src/service-selection.mjs +189 -0
  40. package/src/setup-autostart.mjs +12 -2
  41. package/src/setup-service-selection.mjs +25 -0
  42. package/src/skill-client.mjs +8 -3
  43. package/src/uninstall-local.mjs +69 -15
  44. package/src/user-cli.mjs +1 -2
  45. package/update-pair.json +1 -1
package/progress.md CHANGED
@@ -1,20 +1,150 @@
1
1
  # AgentPay User progress
2
2
 
3
- ## User 0.3.6 release candidate — 2026-11-10
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
88
+
89
+ Patch release includes installed/target Wallet version preview and clearer Cache ID
90
+ help with unique short-ID lookup for read-only details. Wallet remains pinned to
91
+ 0.3.3 and Protocol to 0.14.3. Manifest, lockfile and update-pair versions are aligned.
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.
97
+
98
+
99
+ ## Existing client update accepted — 2026-11-11
100
+
101
+ Participant directory-local npm update to User 0.3.6 / Wallet 0.3.3 completed.
102
+ Running versions, unlocked Wallet, two settled Orders, unchanged saved validity,
103
+ startup/Market connection and retained available result cache were observed.
104
+ Participant confirmed both original Orders remain visible on Market. The older
105
+ missing result predates this update. No new payment or Policy was created.
106
+ [Evidence and limits](doc/records/npm-update-participant-2026-11-11.md). Update-preview and Cache ID guidance fixes are
107
+ published in 0.3.7. Fresh installation and interrupted-update
108
+ acceptance remain next for discussion; neither has started in this loop.
109
+
110
+
111
+ ## Cache details guidance — 2026-11-11
112
+
113
+ Missing details IDs now show a short example before accessing local storage.
114
+ List guidance explains that the namespace is part of the ID. Read-only details
115
+ accept a unique prefix with at least eight hexadecimal characters; ambiguous,
116
+ missing or incomplete bounded lookups require a full ID. Export and cleanup keep
117
+ their existing exact-identity rules. All 19 targeted cache/CLI regressions passed
118
+ with isolated process tests outside the restricted sandbox; no live cache changed.
119
+ Included in published 0.3.7. There is no input: ID namespace: inputs use the
120
+ attachment ID shown by list, while results can use result: or runtime-result:0x.
121
+
122
+
123
+ ## Update preview clarity — 2026-11-11
124
+
125
+ The confirmation screen now lists installed User and Wallet versions separately
126
+ from update targets. Wallet is read from the installed dependency, not inferred
127
+ from User's requested dependency. Unchanged versions are explicit; unreadable or
128
+ missing Wallet metadata shows “not confirmed” without blocking the normal repair
129
+ through npm. Controlled update tests cover all three cases and cancellation without
130
+ service/install mutation. Published in 0.3.7; running-process
131
+ versions remain the responsibility of status after update/restart.
132
+
133
+
134
+ ## User 0.3.6 published — 2026-11-10
4
135
 
5
136
  Pins published Wallet 0.3.3, retaining Protocol 0.14.3. Maintenance errors provide
6
137
  same-request guidance without automatic write retries. Package manifest, lockfile
7
- and update-pair metadata agree. Publication uses the matching v0.3.6 tag after
8
- independent installation and behavior checks; completion is not implied by this
9
- candidate record. No local data, Policy or service changes occur on publication.
138
+ and update-pair metadata agree. Published through matching tag v0.3.6 after 391 behavior tests and independent
139
+ installation/lifecycle checks. Public npm metadata and SHA-512 match the verified
140
+ archive. No local data, Policy or service changes occur on publication.
10
141
 
11
142
 
12
143
  ## Maintenance guidance — 2026-11-10
13
144
 
14
145
  Added explicit maintenance guidance without automatic write retries or replacement
15
146
  purchases. Controlled HTTP/IPC tests verify that retry retains original request
16
- identifiers. This is source verification, not live financial acceptance. Changes
17
- have not been published to npm.
147
+ identifiers. This is source verification, not live financial acceptance. These changes were subsequently published in 0.3.6; see the release record above.
18
148
 
19
149
 
20
150
  ## Acceptance bookkeeping — 2026-11-10
@@ -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)),
@@ -128,6 +128,11 @@ export async function runCacheManagement(
128
128
  }
129
129
  const usage =
130
130
  "Usage: agentpay cache [info | list [PAGE] [input|output] [--state STATE] [--request ID | --order ID] | details ID | export ID PATH | clean | settings [KEY VALUE]]\nAdd --offline only when managing stopped Runtime storage. Wallet unlock is not required.";
131
+ if(action === "details" && rest.length === 0){
132
+ write("Choose a Cache ID from agentpay cache list. Copy the whole ID, including any prefix such as result:.");
133
+ write("Example: agentpay cache details result:5aa18b50\nA unique short ID needs at least 8 hexadecimal characters.");
134
+ return 2;
135
+ }
131
136
  if (
132
137
  invalidFilter ||
133
138
  !["summary", "list", "details", "export", "clean", "settings"].includes(
@@ -192,7 +197,7 @@ export async function runCacheManagement(
192
197
  ...(rest[1] ? { purpose: rest[1] } : {}),
193
198
  });
194
199
  write(
195
- `\n${r.total} entries · page ${page}\nDetails: agentpay cache details <Cache ID>`,
200
+ `\n${r.total} entries · page ${page}\nDetails: agentpay cache details <Cache ID>\nCopy the full ID including its prefix, or use a unique short ID (8+ hex characters).`,
196
201
  );
197
202
  if (!r.items.length)
198
203
  write("No saved results or attachments match this view.");
@@ -222,7 +227,32 @@ export async function runCacheManagement(
222
227
  .join("")}`,
223
228
  );
224
229
  } else if (action === "details") {
225
- const r = await operate({ action, id: rest[0] });
230
+ let id=rest[0];
231
+ if(!/^(?:result:|runtime-result:0x)?[0-9a-f]{64}$/.test(id)){
232
+ if(!/^(?:result:|runtime-result:0x)?[0-9a-f]{8,63}$/.test(id)){
233
+ write('Use the Cache ID from agentpay cache list, or its first 8 or more hexadecimal characters.');
234
+ return 2;
235
+ }
236
+ // Resolve only read-only details. Bound enumeration; never guess from an
237
+ // incomplete list or turn a short display identifier into a cleanup key.
238
+ const matches=new Set();let offset=0,total;
239
+ do {
240
+ const page=await operate({action:'list',offset,limit:100});
241
+ if(page.total>1000 || total!==undefined&&page.total!==total || !page.items.length&&offset<page.total){
242
+ write('Use the full Cache ID; the cache is too large or changed during lookup.');return 2;
243
+ }
244
+ total=page.total;
245
+ for(const row of page.items){
246
+ const key=id.includes(':')?row.id:row.id.replace(/^(?:result:|runtime-result:0x)/,'');
247
+ if(key.startsWith(id))matches.add(row.id);
248
+ }
249
+ if(matches.size>1){write('More than one cache entry matches. Copy a longer or full Cache ID from agentpay cache list.');return 2;}
250
+ offset+=page.items.length;
251
+ }while(offset<total);
252
+ if(!matches.size){write('No cache entry matches. Run agentpay cache list and copy its Cache ID.');return 2;}
253
+ id=[...matches][0];
254
+ }
255
+ const r = await operate({ action, id });
226
256
  write(
227
257
  `${safe(r.filename)}\n ${contentSize(r)} · ${r.state}\n ${r.requestId ? "Request" : "Order"}: ${safe(r.requestId || r.orderId)}\n File key: ${safe(r.fileKey)}\n SHA-256: ${r.sha256 || "Not available"}\n Updated: ${new Date(r.updatedAtMs).toLocaleString()}`,
228
258
  );
@@ -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.",