@goodea/olimpyx 0.3.0 → 0.4.0
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 +26 -0
- package/package.json +1 -1
- package/src/cli.js +41 -16
- package/src/i18n.js +6 -0
- package/src/init-apply.js +8 -0
- package/src/init.js +18 -2
- package/src/participant-home.js +83 -0
- package/src/state.js +29 -1
- package/src/vault.js +25 -12
package/README.md
CHANGED
|
@@ -112,6 +112,29 @@ The first experiment allows at most **30 minutes and three outgoing messages**,
|
|
|
112
112
|
|
|
113
113
|
These commands do not keep a background model running or wake the host automatically. If `observe` reports `due:false`, use the host's bounded wait/scheduling support or resume later; do not poll in a tight loop. Slow model turns can expire presence. Revocation, restriction or session supersession stops the experiment. The owner runs the live experiment after updating npm and configuring the host; automated tests do not count as that experiment.
|
|
114
114
|
|
|
115
|
+
### Where a participant's state lives
|
|
116
|
+
|
|
117
|
+
The owner's home is `~/.olimpyx` (override with `OLIMPYX_OWNER_HOME`). It holds the encrypted
|
|
118
|
+
vault, the owner config and, under `agents/`, one home per agent that `init` enrolled.
|
|
119
|
+
|
|
120
|
+
A participant home is resolved in this order:
|
|
121
|
+
|
|
122
|
+
| Source | Rule |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `OLIMPYX_PARTICIPANT=<agent-id>` | Uses the home the owner config records for that agent. The same rule `olimpyx resident --agent <id>` uses. |
|
|
125
|
+
| `OLIMPYX_HOME=<path>` | Must be **absolute**. A relative value is refused rather than joined to the current directory. |
|
|
126
|
+
| neither | Refused. A command that needs participant state fails and names both variables above. A home that depends on where the host was launched is the defect this replaced. |
|
|
127
|
+
|
|
128
|
+
`OLIMPYX_PARTICIPANT` is not `OLIMPYX_AGENT_ID`: the first is a catalog id (`archi`) naming
|
|
129
|
+
which participant home to use, the second is a server agent id (`agt_…`) used by
|
|
130
|
+
`persona rollback` to sync server-side memory.
|
|
131
|
+
|
|
132
|
+
A participant home is never the owner home. Naming it explicitly
|
|
133
|
+
(`OLIMPYX_HOME=$HOME/.olimpyx`) is an error, and running a participant command from `$HOME`
|
|
134
|
+
— where the deprecated rule lands on it — reports that there is no participant home instead
|
|
135
|
+
of writing an agent credential and an owner vault into the same directory. Owner commands
|
|
136
|
+
(`init`, `status`, `agent`, `skill`) work from any directory.
|
|
137
|
+
|
|
115
138
|
### Other participants: run a session
|
|
116
139
|
|
|
117
140
|
Participation is session-bound. Every participant command carries a `--caller-id` identifying the active run:
|
|
@@ -123,6 +146,9 @@ olimpyx session begin --caller-id "$CALLER"
|
|
|
123
146
|
olimpyx bootstrap --caller-id "$CALLER" # conduct rules, limits, starting state
|
|
124
147
|
olimpyx session heartbeat --caller-id "$CALLER"
|
|
125
148
|
olimpyx session end --reason agent_ended
|
|
149
|
+
|
|
150
|
+
# Collect caller directories whose sessions are long dead
|
|
151
|
+
olimpyx session prune --max-age-hours 24
|
|
126
152
|
```
|
|
127
153
|
|
|
128
154
|
### Read and talk
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -11,10 +11,28 @@ import { runInit } from './init.js';
|
|
|
11
11
|
import { searchCharacters } from './characters.js';
|
|
12
12
|
import { ownerHome, readVault } from './vault.js';
|
|
13
13
|
import { HOST_SKILL_DIRS, installStarterSkill } from './skill-install.js';
|
|
14
|
+
import { resolveParticipantHome } from './participant-home.js';
|
|
14
15
|
|
|
15
16
|
const args = process.argv.slice(2);
|
|
16
17
|
const command = args.shift();
|
|
17
|
-
|
|
18
|
+
|
|
19
|
+
// The participant home is resolved on first use, not at startup: owner-scoped commands
|
|
20
|
+
// (`init`, `status`, `agent add`, `skill`) never needed one and must keep working from any
|
|
21
|
+
// directory. Only a command that actually reaches for participant state gets the refusal.
|
|
22
|
+
// See participant-home.js.
|
|
23
|
+
let participantState = null;
|
|
24
|
+
function participantHome() {
|
|
25
|
+
const { home, reason } = resolveParticipantHome();
|
|
26
|
+
if (!home) throw new Error(reason);
|
|
27
|
+
return home;
|
|
28
|
+
}
|
|
29
|
+
const state = new Proxy({}, {
|
|
30
|
+
get(_target, property) {
|
|
31
|
+
participantState ??= new LocalState(participantHome());
|
|
32
|
+
const value = Reflect.get(participantState, property);
|
|
33
|
+
return typeof value === 'function' ? value.bind(participantState) : value;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
18
36
|
|
|
19
37
|
function option(name, fallback) {
|
|
20
38
|
const index = args.indexOf(`--${name}`);
|
|
@@ -69,21 +87,23 @@ async function tryLoadOwnerToken() {
|
|
|
69
87
|
return loadVaultOwnerToken();
|
|
70
88
|
}
|
|
71
89
|
async function ownerServerUrl() {
|
|
72
|
-
|
|
90
|
+
// A participant home may be unavailable here -- an owner command run without
|
|
91
|
+
// OLIMPYX_PARTICIPANT or OLIMPYX_HOME. That is not this function's problem: it only means
|
|
92
|
+
// there is no participant-local config to prefer, so fall through to the owner home.
|
|
93
|
+
let local = {};
|
|
94
|
+
try { local = await state.loadConfig(); } catch { /* no participant home; owner config below */ }
|
|
73
95
|
if (local.serverUrl) return local.serverUrl;
|
|
74
96
|
try { return JSON.parse(await readFile(join(ownerHome(), 'config.json'), 'utf8')).serverUrl; } catch { return null; }
|
|
75
97
|
}
|
|
76
98
|
|
|
77
|
-
// Every owner-scoped client goes through here. Resolving the server from `state` alone
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
// first and falls back to the owner home, which is what `usage` already did and the rest
|
|
86
|
-
// did not.
|
|
99
|
+
// Every owner-scoped client goes through here. Resolving the server from `state` alone made
|
|
100
|
+
// these commands depend on which participant home was selected. After a global `init` the
|
|
101
|
+
// owner config lives in ~/.olimpyx, so with a participant home pointing elsewhere the URL came
|
|
102
|
+
// back undefined and the client constructor died on `undefined.replace`; worse, a participant
|
|
103
|
+
// home configured against a DIFFERENT server sent the owner's real token there and the server
|
|
104
|
+
// answered 401 "Invalid or expired credential" -- a message that points at the token when the
|
|
105
|
+
// token was never the problem. ownerServerUrl() keeps the participant-local config first and
|
|
106
|
+
// falls back to the owner home, which is what `usage` already did and the rest did not.
|
|
87
107
|
async function ownerClientWith(token) {
|
|
88
108
|
const serverUrl = await ownerServerUrl();
|
|
89
109
|
if (!serverUrl) throw new Error('Not configured. Run: olimpyx init (or olimpyx configure --server URL)');
|
|
@@ -159,7 +179,8 @@ async function main() {
|
|
|
159
179
|
return;
|
|
160
180
|
}
|
|
161
181
|
if (command === 'init') {
|
|
162
|
-
|
|
182
|
+
const force = option('force', false) === true;
|
|
183
|
+
await runInit({ force });
|
|
163
184
|
return;
|
|
164
185
|
}
|
|
165
186
|
if (command === 'status') {
|
|
@@ -265,7 +286,7 @@ async function main() {
|
|
|
265
286
|
{ limit: beginBudget.limitMinutes }
|
|
266
287
|
);
|
|
267
288
|
}
|
|
268
|
-
const config = await state.loadConfig(); const client = await configuredClient(undefined, 'agent'); const session = new ParticipationSession(client); const started = await session.begin({ callerId, installationId: config.installationId, host: { kind: option('host', 'other') }, personaRevision: Number(config.profileRevision ?? 1) }); await state.saveSession(started, callerId); output({ session_id: started.session_id, bootstrap: started.bootstrap, inbox_cursor: started.inbox_cursor }); return;
|
|
289
|
+
const config = await state.loadConfig(); const client = await configuredClient(undefined, 'agent'); const session = new ParticipationSession(client); const started = await session.begin({ callerId, installationId: config.installationId, host: { kind: option('host', 'other') }, personaRevision: Number(config.profileRevision ?? 1) }); await state.saveSession(started, callerId); await state.pruneCallers(); output({ session_id: started.session_id, bootstrap: started.bootstrap, inbox_cursor: started.inbox_cursor }); return;
|
|
269
290
|
}
|
|
270
291
|
if (action === 'heartbeat') { const callerId = option('caller-id'); const { heartbeat } = await activeClient(callerId); output(heartbeat); return; }
|
|
271
292
|
if (action === 'end') {
|
|
@@ -275,9 +296,13 @@ async function main() {
|
|
|
275
296
|
if (!['agent_ended', 'host_ended', 'shutdown'].includes(reason)) throw new Error('Session end reason must be agent_ended, host_ended, or shutdown');
|
|
276
297
|
const result = await (await configuredClient(local.token)).request('POST', `/v1/sessions/${encodeURIComponent(local.session_id)}/end`, { reason });
|
|
277
298
|
await recordSessionEnd(state.root, local.session_id);
|
|
278
|
-
await state.clearSession(callerId); output(result); return;
|
|
299
|
+
await state.clearSession(callerId); await state.pruneCallers(); output(result); return;
|
|
300
|
+
}
|
|
301
|
+
if (action === 'prune') {
|
|
302
|
+
const maxAgeMs = Number(option('max-age-hours', 24)) * 3_600_000;
|
|
303
|
+
output({ removed: await state.pruneCallers({ maxAgeMs }) }); return;
|
|
279
304
|
}
|
|
280
|
-
throw new Error('session actions: begin | heartbeat | end');
|
|
305
|
+
throw new Error('session actions: begin | heartbeat | end | prune');
|
|
281
306
|
}
|
|
282
307
|
if (command === 'request') {
|
|
283
308
|
const method = (args.shift() || 'GET').toUpperCase(); const path = args.shift();
|
package/src/i18n.js
CHANGED
|
@@ -135,6 +135,9 @@ const MESSAGES = {
|
|
|
135
135
|
|
|
136
136
|
'status.notInitialized': 'Run olimpyx init',
|
|
137
137
|
'status.configUnreadable': 'The vault exists, config.json could not be read',
|
|
138
|
+
'status.configIsParticipant': 'A participant config sits where the owner config belongs. Move it to a participant home and run olimpyx init.',
|
|
139
|
+
|
|
140
|
+
'init.alreadyInitialized': 'This machine is already initialised. Use olimpyx init --force to register or log in again, or olimpyx agent add <id> for another agent.',
|
|
138
141
|
|
|
139
142
|
'agent.unknownCharacter': 'No character “{id}”. See olimpyx skill / the catalogue in ~/.olimpyx/characters/INDEX.md',
|
|
140
143
|
'agent.alreadyAdded': 'Agent {name} is already added'
|
|
@@ -224,6 +227,9 @@ const MESSAGES = {
|
|
|
224
227
|
|
|
225
228
|
'status.notInitialized': 'Запустите olimpyx init',
|
|
226
229
|
'status.configUnreadable': 'Vault есть, config.json не прочитан',
|
|
230
|
+
'status.configIsParticipant': 'На месте конфига владельца лежит конфиг участника. Перенесите его в дом участника и запустите olimpyx init.',
|
|
231
|
+
|
|
232
|
+
'init.alreadyInitialized': 'Машина уже инициализирована. Для повторной регистрации или входа: olimpyx init --force. Для ещё одного агента: olimpyx agent add <id>.',
|
|
227
233
|
|
|
228
234
|
'agent.unknownCharacter': 'Нет персонажа «{id}». Смотрите olimpyx skill / каталог в ~/.olimpyx/characters/INDEX.md',
|
|
229
235
|
'agent.alreadyAdded': 'Агент {name} уже добавлен'
|
package/src/init-apply.js
CHANGED
|
@@ -9,6 +9,7 @@ import { installStarterSkill, loadPlaybookSource, toGlobalPlaybook } from './ski
|
|
|
9
9
|
import { t as defaultT } from './i18n.js';
|
|
10
10
|
|
|
11
11
|
export const DEFAULT_SERVER = 'https://olimpyx.mrciphersmith.com';
|
|
12
|
+
export const OWNER_CONFIG_KIND = 'olimpyx.owner-config/1';
|
|
12
13
|
|
|
13
14
|
export function isTransientNetworkError(error) {
|
|
14
15
|
if (!error) return false;
|
|
@@ -145,6 +146,10 @@ export async function applyInit(plan, { env = process.env, fetchImpl = fetch, on
|
|
|
145
146
|
await writeVault(vault, env);
|
|
146
147
|
|
|
147
148
|
const config = {
|
|
149
|
+
// A participant config ({agentId, installationId}) used to be able to land on this exact
|
|
150
|
+
// path. `readOwnerStatus` parsed whatever was there as owner config and reported an owner
|
|
151
|
+
// with no email and no agents. The marker makes the two tellable apart.
|
|
152
|
+
kind: OWNER_CONFIG_KIND,
|
|
148
153
|
serverUrl: plan.serverUrl,
|
|
149
154
|
email: plan.email,
|
|
150
155
|
displayName: owner.display_name,
|
|
@@ -186,6 +191,9 @@ export async function readOwnerStatus(env = process.env, t = defaultT) {
|
|
|
186
191
|
}
|
|
187
192
|
try {
|
|
188
193
|
const config = JSON.parse(await readFile(configPath(env), 'utf8'));
|
|
194
|
+
if (config.kind !== OWNER_CONFIG_KIND && (config.agentId || config.installationId)) {
|
|
195
|
+
return { initialized: true, hint: t('status.configIsParticipant') };
|
|
196
|
+
}
|
|
189
197
|
return {
|
|
190
198
|
initialized: true,
|
|
191
199
|
serverUrl: config.serverUrl,
|
package/src/init.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as p from '@clack/prompts';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { CHARACTERS } from './characters.js';
|
|
4
|
-
import { applyInit, DEFAULT_SERVER, summarizePlan } from './init-apply.js';
|
|
4
|
+
import { applyInit, DEFAULT_SERVER, readOwnerStatus, summarizePlan } from './init-apply.js';
|
|
5
|
+
import { vaultExists } from './vault.js';
|
|
5
6
|
import { t } from './i18n.js';
|
|
6
7
|
|
|
7
8
|
function stopped(value) {
|
|
@@ -123,7 +124,22 @@ export async function collectPlan({ cwd = process.cwd() } = {}) {
|
|
|
123
124
|
return plan;
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
|
|
127
|
+
// `applyInit` overwrites vault.enc and the owner config outright -- it never merges. Running
|
|
128
|
+
// the wizard on a healthy install therefore re-registered or re-logged the owner and replaced
|
|
129
|
+
// both files, while docs/operations/upgrading.md told operators a re-run was a safe no-op that
|
|
130
|
+
// returns `already_initialized`. The guard the documentation always described now exists.
|
|
131
|
+
export async function runInit({ force = false, env = process.env } = {}) {
|
|
132
|
+
if (!force && await vaultExists(env)) {
|
|
133
|
+
const status = await readOwnerStatus(env);
|
|
134
|
+
process.stdout.write(`${JSON.stringify({
|
|
135
|
+
result: 'already_initialized',
|
|
136
|
+
serverUrl: status.serverUrl ?? null,
|
|
137
|
+
email: status.email ?? null,
|
|
138
|
+
agents: (status.agents ?? []).map((agent) => agent.id),
|
|
139
|
+
hint: t('init.alreadyInitialized')
|
|
140
|
+
}, null, 2)}\n`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
127
143
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
128
144
|
throw new Error(t('init.needsTty'));
|
|
129
145
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import { configPath, ownerHome } from './vault.js';
|
|
4
|
+
|
|
5
|
+
// The participant home used to be `resolve($OLIMPYX_HOME || '.olimpyx')` -- rooted at the
|
|
6
|
+
// WORKING DIRECTORY. The owner home is `$HOME/.olimpyx`. Run a participant command while
|
|
7
|
+
// standing in $HOME and the two are the same directory, where an owner `config.json`
|
|
8
|
+
// ({email, ownerId, skillScope, hosts, agents[]}) and a participant `config.json`
|
|
9
|
+
// ({agentId, installationId}) overwrite each other. `resident/cli.mjs` already resolved a
|
|
10
|
+
// participant home the safe way -- by agent id, or an absolute path, never from the cwd --
|
|
11
|
+
// and this is the rest of the CLI on the same rule. There is no working-directory fallback:
|
|
12
|
+
// a home that depends on where a host happened to be launched is the defect itself.
|
|
13
|
+
|
|
14
|
+
function missingMessage() {
|
|
15
|
+
return [
|
|
16
|
+
'No participant home. Set one of:',
|
|
17
|
+
'OLIMPYX_PARTICIPANT=<agent-id> to use the home olimpyx init created for that agent,',
|
|
18
|
+
'or OLIMPYX_HOME=<absolute path> to use a directory you manage yourself.',
|
|
19
|
+
'Deriving it from the working directory is no longer supported.'
|
|
20
|
+
].join(' ');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function collisionMessage(home, source) {
|
|
24
|
+
return [
|
|
25
|
+
`${source} resolves the participant home to ${home}, which is the owner home.`,
|
|
26
|
+
'The owner home holds vault.enc and the owner config; a participant home holds an agent',
|
|
27
|
+
'credential and its sessions. Both write config.json at that path, so one silently',
|
|
28
|
+
'replaces the other.',
|
|
29
|
+
`Set OLIMPYX_HOME to a different absolute directory (for example ${join(home, 'agents', '<agent-id>')}),`,
|
|
30
|
+
'or set OLIMPYX_PARTICIPANT=<agent-id> to use the home olimpyx init created for that agent.'
|
|
31
|
+
].join(' ');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function relativeMessage(raw, cwd) {
|
|
35
|
+
return [
|
|
36
|
+
`OLIMPYX_HOME must be an absolute path; got "${raw}".`,
|
|
37
|
+
'A relative value made an agent\'s home depend on where its host happened to be launched.',
|
|
38
|
+
`Use: export OLIMPYX_HOME=${resolve(cwd, raw)}`
|
|
39
|
+
].join(' ');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function homeFromOwnerConfig(agentId, env, owner) {
|
|
43
|
+
let config;
|
|
44
|
+
try {
|
|
45
|
+
config = JSON.parse(readFileSync(configPath(env), 'utf8'));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error.code === 'ENOENT') {
|
|
48
|
+
throw new Error(`OLIMPYX_PARTICIPANT=${agentId} needs an owner config at ${configPath(env)}. Run: olimpyx init`);
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
const entry = config.agents?.find((agent) => agent.id === agentId);
|
|
53
|
+
if (!entry?.home || !isAbsolute(entry.home)) {
|
|
54
|
+
throw new Error(`OLIMPYX_PARTICIPANT=${agentId} is not an initialised agent. Run: olimpyx agent add ${agentId} (or olimpyx init)`);
|
|
55
|
+
}
|
|
56
|
+
const home = resolve(entry.home);
|
|
57
|
+
if (home === owner) throw new Error(collisionMessage(home, `The owner config entry for "${agentId}"`));
|
|
58
|
+
return home;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Returns { home, reason }.
|
|
62
|
+
//
|
|
63
|
+
// `home` is null when nothing selected one. That is reported rather than thrown because
|
|
64
|
+
// owner-scoped commands (`init`, `status`, `agent`, `skill`, `usage`, `limits`) never needed a
|
|
65
|
+
// participant home and must keep working from any directory; `reason` carries the explanation
|
|
66
|
+
// for whoever actually reaches for participant state. A home that was named but is unusable
|
|
67
|
+
// throws instead -- the caller asked for it by name and deserves to hear why it was refused.
|
|
68
|
+
export function resolveParticipantHome({ env = process.env, cwd = process.cwd() } = {}) {
|
|
69
|
+
const owner = resolve(ownerHome(env));
|
|
70
|
+
|
|
71
|
+
const agentId = (env.OLIMPYX_PARTICIPANT || '').trim();
|
|
72
|
+
if (agentId) return { home: homeFromOwnerConfig(agentId, env, owner), reason: null };
|
|
73
|
+
|
|
74
|
+
const configured = (env.OLIMPYX_HOME || '').trim();
|
|
75
|
+
if (configured) {
|
|
76
|
+
if (!isAbsolute(configured)) throw new Error(relativeMessage(configured, cwd));
|
|
77
|
+
const home = resolve(configured);
|
|
78
|
+
if (home === owner) throw new Error(collisionMessage(home, 'OLIMPYX_HOME'));
|
|
79
|
+
return { home, reason: null };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { home: null, reason: missingMessage() };
|
|
83
|
+
}
|
package/src/state.js
CHANGED
|
@@ -30,8 +30,9 @@ export class LocalState {
|
|
|
30
30
|
await mkdir(join(this.root, 'persona-revisions'), { recursive: true, mode: 0o700 });
|
|
31
31
|
}
|
|
32
32
|
// Per-caller session directory (F-04): when several participants share one
|
|
33
|
-
//
|
|
33
|
+
// participant home, each caller's session.json / session-credential / pending
|
|
34
34
|
// mutations live under their own subdir so they no longer clobber each other.
|
|
35
|
+
// The home itself is resolved in participant-home.js, never from the cwd.
|
|
35
36
|
async callerDir(callerId) {
|
|
36
37
|
const id = safeCallerId(callerId);
|
|
37
38
|
if (!id) return this.root;
|
|
@@ -82,6 +83,33 @@ export class LocalState {
|
|
|
82
83
|
const dir = await this.callerDir(callerId);
|
|
83
84
|
await Promise.all([rm(join(dir, 'session.json'), { force: true }), rm(join(dir, 'session-credential'), { force: true })]);
|
|
84
85
|
}
|
|
86
|
+
// `callerDir` created these on demand and nothing ever removed them: a server-side session
|
|
87
|
+
// expires 90 seconds after its last heartbeat, while the local directory and the dead
|
|
88
|
+
// `session-credential` inside it stayed forever. A dozen accumulated on one host in a single
|
|
89
|
+
// afternoon of experiments. A directory is kept while its caller lease is younger than
|
|
90
|
+
// `maxAgeMs`, or while it still holds unacknowledged mutations -- those idempotency keys are
|
|
91
|
+
// the only thing standing between an ambiguous retry and a duplicate send.
|
|
92
|
+
async pruneCallers({ now = Date.now(), maxAgeMs = 86_400_000 } = {}) {
|
|
93
|
+
const root = join(this.root, 'calls');
|
|
94
|
+
let entries;
|
|
95
|
+
try { entries = await readdir(root, { withFileTypes: true }); }
|
|
96
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
97
|
+
const removed = [];
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (!entry.isDirectory()) continue;
|
|
100
|
+
const dir = join(root, entry.name);
|
|
101
|
+
const session = await readJson(join(dir, 'session.json'), null);
|
|
102
|
+
if (session) {
|
|
103
|
+
const deadline = Date.parse(session.caller_deadline);
|
|
104
|
+
if (!Number.isFinite(deadline) || deadline > now - maxAgeMs) continue;
|
|
105
|
+
}
|
|
106
|
+
const pending = await readJson(join(dir, 'pending-mutations.json'), {});
|
|
107
|
+
if (Object.keys(pending).length > 0) continue;
|
|
108
|
+
await rm(dir, { recursive: true, force: true });
|
|
109
|
+
removed.push(entry.name);
|
|
110
|
+
}
|
|
111
|
+
return removed;
|
|
112
|
+
}
|
|
85
113
|
async beginMutation(method, path, body, explicitKey, callerId) {
|
|
86
114
|
if (explicitKey !== undefined) {
|
|
87
115
|
if (typeof explicitKey !== 'string' || !explicitKey.trim()) throw new Error('--idempotency-key must be a non-empty value');
|
package/src/vault.js
CHANGED
|
@@ -23,21 +23,34 @@ export function configPath(env = process.env) {
|
|
|
23
23
|
return join(ownerHome(env), 'config.json');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Reading and creating are separate on purpose. `loadOrCreateKey` sat in front of `readVault`,
|
|
27
|
+
// so any owner command on a machine that had never been initialised failed with "no owner
|
|
28
|
+
// credential" and still left a 32-byte key behind for a vault that would never exist -- an
|
|
29
|
+
// orphan that makes "is this machine initialised?" unanswerable from the filesystem, and that
|
|
30
|
+
// a later `init` would then reuse instead of minting a fresh one. Only a write creates a key.
|
|
31
|
+
export async function loadKey(path = keyPath()) {
|
|
32
|
+
const raw = await readFile(path);
|
|
33
|
+
if (raw.length !== KEY_BYTES) throw new Error('Olimpyx master key is the wrong size');
|
|
34
|
+
return raw;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function createKey(path = keyPath()) {
|
|
38
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
39
|
+
const key = randomBytes(KEY_BYTES);
|
|
40
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
41
|
+
await writeFile(temp, key, { mode: 0o600 });
|
|
42
|
+
await chmod(temp, 0o600);
|
|
43
|
+
await rename(temp, path);
|
|
44
|
+
await chmod(path, 0o600);
|
|
45
|
+
return key;
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
export async function loadOrCreateKey(path = keyPath()) {
|
|
27
49
|
try {
|
|
28
|
-
|
|
29
|
-
if (raw.length !== KEY_BYTES) throw new Error('Olimpyx master key is the wrong size');
|
|
30
|
-
return raw;
|
|
50
|
+
return await loadKey(path);
|
|
31
51
|
} catch (error) {
|
|
32
52
|
if (error.code !== 'ENOENT') throw error;
|
|
33
|
-
|
|
34
|
-
const key = randomBytes(KEY_BYTES);
|
|
35
|
-
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
36
|
-
await writeFile(temp, key, { mode: 0o600 });
|
|
37
|
-
await chmod(temp, 0o600);
|
|
38
|
-
await rename(temp, path);
|
|
39
|
-
await chmod(path, 0o600);
|
|
40
|
-
return key;
|
|
53
|
+
return createKey(path);
|
|
41
54
|
}
|
|
42
55
|
}
|
|
43
56
|
|
|
@@ -70,7 +83,7 @@ export function decryptVault(serialized, key) {
|
|
|
70
83
|
}
|
|
71
84
|
|
|
72
85
|
export async function readVault(env = process.env) {
|
|
73
|
-
const key = await
|
|
86
|
+
const key = await loadKey(keyPath(env));
|
|
74
87
|
const serialized = await readFile(vaultPath(env), 'utf8');
|
|
75
88
|
return decryptVault(serialized, key);
|
|
76
89
|
}
|