@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
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ // Manual setup remains available when the OS has no user service manager.
3
+ import { execFile, spawn } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { installedServiceSelection } from './service-selection.mjs';
7
+ import {installationChildOptions} from './installation-operation.mjs';
8
+ export async function activateSetup({dataDir, platform=process.platform,
9
+ available=()=>promisify(execFile)('systemctl',['--user','show-environment'],{timeout:10000}),
10
+ inspect=installedServiceSelection, write=console.log,
11
+ launch=()=>new Promise((resolve,reject)=>{
12
+ const child=spawn(process.execPath,[fileURLToPath(new URL('../scripts/autostart.mjs',import.meta.url)),'activate-setup'],{
13
+ ...installationChildOptions({stdio:['inherit','inherit','inherit'],env:{...process.env,AGENTPAY_USER_DATA_DIR:dataDir}})
14
+ });
15
+ child.once('error',reject);child.once('close',code=>resolve(code ?? 1));
16
+ })}={}) {
17
+ let managed=platform==='linux';
18
+ if (managed) { try {await available();} catch {managed=false;} }
19
+ if (!managed) {
20
+ if (await inspect()) throw Error('Existing background service files were found, but the user service manager is unavailable. Restore it before switching installations.');
21
+ write('User service manager unavailable. Continuing with manual setup; login startup is not enabled.');
22
+ return 0;
23
+ }
24
+ return launch();
25
+ }
@@ -1,10 +1,12 @@
1
1
  // SPDX-License-Identifier: MPL-2.0
2
2
  // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
3
3
  // If a copy was not distributed with this file, see https://mozilla.org/MPL/2.0/.
4
+ import { constants } from "node:fs";
4
5
  import { currentTimeMs } from "./local-time.mjs";
5
6
  import { readSkillZip } from "./skill-zip.mjs";
6
7
  import { createHash, randomUUID } from "node:crypto";
