@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.
- package/README.md +3 -1
- package/doc/README.md +10 -0
- package/doc/TODO.md +43 -3
- package/doc/plans/local-instances-and-install-recovery.md +250 -0
- package/doc/records/client-isolated-acceptance-2026-11-11.md +37 -0
- package/doc/records/dual-install-systemd-2026-11-12.md +40 -0
- package/doc/records/historical-request-routing-2026-11-11.md +47 -0
- package/doc/records/installation-maintenance-2026-11-11.md +46 -0
- package/doc/reference/local-update.md +2 -1
- package/doc/runbooks/commands.md +28 -0
- package/doc/runbooks/local-update.md +11 -5
- package/doc/usage.md +8 -0
- package/package.json +1 -1
- package/progress.md +114 -6
- package/scripts/autostart.mjs +51 -25
- package/scripts/install-user.mjs +10 -3
- package/scripts/local-service.mjs +1 -1
- package/scripts/user-setup.mjs +4 -0
- package/src/default-request-owner.mjs +163 -0
- package/src/index.mjs +9 -0
- package/src/install-and-setup.mjs +6 -1
- package/src/install-selection.mjs +41 -0
- package/src/install-user.mjs +22 -22
- package/src/installation-binding.mjs +62 -0
- package/src/installation-operation.mjs +114 -0
- package/src/installation-paths.mjs +50 -1
- package/src/invocation-store.mjs +1 -1
- package/src/local-data-directory.mjs +17 -17
- package/src/local-update.mjs +41 -10
- package/src/local-wallet-ipc.mjs +2 -2
- package/src/npm-update.mjs +66 -23
- package/src/official-test-update.mjs +13 -0
- package/src/platform-endpoints.mjs +5 -8
- package/src/runtime-client.mjs +21 -4
- package/src/runtime-connection-settings.mjs +2 -1
- package/src/runtime-owner.mjs +22 -0
- package/src/runtime-selection-ready.mjs +29 -0
- package/src/service-readiness.mjs +29 -0
- package/src/service-selection.mjs +189 -0
- package/src/setup-autostart.mjs +12 -2
- package/src/setup-service-selection.mjs +25 -0
- package/src/skill-bundle.mjs +38 -0
- package/src/skill-client.mjs +8 -3
- package/src/uninstall-local.mjs +69 -15
- package/src/user-cli.mjs +1 -2
- 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
|
+
}
|
package/src/skill-bundle.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/skill-client.mjs
CHANGED
|
@@ -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
|
-
|
|
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 } : {}),
|
package/src/uninstall-local.mjs
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
89
|
-
|
|
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({ ...
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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"]);
|