@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/src/install-user.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {withInstallationOperation, installationChildOptions} from './installation-operation.mjs';
|
|
1
2
|
import { installationDirectory, importSetupFiles } from "./installation-paths.mjs";
|
|
2
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
3
4
|
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
@@ -70,7 +71,10 @@ async function saveJson(path, value) {
|
|
|
70
71
|
await unlink(temp).catch(() => {});
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
|
-
export async function runInstall({
|
|
74
|
+
export async function runInstall(options) {
|
|
75
|
+
return withInstallationOperation(() => installUnderLock(options));
|
|
76
|
+
}
|
|
77
|
+
async function installUnderLock({
|
|
74
78
|
userDir,
|
|
75
79
|
market,
|
|
76
80
|
dataDir,
|
|
@@ -81,6 +85,7 @@ export async function runInstall({
|
|
|
81
85
|
prepareOnly = false,
|
|
82
86
|
beforePrepare,
|
|
83
87
|
afterPrepare,
|
|
88
|
+
beforeApplySettings,
|
|
84
89
|
}) {
|
|
85
90
|
userDir = resolve(userDir);
|
|
86
91
|
|
|
@@ -100,17 +105,6 @@ export async function runInstall({
|
|
|
100
105
|
const stateInfo = await lstat(stateDir);
|
|
101
106
|
if (!stateInfo.isDirectory() || stateInfo.isSymbolicLink() || stateInfo.uid !== process.getuid() || stateInfo.mode & 0o077)
|
|
102
107
|
throw Error('Installation state directory must be owner-held and private.');
|
|
103
|
-
const lockPath = join(stateDir, "install.lock");
|
|
104
|
-
let lock;
|
|
105
|
-
try {
|
|
106
|
-
lock = await open(lockPath, "wx", 0o600);
|
|
107
|
-
} catch (e) {
|
|
108
|
-
if (e.code === "EEXIST")
|
|
109
|
-
throw Error(
|
|
110
|
-
`Another installation may be running. Check ${lockPath} before removing a stale lock.`,
|
|
111
|
-
);
|
|
112
|
-
throw e;
|
|
113
|
-
}
|
|
114
108
|
const logPath = join(stateDir, `install-${Date.now()}-${randomUUID()}.log`),
|
|
115
109
|
statePath = join(stateDir, "state.json");
|
|
116
110
|
let state = { version: 1 };
|
|
@@ -162,7 +156,7 @@ export async function runInstall({
|
|
|
162
156
|
const inputs = sourceInstall ? await fingerprint(userDir, ['package.json', 'package-lock.json']) : null;
|
|
163
157
|
if (sourceInstall && (state.userDependencies !== inputs || !initial.ok)) {
|
|
164
158
|
write('1/2 Install the pinned User and Wallet dependencies. This may take a few minutes.');
|
|
165
|
-
await run('npm', ['ci'], {cwd:userDir, logPath});
|
|
159
|
+
await run('npm', ['ci', '--ignore-scripts'], {cwd:userDir, logPath});
|
|
166
160
|
state.userDependencies = inputs;
|
|
167
161
|
} else write('1/2 Check installed User and Wallet packages.');
|
|
168
162
|
const checked = await checkLocalInstallation({
|
|
@@ -175,10 +169,10 @@ export async function runInstall({
|
|
|
175
169
|
throw Error(`${failed.message}. ${failed.fix || ""}`);
|
|
176
170
|
}
|
|
177
171
|
await mkdir(privateDir, {recursive:true, mode:0o700});
|
|
178
|
-
await importSetupFiles(privateDir, userDir);
|
|
179
|
-
state.dataDir = privateDir;
|
|
180
|
-
await saveJson(statePath, state);
|
|
181
172
|
if (prepareOnly) {
|
|
173
|
+
await importSetupFiles(privateDir, userDir);
|
|
174
|
+
state.dataDir = privateDir;
|
|
175
|
+
await saveJson(statePath, state);
|
|
182
176
|
if (afterPrepare) await afterPrepare(preparationContext);
|
|
183
177
|
return { completed: true, logPath };
|
|
184
178
|
}
|
|
@@ -220,7 +214,16 @@ export async function runInstall({
|
|
|
220
214
|
"./prepare-onboarding.mjs"
|
|
221
215
|
);
|
|
222
216
|
const config = validatePublicDeployment(JSON.parse(raw));
|
|
217
|
+
// Keep downloaded settings in memory until the serialized service switch is
|
|
218
|
+
// approved. A cancelled switch must not alter the other installation's data.
|
|
219
|
+
if (beforeApplySettings) {
|
|
220
|
+
const code = await beforeApplySettings();
|
|
221
|
+
if (code) return {completed:false,cancelled:true,exitCode:code,logPath};
|
|
222
|
+
}
|
|
223
|
+
await importSetupFiles(privateDir, userDir);
|
|
223
224
|
await saveJson(join(privateDir, "user-deployment.json"), config);
|
|
225
|
+
state.dataDir = privateDir;
|
|
226
|
+
await saveJson(statePath, state);
|
|
224
227
|
write(
|
|
225
228
|
"User, Wallet and Market settings are ready. Continuing to setup.",
|
|
226
229
|
);
|
|
@@ -235,9 +238,6 @@ export async function runInstall({
|
|
|
235
238
|
error.messageDisplayed = true;
|
|
236
239
|
error.logPath = logPath;
|
|
237
240
|
throw error;
|
|
238
|
-
} finally {
|
|
239
|
-
await lock.close();
|
|
240
|
-
await unlink(lockPath);
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
243
|
async function runCommand(command, args, { cwd, logPath }) {
|
|
@@ -246,7 +246,7 @@ async function runCommand(command, args, { cwd, logPath }) {
|
|
|
246
246
|
await new Promise((resolve, reject) => {
|
|
247
247
|
const child = spawn(command, args, {
|
|
248
248
|
cwd,
|
|
249
|
-
env: {
|
|
249
|
+
...installationChildOptions({env: {
|
|
250
250
|
...process.env,
|
|
251
251
|
PATH:
|
|
252
252
|
dirname(process.execPath) +
|
|
@@ -254,7 +254,7 @@ async function runCommand(command, args, { cwd, logPath }) {
|
|
|
254
254
|
(process.env.PATH || ""),
|
|
255
255
|
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
|
|
256
256
|
},
|
|
257
|
-
stdio: ["ignore", log.fd, log.fd],
|
|
257
|
+
stdio: ["ignore", log.fd, log.fd]}),
|
|
258
258
|
});
|
|
259
259
|
child.once("error", (e) =>
|
|
260
260
|
reject(
|
|
@@ -263,7 +263,7 @@ async function runCommand(command, args, { cwd, logPath }) {
|
|
|
263
263
|
),
|
|
264
264
|
),
|
|
265
265
|
);
|
|
266
|
-
child.once("
|
|
266
|
+
child.once("close", (code) =>
|
|
267
267
|
code === 0
|
|
268
268
|
? resolve()
|
|
269
269
|
: reject(
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
// Installation routing is local metadata, never Wallet or payment identity.
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { open, realpath, mkdir, link, unlink, lstat } from 'node:fs/promises';
|
|
5
|
+
import { join, resolve, isAbsolute } from 'node:path';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { installationDirectory } from './installation-paths.mjs';
|
|
8
|
+
import { atomicWritePrivate } from './private-journal-files.mjs';
|
|
9
|
+
|
|
10
|
+
async function location(userDir) {
|
|
11
|
+
const root = await realpath(userDir);
|
|
12
|
+
const directory = join(installationDirectory(), 'bindings');
|
|
13
|
+
const key = createHash('sha256').update(root).digest('hex');
|
|
14
|
+
return {root, directory, path: join(directory, key + '.json')};
|
|
15
|
+
}
|
|
16
|
+
export async function readInstallationBinding(userDir) {
|
|
17
|
+
let place;
|
|
18
|
+
try { place = await location(userDir); }
|
|
19
|
+
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
20
|
+
let handle;
|
|
21
|
+
try {
|
|
22
|
+
const directory = await lstat(place.directory);
|
|
23
|
+
if (!directory.isDirectory() || directory.isSymbolicLink() || directory.uid !== process.getuid() || directory.mode & 0o077)
|
|
24
|
+
throw Error('Installation bindings directory must be private and owned by you.');
|
|
25
|
+
handle = await open(place.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
26
|
+
const info = await handle.stat();
|
|
27
|
+
if (!info.isFile() || info.nlink !== 1 || info.uid !== process.getuid() || info.mode & 0o077 || info.size > 16384)
|
|
28
|
+
throw Error('Installation binding must be a private file owned by you.');
|
|
29
|
+
const value = JSON.parse(await handle.readFile('utf8'));
|
|
30
|
+
if (value.version !== 1 || value.installation !== place.root || typeof value.dataDir !== 'string' || !isAbsolute(value.dataDir) || value.dataDir.includes('\0'))
|
|
31
|
+
throw Error('Installation binding is invalid. Keep it for review.');
|
|
32
|
+
return value;
|
|
33
|
+
} catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
|
34
|
+
finally { await handle?.close(); }
|
|
35
|
+
}
|
|
36
|
+
export async function rememberInstallationBinding(userDir, dataDir) {
|
|
37
|
+
const place = await location(userDir);
|
|
38
|
+
// Missing data is valid before setup. Existing paths use their canonical location.
|
|
39
|
+
const selected = await realpath(resolve(dataDir)).catch(e => {
|
|
40
|
+
if (e.code === 'ENOENT') return resolve(dataDir);
|
|
41
|
+
throw e;
|
|
42
|
+
});
|
|
43
|
+
const saved = await readInstallationBinding(userDir);
|
|
44
|
+
if (saved) {
|
|
45
|
+
if (saved.dataDir !== selected) throw Error('This installation uses another data folder. Explicit adoption is required before changing it.');
|
|
46
|
+
return saved;
|
|
47
|
+
}
|
|
48
|
+
await mkdir(place.directory, {recursive:true, mode:0o700});
|
|
49
|
+
const value = {version:1, installation:place.root, dataDir:selected};
|
|
50
|
+
const temporary = join(place.directory, '.binding-' + randomUUID());
|
|
51
|
+
try {
|
|
52
|
+
await atomicWritePrivate(temporary, JSON.stringify(value) + '\n');
|
|
53
|
+
// Publish without replacing a concurrently established binding.
|
|
54
|
+
try { await link(temporary, place.path); }
|
|
55
|
+
catch (e) { if (e.code !== 'EEXIST') throw e; }
|
|
56
|
+
} finally { await unlink(temporary).catch(e => { if (e.code !== 'ENOENT') throw e; }); }
|
|
57
|
+
const actual = await readInstallationBinding(userDir);
|
|
58
|
+
if (actual.dataDir !== selected) throw Error('Another command selected a different data folder. No binding was replaced.');
|
|
59
|
+
return actual;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function installationBindingPath(userDir) { return (await location(userDir)).path; }
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
// Serialize installation writes and managed-service changes. Descriptor locks are
|
|
3
|
+
// inherited by npm/repair children: killing the caller does not unlock a live writer.
|
|
4
|
+
import {AsyncLocalStorage} from 'node:async_hooks';
|
|
5
|
+
import {constants, fstatSync} from 'node:fs';
|
|
6
|
+
import {open, mkdir, lstat, unlink, link} from 'node:fs/promises';
|
|
7
|
+
import {join} from 'node:path';
|
|
8
|
+
import {randomUUID} from 'node:crypto';
|
|
9
|
+
import {ensureInstallationDirectory} from './installation-paths.mjs';
|
|
10
|
+
|
|
11
|
+
const context = new AsyncLocalStorage();
|
|
12
|
+
export const installationLockMarker = 'agentpay.install-lock.v2\n';
|
|
13
|
+
const marker = installationLockMarker;
|
|
14
|
+
const fdVariable = 'AGENTPAY_INSTALLATION_LOCK_FD';
|
|
15
|
+
const busy = () => Error('Another AgentPay installation or service change is running. Wait for it to finish, then retry.');
|
|
16
|
+
function checkFile(info, allowLinks = false) {
|
|
17
|
+
if (!info.isFile() || (!allowLinks && info.nlink !== 1) || info.uid !== process.getuid() || info.mode & 0o077)
|
|
18
|
+
throw Error('Installation lock must be a private regular file owned by you.');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function withInstallationOperation(operation) {
|
|
22
|
+
const directory = await ensureInstallationDirectory();
|
|
23
|
+
const existing = context.getStore();
|
|
24
|
+
if (existing?.directory === directory) return operation();
|
|
25
|
+
await mkdir(directory,{recursive:true,mode:0o700});
|
|
26
|
+
const info = await lstat(directory);
|
|
27
|
+
if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid() || info.mode & 0o077)
|
|
28
|
+
throw Error('Installation state directory must be private and owned by you.');
|
|
29
|
+
let tryLock;
|
|
30
|
+
try { ({tryLock} = await import('fs-native-extensions')); }
|
|
31
|
+
catch { throw Error('Installation tools are missing. Reinstall the User npm package, or run npm ci in your User source folder, then retry.'); }
|
|
32
|
+
const path = join(directory,'service-selection.lock');
|
|
33
|
+
const file = await open(path,constants.O_CREAT | constants.O_RDWR | constants.O_NOFOLLOW,0o600);
|
|
34
|
+
let ownsMarker = false;
|
|
35
|
+
try {
|
|
36
|
+
const stat = await file.stat(); checkFile(stat);
|
|
37
|
+
const inherited = process.env[fdVariable];
|
|
38
|
+
if (inherited !== undefined) {
|
|
39
|
+
if (!/^\d+$/.test(inherited) || Number(inherited) < 3) throw Error('Invalid installation lock handoff.');
|
|
40
|
+
const fd = Number(inherited), held = fstatSync(fd); checkFile(held);
|
|
41
|
+
if (held.dev !== stat.dev || held.ino !== stat.ino || !tryLock(fd)) throw Error('Invalid installation lock handoff.');
|
|
42
|
+
// A separately opened descriptor must be excluded, proving the inherited
|
|
43
|
+
// descriptor really protects this file; an environment flag alone is not a bypass.
|
|
44
|
+
if (tryLock(file.fd)) throw Error('Installation lock handoff did not preserve exclusion.');
|
|
45
|
+
return await context.run({directory,fd},operation);
|
|
46
|
+
}
|
|
47
|
+
if (!tryLock(file.fd)) throw busy();
|
|
48
|
+
// Preserve interoperability with older installers. Only our recognizable v2
|
|
49
|
+
// marker can be reclaimed after acquiring the native lock. Unknown/empty old
|
|
50
|
+
// markers need operator review, never PID-age based deletion.
|
|
51
|
+
const legacyPath = join(directory,'install.lock');
|
|
52
|
+
const temporary = join(directory, 'install-marker-' + randomUUID());
|
|
53
|
+
let legacy;
|
|
54
|
+
try {
|
|
55
|
+
legacy = await open(temporary,'wx',0o600);
|
|
56
|
+
await legacy.writeFile(marker); await legacy.sync(); await legacy.close(); legacy=undefined;
|
|
57
|
+
await link(temporary,legacyPath);
|
|
58
|
+
} catch(error) {
|
|
59
|
+
await legacy?.close(); legacy = undefined;
|
|
60
|
+
if (error.code !== 'EEXIST') throw error;
|
|
61
|
+
legacy = await open(legacyPath,constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
62
|
+
// A crash between link publication and temporary-name cleanup can leave
|
|
63
|
+
// two names for this marker. We only read/unlink it; the actual native
|
|
64
|
+
// lock file still requires exactly one link and provides exclusion.
|
|
65
|
+
const saved = await legacy.stat(); checkFile(saved, true);
|
|
66
|
+
if (saved.size !== Buffer.byteLength(marker) || await legacy.readFile('utf8') !== marker)
|
|
67
|
+
throw Error('An older installer lock exists. Keep it for review; automatic recovery cannot confirm whether its writer has stopped.');
|
|
68
|
+
} finally {
|
|
69
|
+
await legacy?.close();
|
|
70
|
+
await unlink(temporary).catch(error=>{if(error.code!=='ENOENT')throw error;});
|
|
71
|
+
}
|
|
72
|
+
ownsMarker = true;
|
|
73
|
+
return await context.run({directory,fd:file.fd},operation);
|
|
74
|
+
} finally {
|
|
75
|
+
// Do not unlock explicitly: inherited children may still hold the same open
|
|
76
|
+
// description. Production command runners wait for child close before returning.
|
|
77
|
+
try {
|
|
78
|
+
if (ownsMarker) await unlink(join(directory,'install.lock')).catch(e=>{if(e.code!=='ENOENT')throw e;});
|
|
79
|
+
} finally { await file.close(); }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Only short-lived administration/writer children inherit the lock. Never pass it
|
|
84
|
+
// to the Wallet/Runtime daemons, which would keep maintenance blocked indefinitely.
|
|
85
|
+
export function installationChildOptions({env = process.env, stdio = ['ignore','pipe','pipe']} = {}) {
|
|
86
|
+
const held = context.getStore();
|
|
87
|
+
const clean = {...env}; delete clean[fdVariable];
|
|
88
|
+
if (!held) return {env:clean,stdio};
|
|
89
|
+
return {env:{...clean,[fdVariable]:String(stdio.length)},stdio:[...stdio,held.fd]};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Wait for close even on timeout/error: returning on a timer while npm still writes
|
|
93
|
+
// would release the caller's lock too early. Writer children retain the descriptor.
|
|
94
|
+
export async function runInstallationCommand(file, args, {env = process.env, timeout = 120000, maxBuffer = 2 * 1024 * 1024, cwd} = {}) {
|
|
95
|
+
const {spawn} = await import('node:child_process');
|
|
96
|
+
return new Promise((resolve,reject) => {
|
|
97
|
+
const child = spawn(file,args,{cwd,...installationChildOptions({env})});
|
|
98
|
+
let stdout='',stderr='',failure,killTimer;
|
|
99
|
+
const stop = error => {
|
|
100
|
+
failure ||= error;
|
|
101
|
+
child.kill('SIGTERM');
|
|
102
|
+
killTimer ||= setTimeout(()=>child.kill('SIGKILL'),1000);
|
|
103
|
+
};
|
|
104
|
+
const timer = setTimeout(()=>stop(Error('Command timed out.')),timeout);
|
|
105
|
+
child.stdout.on('data',chunk=>{if(failure)return;stdout+=chunk;if(Buffer.byteLength(stdout)>maxBuffer)stop(Error('Command output exceeded its limit.'));});
|
|
106
|
+
child.stderr.on('data',chunk=>{if(failure)return;stderr+=chunk;if(Buffer.byteLength(stderr)>maxBuffer)stop(Error('Command output exceeded its limit.'));});
|
|
107
|
+
child.once('error',error=>{failure=error;});
|
|
108
|
+
child.once('close',(code,signal)=>{
|
|
109
|
+
clearTimeout(timer); clearTimeout(killTimer);
|
|
110
|
+
if(failure || code!==0) reject(Object.assign(failure || Error(`Command failed (${signal || code}).`),{stdout,stderr}));
|
|
111
|
+
else resolve({stdout,stderr});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -3,12 +3,61 @@
|
|
|
3
3
|
// per-user state storage; explicit data-directory settings still select key/history data.
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { isAbsolute, join, dirname } from 'node:path';
|
|
6
|
-
import { lstat, mkdir, copyFile, constants } from 'node:fs/promises';
|
|
6
|
+
import { lstat, mkdir, copyFile, constants, link, unlink, realpath } from 'node:fs/promises';
|
|
7
|
+
import {openSync,closeSync,fstatSync,readFileSync,lstatSync} from 'node:fs';
|
|
8
|
+
import {randomUUID} from 'node:crypto';
|
|
9
|
+
import {atomicWritePrivate} from './private-journal-files.mjs';
|
|
10
|
+
export function stateLocationFile() {
|
|
11
|
+
return join(homedir(),'.local/state/agentpay/state-location.json');
|
|
12
|
+
}
|
|
7
13
|
export function installationDirectory() {
|
|
14
|
+
// A fixed, non-secret locator makes login services and Skills independent of
|
|
15
|
+
// shell-only XDG settings. XDG chooses the initial location, not a second instance.
|
|
16
|
+
let handle;
|
|
17
|
+
try {
|
|
18
|
+
const path=stateLocationFile(),parent=lstatSync(dirname(path));
|
|
19
|
+
if(!parent.isDirectory() || parent.isSymbolicLink() || parent.uid!==process.getuid() || parent.mode & 0o022)
|
|
20
|
+
throw Error('AgentPay state locator directory needs ownership review.');
|
|
21
|
+
handle=openSync(path,constants.O_RDONLY|constants.O_NOFOLLOW);
|
|
22
|
+
const st=fstatSync(handle);
|
|
23
|
+
if(!st.isFile() || st.uid!==process.getuid() || st.mode & 0o077 || st.size>16384)
|
|
24
|
+
throw Error('AgentPay state locator must be private and owned by you.');
|
|
25
|
+
const saved=JSON.parse(readFileSync(handle,'utf8'));
|
|
26
|
+
if(saved.version!==1 || typeof saved.directory!=='string' || !isAbsolute(saved.directory) || /[\x00-\x1f\x7f]/.test(saved.directory))
|
|
27
|
+
throw Error('Invalid AgentPay state locator. Preserve it for review.');
|
|
28
|
+
return saved.directory;
|
|
29
|
+
} catch(error) {if(error.code!=='ENOENT')throw error;}
|
|
30
|
+
finally {if(handle!==undefined)closeSync(handle);}
|
|
8
31
|
const base = process.env.XDG_STATE_HOME || join(homedir(), '.local/state');
|
|
9
32
|
if (!isAbsolute(base)) throw Error('XDG_STATE_HOME must be an absolute path.');
|
|
10
33
|
return join(base, 'agentpay/user');
|
|
11
34
|
}
|
|
35
|
+
export async function ensureInstallationDirectory() {
|
|
36
|
+
const requested=installationDirectory();
|
|
37
|
+
await mkdir(requested,{recursive:true,mode:0o700});
|
|
38
|
+
const info=await lstat(requested);
|
|
39
|
+
if(!info.isDirectory() || info.isSymbolicLink() || info.uid!==process.getuid() || info.mode & 0o077)
|
|
40
|
+
throw Error('Installation state directory must be private and owned by you.');
|
|
41
|
+
const directory=await realpath(requested),path=stateLocationFile();
|
|
42
|
+
try {
|
|
43
|
+
await lstat(path);
|
|
44
|
+
if(installationDirectory()!==directory)
|
|
45
|
+
throw Error('Another command selected the AgentPay state directory. Retry using its saved location.');
|
|
46
|
+
return directory;
|
|
47
|
+
} catch(error) { if(error.code!=='ENOENT')throw error; }
|
|
48
|
+
await mkdir(dirname(path),{recursive:true,mode:0o700});
|
|
49
|
+
const parent=await lstat(dirname(path));
|
|
50
|
+
if(!parent.isDirectory() || parent.isSymbolicLink() || parent.uid!==process.getuid() || parent.mode & 0o022)
|
|
51
|
+
throw Error('AgentPay state locator directory needs ownership review.');
|
|
52
|
+
const temporary=path+'.'+randomUUID();
|
|
53
|
+
try {
|
|
54
|
+
await atomicWritePrivate(temporary,JSON.stringify({version:1,directory})+'\n');
|
|
55
|
+
try {await link(temporary,path);}catch(error){if(error.code!=='EEXIST')throw error;}
|
|
56
|
+
} finally {await unlink(temporary).catch(error=>{if(error.code!=='ENOENT')throw error;});}
|
|
57
|
+
if(installationDirectory()!==directory)
|
|
58
|
+
throw Error('Another command selected the AgentPay state directory. Retry using its saved location.');
|
|
59
|
+
return directory;
|
|
60
|
+
}
|
|
12
61
|
export function legacyInstallationDirectory(userDir) { return join(dirname(userDir), '.agentpay-install'); }
|
|
13
62
|
export async function deploymentFilePath(dataDir, userDir, name) {
|
|
14
63
|
if (!['anvil-onboarding.json', 'user-deployment.json'].includes(name)) throw Error('Invalid setup filename.');
|
package/src/invocation-store.mjs
CHANGED
|
@@ -367,7 +367,7 @@ export function createInvocationStore(path, serviceBinding = null) {
|
|
|
367
367
|
});
|
|
368
368
|
}
|
|
369
369
|
|
|
370
|
-
function hashInvocationKey(value) {
|
|
370
|
+
export function hashInvocationKey(value) {
|
|
371
371
|
const normalized = String(value || "").trim();
|
|
372
372
|
if (!/^[A-Za-z0-9._:-]{8,128}$/.test(normalized))
|
|
373
373
|
throw coded(
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import {withInstallationOperation} from './installation-operation.mjs';
|
|
2
|
+
import { readInstallationBinding, rememberInstallationBinding } from "./installation-binding.mjs";
|
|
1
3
|
import { installationDirectory, legacyInstallationDirectory } from "./installation-paths.mjs";
|
|
2
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
3
5
|
// This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0.
|
|
4
6
|
// If a copy was not distributed with this file, see https://mozilla.org/MPL/2.0/.
|
|
5
7
|
import { constants } from "node:fs";
|
|
6
|
-
import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
8
|
+
import { lstat, mkdir, open, rename, unlink, realpath } from "node:fs/promises";
|
|
7
9
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
10
|
import { homedir } from "node:os";
|
|
9
11
|
import { fileURLToPath } from "node:url";
|
|
@@ -65,13 +67,23 @@ export async function resolveUserDataDir({
|
|
|
65
67
|
env = process.env,
|
|
66
68
|
userDir = installedUserDir,
|
|
67
69
|
} = {}) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
const binding = await readInstallationBinding(userDir);
|
|
71
|
+
if (argument || env.AGENTPAY_USER_DATA_DIR) {
|
|
72
|
+
const selected = resolve(argument || env.AGENTPAY_USER_DATA_DIR);
|
|
73
|
+
const canonical = await realpath(selected).catch(e => { if (e.code === 'ENOENT') return selected; throw e; });
|
|
74
|
+
if (binding && canonical !== binding.dataDir)
|
|
75
|
+
throw Error('This installation is bound to another data folder. Use its configured folder; explicit adoption is required to change it.');
|
|
76
|
+
return selected;
|
|
77
|
+
}
|
|
78
|
+
if (binding) return binding.dataDir;
|
|
70
79
|
const state = await readState(userDir);
|
|
71
80
|
return state.dataDir || join(homedir(), ".local/share/agentpay/user");
|
|
72
81
|
}
|
|
73
82
|
// Uses the existing installer lock and preserves dependency/build checkpoints.
|
|
74
|
-
export async function rememberUserDataDir({
|
|
83
|
+
export async function rememberUserDataDir(options) {
|
|
84
|
+
return withInstallationOperation(() => rememberUnderLock(options));
|
|
85
|
+
}
|
|
86
|
+
async function rememberUnderLock({
|
|
75
87
|
userDir = installedUserDir,
|
|
76
88
|
dataDir,
|
|
77
89
|
}) {
|
|
@@ -84,21 +96,11 @@ export async function rememberUserDataDir({
|
|
|
84
96
|
throw Error(
|
|
85
97
|
"Installation record directory must be private and owned by you.",
|
|
86
98
|
);
|
|
87
|
-
const lockPath = join(directory, "install.lock");
|
|
88
|
-
let lock;
|
|
89
|
-
try {
|
|
90
|
-
lock = await open(lockPath, "wx", 0o600);
|
|
91
|
-
} catch (error) {
|
|
92
|
-
if (error.code === "EEXIST")
|
|
93
|
-
throw Error(
|
|
94
|
-
"Another installation may be running. Retry after it finishes.",
|
|
95
|
-
);
|
|
96
|
-
throw error;
|
|
97
|
-
}
|
|
98
99
|
const temp = join(directory, `location-${randomUUID()}.tmp`);
|
|
99
100
|
let file;
|
|
100
101
|
try {
|
|
101
102
|
const state = await readState(userDir);
|
|
103
|
+
await rememberInstallationBinding(userDir, dataDir);
|
|
102
104
|
file = await open(temp, "wx", 0o600);
|
|
103
105
|
await file.writeFile(
|
|
104
106
|
JSON.stringify({ ...state, version: 1, dataDir: resolve(dataDir) }) +
|
|
@@ -111,7 +113,5 @@ export async function rememberUserDataDir({
|
|
|
111
113
|
} finally {
|
|
112
114
|
await file?.close();
|
|
113
115
|
await unlink(temp).catch(() => {});
|
|
114
|
-
await lock.close();
|
|
115
|
-
await unlink(lockPath);
|
|
116
116
|
}
|
|
117
117
|
}
|
package/src/local-update.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {withInstallationOperation, installationChildOptions} from './installation-operation.mjs';
|
|
2
|
+
import {maintenanceServiceScope} from './service-selection.mjs';
|
|
1
3
|
import { requireRunningSoftware } from "./software-versions.mjs";
|
|
2
4
|
import { walletPackage } from "./wallet-package.mjs";
|
|
3
5
|
// Apply installed packages. Preserve identity/history and startup preferences;
|
|
@@ -112,12 +114,12 @@ async function manage(action, { userDir, dataDir, logPath }) {
|
|
|
112
114
|
[join(userDir, "scripts/autostart.mjs"), action],
|
|
113
115
|
{
|
|
114
116
|
shell: false,
|
|
115
|
-
env: { ...process.env, AGENTPAY_USER_DATA_DIR: dataDir },
|
|
116
|
-
stdio: ["ignore", file.fd, file.fd],
|
|
117
|
+
...installationChildOptions({env: { ...process.env, AGENTPAY_USER_DATA_DIR: dataDir },
|
|
118
|
+
stdio: ["ignore", file.fd, file.fd]}),
|
|
117
119
|
},
|
|
118
120
|
);
|
|
119
121
|
child.once("error", reject);
|
|
120
|
-
child.once("
|
|
122
|
+
child.once("close", (code) =>
|
|
121
123
|
code === 0
|
|
122
124
|
? done()
|
|
123
125
|
: reject(Error("Background service operation failed.")),
|
|
@@ -127,7 +129,11 @@ async function manage(action, { userDir, dataDir, logPath }) {
|
|
|
127
129
|
await file.close();
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
|
-
export async function applyLocalUpdate({
|
|
132
|
+
export async function applyLocalUpdate(options = {}) {
|
|
133
|
+
try { return await withInstallationOperation(() => repairUnderLock(options)); }
|
|
134
|
+
catch(error) { (options.write || console.log)("Repair stopped. " + error.message); return 1; }
|
|
135
|
+
}
|
|
136
|
+
async function repairUnderLock({
|
|
131
137
|
userDir = installedUserDir,
|
|
132
138
|
dataDir,
|
|
133
139
|
write = console.log,
|
|
@@ -143,13 +149,15 @@ export async function applyLocalUpdate({
|
|
|
143
149
|
connection = ensureRuntimeConnection,
|
|
144
150
|
prepare = runInstall,
|
|
145
151
|
service = manage,
|
|
152
|
+
serviceScope = maintenanceServiceScope,
|
|
146
153
|
wallet = localWalletAdmin,
|
|
147
154
|
runtime = readRuntimeStatus,
|
|
148
155
|
pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
149
156
|
} = {}) {
|
|
150
157
|
let phase = "preflight",
|
|
151
158
|
logPath,
|
|
152
|
-
selected
|
|
159
|
+
selected,
|
|
160
|
+
activeServices = true;
|
|
153
161
|
try {
|
|
154
162
|
write(
|
|
155
163
|
"\n------------------------------------------------------------\n> Repair User and Wallet\n------------------------------------------------------------",
|
|
@@ -165,6 +173,8 @@ export async function applyLocalUpdate({
|
|
|
165
173
|
const pair = await pairCheck(userDir);
|
|
166
174
|
await checkDirectory(dataDir);
|
|
167
175
|
await settings(dataDir);
|
|
176
|
+
const scope = await serviceScope({userDir,dataDir});
|
|
177
|
+
activeServices = scope.active;
|
|
168
178
|
write(
|
|
169
179
|
`1/3 Check local files · User ${pair.userVersion} / Wallet ${pair.walletVersion}`,
|
|
170
180
|
);
|
|
@@ -186,12 +196,18 @@ export async function applyLocalUpdate({
|
|
|
186
196
|
applied.inputs === selected.inputs &&
|
|
187
197
|
applied.outputs === selected.outputs
|
|
188
198
|
) {
|
|
199
|
+
if (!scope.active) {
|
|
200
|
+
await connection({dataDir,write,refreshClientModule:true});
|
|
201
|
+
phase = "unchanged";
|
|
202
|
+
write("This installation is up to date and remains inactive. Running services were not changed.");
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
189
205
|
let runningMatches = false;
|
|
190
206
|
try {
|
|
191
207
|
requireRunningSoftware(await wallet(dataDir, "status"), await runtime(dataDir), { ...pair, protocolVersion: pair.typesDependency });
|
|
192
208
|
runningMatches = true;
|
|
193
209
|
} catch { /* Keep repair available for old, stopped or unreported processes. */ }
|
|
194
|
-
if (runningMatches) {
|
|
210
|
+
if (runningMatches && scope.active) {
|
|
195
211
|
await connection({ dataDir, write });
|
|
196
212
|
phase = "unchanged";
|
|
197
213
|
write("Local installation and running versions are up to date. Background services were not changed.");
|
|
@@ -205,7 +221,8 @@ export async function applyLocalUpdate({
|
|
|
205
221
|
: "Installed program changes need to be applied.",
|
|
206
222
|
);
|
|
207
223
|
write(
|
|
208
|
-
"Wallet and Runtime will pause, verify installed files and restart. Wallet will need unlocking afterwards."
|
|
224
|
+
scope.active ? "Wallet and Runtime will pause, verify installed files and restart. Wallet will need unlocking afterwards."
|
|
225
|
+
: "This inactive installation will be repaired. The running Wallet and Runtime will not change.",
|
|
209
226
|
);
|
|
210
227
|
if (!force) {
|
|
211
228
|
while (true) {
|
|
@@ -234,6 +251,11 @@ export async function applyLocalUpdate({
|
|
|
234
251
|
inputs: selected.inputs,
|
|
235
252
|
};
|
|
236
253
|
await context.saveState();
|
|
254
|
+
if (!scope.active) {
|
|
255
|
+
phase = "building";
|
|
256
|
+
write("2/3 Check this installation's packages. Active services are unchanged.");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
237
259
|
await service("prepare-repair", { userDir, dataDir, logPath });
|
|
238
260
|
phase = "stopping";
|
|
239
261
|
write(
|
|
@@ -248,6 +270,13 @@ export async function applyLocalUpdate({
|
|
|
248
270
|
if (built.inputs !== selected.inputs)
|
|
249
271
|
throw Error("Installed files changed during repair.");
|
|
250
272
|
await connection({ dataDir, write, refreshClientModule: true });
|
|
273
|
+
if (!scope.active) {
|
|
274
|
+
context.state.localUpdate = {status:"complete", ...built};
|
|
275
|
+
await context.saveState();
|
|
276
|
+
phase = "ready";
|
|
277
|
+
write("3/3 Inactive installation checked. It has not been started.");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
251
280
|
phase = "starting";
|
|
252
281
|
write("3/3 Start Wallet and Runtime.");
|
|
253
282
|
await service("start", { userDir, dataDir, logPath });
|
|
@@ -288,20 +317,22 @@ export async function applyLocalUpdate({
|
|
|
288
317
|
if (!result?.completed || phase !== "ready")
|
|
289
318
|
throw Error("Repair did not finish.");
|
|
290
319
|
write(
|
|
291
|
-
"\nRepair complete. Wallet and Runtime are running in the background."
|
|
320
|
+
scope.active ? "\nRepair complete. Wallet and Runtime are running in the background."
|
|
321
|
+
: "\nRepair complete. This installation remains inactive; existing services were kept.",
|
|
292
322
|
);
|
|
293
323
|
write(
|
|
294
324
|
"Keys, spending permission and Order records were kept. Login startup preference is unchanged.",
|
|
295
325
|
);
|
|
296
326
|
await officialTest({ userDir, dataDir, write });
|
|
297
|
-
write("Next: agentpay wallet unlock\nThen: agentpay status");
|
|
327
|
+
write(scope.active ? "Next: agentpay wallet unlock\nThen: agentpay status" : "To switch to this installation: agentpay service start");
|
|
298
328
|
return 0;
|
|
299
329
|
} catch (error) {
|
|
300
330
|
if (phase === "preflight")
|
|
301
331
|
write("Repair stopped before pausing services. " + error.message);
|
|
302
332
|
else if (phase === "building")
|
|
303
333
|
write(
|
|
304
|
-
"Repair could not finish. Wallet and Runtime remain stopped; your local data was kept."
|
|
334
|
+
activeServices ? "Repair could not finish. Wallet and Runtime remain stopped; your local data was kept."
|
|
335
|
+
: "Repair could not finish. The active installation was not changed; your local data was kept.",
|
|
305
336
|
);
|
|
306
337
|
else
|
|
307
338
|
write(
|
package/src/local-wallet-ipc.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { open } from "node:fs/promises";
|
|
|
7
7
|
import { createConnection } from "node:net";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { randomBytes } from "node:crypto";
|
|
10
|
-
export async function localWalletAdmin(dataDir, method, params = {}) {
|
|
10
|
+
export async function localWalletAdmin(dataDir, method, params = {}, {timeoutMs = 30000} = {}) {
|
|
11
11
|
await checkServiceDirectory(dataDir);
|
|
12
12
|
const h = await open(
|
|
13
13
|
join(dataDir, "wallet/admin.token"),
|
|
@@ -40,7 +40,7 @@ export async function localWalletAdmin(dataDir, method, params = {}) {
|
|
|
40
40
|
};
|
|
41
41
|
const timer = setTimeout(
|
|
42
42
|
() => finish(Error("Wallet did not respond. Run agentpay status.")),
|
|
43
|
-
|
|
43
|
+
timeoutMs,
|
|
44
44
|
);
|
|
45
45
|
socket.setEncoding("utf8");
|
|
46
46
|
socket.once("error", (e) =>
|