7
8
  import {
9
+ open,
8
10
  mkdir,
9
11
  lstat,
10
12
  readFile,
@@ -140,3 +142,39 @@ export async function installSkillBundle({
140
142
  await rm(staging, { recursive: true, force: true });
141
143
  }
142
144
  }
145
+
146
+ // Read-only fast path for a release-pinned package. A matching ZIP alone is not
147
+ // enough: compare every installed file with its authenticated archive content.
148
+ export async function inspectSavedSkillBundle(dataDir, pin) {
149
+ const parent = join(dataDir, "runtime", "skill-bundles");
150
+ const directory = join(parent, `${pin.name}-${pin.version}-${pin.sha256}`);
151
+ async function readBounded(file, max) {
152
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
153
+ try {
154
+ const stat = await handle.stat();
155
+ if (!stat.isFile() || stat.size > max) throw Error("Invalid saved Skill file");
156
+ return await handle.readFile();
157
+ } finally { await handle.close(); }
158
+ }
159
+ try {
160
+ for (const path of [dataDir, join(dataDir, "runtime"), parent, directory]) await privateDir(path);
161
+ const bytes = await readBounded(join(directory, "publisher.zip"), skillArchiveLimits.compressed);
162
+ if (bundleDigest(bytes) !== pin.sha256) return {status: "download_required"};
163
+ const parsed = parseSkillBundle(bytes);
164
+ if (parsed.manifest.packageName !== pin.name || parsed.manifest.packageVersion !== pin.version)
165
+ return {status: "download_required"};
166
+ try {
167
+ for (const [name, expected] of Object.entries(parsed.files)) {
168
+ // Refuse symlinked intermediate directories as well as symlinked files.
169
+ let ancestor = dirname(join(directory, name));
170
+ while (ancestor !== directory) {
171
+ await privateDir(ancestor);
172
+ ancestor = dirname(ancestor);
173
+ }
174
+ const actual = await readBounded(join(directory, name), skillArchiveLimits.file);
175
+ if (!actual.equals(expected)) return {status: "local_edits", directory};
176
+ }
177
+ } catch { return {status: "local_edits", directory}; }
178
+ return {status: "ready", directory};
179
+ } catch { return {status: "download_required"}; }
180
+ }
@@ -1,3 +1,4 @@
1
+ import {withDefaultRequest} from "./default-request-owner.mjs";
1
2
  // Generic Skill CLI glue. Business input/output validation is supplied by the Skill;
2
3
  // payment authority and request recovery remain in the installed client/Runtime.
3
4
  // SPDX-License-Identifier: MPL-2.0
@@ -7,6 +8,7 @@ export const skillClientCapabilities = Object.freeze([
7
8
  "input-snapshots-v1",
8
9
  "output-files-v1",
9
10
  "original-request-v1",
11
+ "default-selection-v1",
10
12
  ]);
11
13
  import {
12
14
  readServiceOffer,
@@ -115,7 +117,7 @@ export async function runSkill({
115
117
  "Check the business input against this Skill’s instructions.",
116
118
  );
117
119
  }
118
- checkpoint = await selectInvocation(
120
+ const select = (knownRequestIds) => selectInvocation(
119
121
  connection,
120
122
  reference,
121
123
  {
@@ -123,8 +125,11 @@ export async function runSkill({
123
125
  requestedMaxUsageUnits: units,
124
126
  ...(acceptedTerms === undefined ? {} : { acceptedTerms }),
125
127
  },
126
- { force, ...(requestId === undefined ? {} : { requestId }) },
128
+ { force, knownRequestIds, ...(requestId === undefined ? {} : { requestId }) },
127
129
  );
130
+ checkpoint = env.AGENTPAY_DEFAULT_SELECTION === '1'
131
+ ? await withDefaultRequest({service:reference,input:{request},dataDir:connection.dataDir,force},select)
132
+ : await select();
128
133
  } else
129
134
  throw new RuntimeClientError(
130
135
  "INVALID_ARGUMENTS",
@@ -181,7 +186,7 @@ export async function runSkill({
181
186
  } catch (error) {
182
187
  const known =
183
188
  error instanceof RuntimeClientError ||
184
- ["SERVICE_REFERENCE_INVALID"].includes(error.code);
189
+ ["SERVICE_REFERENCE_INVALID", "ORIGINAL_INSTALLATION_REQUIRED", "ORIGINAL_REQUEST_AMBIGUOUS", "REQUEST_HISTORY_REVIEW_REQUIRED"].includes(error.code);
185
190
  write({
186
191
  ok: false,
187
192
  ...(requestId ? { requestId } : {}),
@@ -1,3 +1,5 @@
1
+ import { installedServiceSelection, withServiceLifecycleLock } from "./service-selection.mjs";
2
+ import { installationBindingPath } from "./installation-binding.mjs";
1
3
  import { installationDirectory, legacyInstallationDirectory } from "./installation-paths.mjs";
2
4
  import { walletPackage } from "./wallet-package.mjs";
3
5
  // SPDX-License-Identifier: MPL-2.0
@@ -6,8 +8,7 @@ import { walletPackage } from "./wallet-package.mjs";
6
8
  import { lstat, realpath, readFile, rm, unlink } from "node:fs/promises";
7
9
  import { dirname, join, relative, resolve, parse, isAbsolute } from "node:path";
8
10
  import { homedir } from "node:os";
9
- import { execFile } from "node:child_process";
10
- import { promisify } from "node:util";
11
+ import {runInstallationCommand, installationLockMarker} from "./installation-operation.mjs";
11
12
  import { createInterface } from "node:readline/promises";
12
13
  import { autostartFiles, isManagedUnit } from "./autostart-files.mjs";
13
14
  import {
@@ -15,7 +16,7 @@ import {
15
16
  installedUserDir,
16
17
  } from "./local-data-directory.mjs";
17
18
  import { readServiceSettings, listenerPresent } from "./local-services.mjs";
18
- const execute = promisify(execFile);
19
+ const execute = runInstallationCommand;
19
20
  const inside = (parent, path) => {
20
21
  const r = relative(parent, path);
21
22
  return r === "" || (!r.startsWith("..") && !parse(r).root);
@@ -43,9 +44,13 @@ export async function uninstallPlan({
43
44
  home = homedir(),
44
45
  node = process.execPath,
45
46
  removeData = false,
47
+ underLock = false,
46
48
  }) {
47
49
  dataDir = resolve(dataDir);
48
- userDir = resolve(userDir);
50
+ userDir = await realpath(resolve(userDir)).catch(error => {
51
+ if (error.code === "ENOENT") return resolve(userDir);
52
+ throw error;
53
+ });
49
54
  home = resolve(home);
50
55
  let walletDir;
51
56
  try { walletDir = walletPackage(userDir).directory; } catch { walletDir = userDir; }
@@ -84,10 +89,15 @@ export async function uninstallPlan({
84
89
  if (e.code === "ENOENT") return null;
85
90
  throw e;
86
91
  });
87
- if (lock)
88
- throw Error(
89
- "An installer lock exists. Finish or inspect that installation before uninstalling.",
90
- );
92
+ if (lock && !underLock) {
93
+ // A v2 marker can remain after interruption; the operation lock, acquired
94
+ // after confirmation, decides whether its writers have actually exited.
95
+ const path = join(installDir, "install.lock");
96
+ await target(path, {privateMode:true});
97
+ if (lock.size !== Buffer.byteLength(installationLockMarker) ||
98
+ await readFile(path, "utf8") !== installationLockMarker)
99
+ throw Error("An older installer lock exists. Keep it for review before uninstalling.");
100
+ }
91
101
  const install = await target(installDir, {
92
102
  directory: true,
93
103
  privateMode: true,
@@ -101,16 +111,24 @@ export async function uninstallPlan({
101
111
  if (
102
112
  saved.version !== 1 ||
103
113
  typeof saved.dataDir !== "string" ||
104
- !isAbsolute(saved.dataDir) ||
105
- resolve(saved.dataDir) !== dataDir
114
+ !isAbsolute(saved.dataDir)
106
115
  )
107
116
  throw Error(
108
117
  "Installation record points to another data directory; no files were removed.",
109
118
  );
110
- targets.push({ ...install, checkpoint: true });
119
+ if (resolve(saved.dataDir) === dataDir) targets.push({ ...state, checkpoint: true });
111
120
 
112
121
  }
113
122
  }
123
+ if (removeData) {
124
+ const path = await installationBindingPath(userDir).catch(e => {if(e.code==='ENOENT')return null;throw e;});
125
+ const binding = path && await target(path, {privateMode:true});
126
+ if (binding) {
127
+ const saved = JSON.parse(await readFile(binding.path,'utf8'));
128
+ if (saved.dataDir !== dataDir) throw Error('Binding changed; no data was removed.');
129
+ targets.push({...binding,checkpoint:true});
130
+ }
131
+ }
114
132
  // Retire only the old source checkpoint that names this exact data directory.
115
133
  // Otherwise a later setup could rediscover and reimport purged configuration.
116
134
  if (removeData) {
@@ -135,7 +153,14 @@ export async function uninstallPlan({
135
153
  const unitDir = join(home, ".config/systemd/user"),
136
154
  generated = autostartFiles({ node, userDir, dataDir }),
137
155
  units = [];
138
- for (const [name, body] of Object.entries(generated)) {
156
+ // A valid pair owned by a different installation is not an uninstall target.
157
+ const owner = await installedServiceSelection(unitDir, undefined, {allowPartial:true}).catch(error => {
158
+ throw Error('Service files are not a consistent managed file set: ' + error.message);
159
+ });
160
+ const foreign = owner && owner.userDir !== userDir;
161
+ if (foreign && owner.dataDir === dataDir && removeData)
162
+ throw Error('Another installation uses this data folder. Its data cannot be deleted here.');
163
+ for (const [name, body] of Object.entries(foreign ? {} : generated)) {
139
164
  const file = await target(join(unitDir, name));
140
165
  if (!file) continue;
141
166
  for (const path of [
@@ -158,14 +183,14 @@ export async function uninstallPlan({
158
183
  );
159
184
  units.push({ ...file, name });
160
185
  }
161
- return { dataDir, targets, units, settings, removeData };
186
+ return { dataDir, targets, units, settings, removeData, scope: {dataDir,userDir,home,node,removeData} };
162
187
  }
163
188
  async function unchanged(item) {
164
189
  const fresh = await target(item.path, item);
165
190
  if (!fresh || fresh.dev !== item.dev || fresh.ino !== item.ino)
166
191
  throw Error("An uninstall target changed. No further files were removed.");
167
192
  }
168
- export async function removeInstallation(
193
+ async function removeConfirmedInstallation(
169
194
  plan,
170
195
  {
171
196
  manage = async (args) =>
@@ -232,6 +257,34 @@ export async function removeInstallation(
232
257
  await rm(item.path, { recursive: item.directory, force: false });
233
258
  }
234
259
  }
260
+ // The preview is not authority: a service switch can happen while the user reads
261
+ // the confirmation. Rebuild it under the same lock used by start/stop/switch.
262
+ export async function removeInstallation(plan, options = {}) {
263
+ return withServiceLifecycleLock(async () => {
264
+ const current = await uninstallPlan({...plan.scope, underLock:true});
265
+ const signature = value => JSON.stringify({
266
+ targets: value.targets, units: value.units, settings: value.settings,
267
+ });
268
+ if (signature(current) !== signature(plan))
269
+ throw Error('Installation or services changed since confirmation. Run uninstall again to review the current scope.');
270
+ const selectionPath = join(installationDirectory(), 'active-services.json');
271
+ let selection;
272
+ try {
273
+ await target(selectionPath, {privateMode:true});
274
+ selection = JSON.parse(await readFile(selectionPath, 'utf8'));
275
+ if (['preparing','stopping'].includes(selection.phase))
276
+ throw Error('A service switch is unfinished. Complete it before uninstalling.');
277
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
278
+ await removeConfirmedInstallation(current, options);
279
+ if (selection?.target?.userDir === plan.scope.userDir &&
280
+ selection?.target?.dataDir === plan.dataDir) {
281
+ // Keep an unavailable selection, rather than falling back to another Wallet.
282
+ const {atomicWritePrivate} = await import('./private-journal-files.mjs');
283
+ await atomicWritePrivate(selectionPath, JSON.stringify({...selection, phase:'stopped'}) + '\n');
284
+ }
285
+ });
286
+ }
287
+
235
288
  export async function uninstallLocal({
236
289
  ask,
237
290
  write = console.log,
@@ -256,7 +309,7 @@ export async function uninstallLocal({
256
309
  write(
257
310
  "It does not revoke Safe permissions or cancel on-chain Orders. Resolve pending Orders and revoke the Safe permission first if you intend to retire it.",
258
311
  );
259
- write("Services will stop. Removal is limited to:");
312
+ write(selected.units.length ? "Selected services will stop. Removal is limited to:" : "No managed services will be stopped. Removal is limited to:");
260
313
  for (const item of [...selected.units, ...selected.targets])
261
314
  write(` ${item.path}`);
262
315
  const confirmation = removeData ? "DELETE LOCAL AGENTPAY DATA" : "REMOVE AGENTPAY SERVICES";
@@ -293,6 +346,7 @@ export async function uninstallLocal({
293
346
  write(
294
347
  `Selected services and data removal complete. On-chain permissions remain unchanged.
295
348
  To remove a global program installation: npm uninstall --global @c4roa0/agentpay-user
349
+ For a folder-local npm installation, run npm uninstall @c4roa0/agentpay-user from its installation folder.
296
350
  For a source checkout, you may now delete the User program folder.`,
297
351
  );
298
352
  return 0;
package/src/user-cli.mjs CHANGED
@@ -289,8 +289,7 @@ export async function runUserCli(
289
289
  }
290
290
  if (command === "service") {
291
291
  if (["start", "restart"].includes(action) && !values.length) {
292
- const prepared = await run([script("autostart"), "prepare"]);
293
- return prepared || run([script("autostart"), action]);
292
+ return run([script("autostart"), action]);
294
293
  }
295
294
  if (action === "stop" && !values.length)
296
295
  return run([script("autostart"), "stop"]);
package/update-pair.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "format": "agentpay.local-update-pair.v1",
3
- "userVersion": "0.3.7",
3
+ "userVersion": "0.3.9",
4
4
  "walletVersion": "0.3.3",
5
5
  "typesDependency": "0.14.3"
6
6
  }