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