@wacando/agent 0.0.0-stage → 2.0.0-rc.1
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/.agents/plugins/marketplace.json +10 -0
- package/.claude-plugin/marketplace.json +12 -0
- package/.cursor-plugin/marketplace.json +12 -0
- package/README.md +166 -2
- package/STATUS.md +121 -0
- package/THIRD-PARTY-NOTICES.txt +1928 -0
- package/adapters/README.md +12 -0
- package/adapters/claude/mcp.mjs +7 -0
- package/adapters/codex/mcp.mjs +7 -0
- package/adapters/cursor/mcp.mjs +7 -0
- package/bin/wacando-agent.mjs +243 -0
- package/docs/AGENT_TOOLS.md +129 -0
- package/docs/CURSOR_IDENTITY_RESUME.md +579 -0
- package/docs/MCP-AUDIT.md +355 -0
- package/docs/MCP-CONTRACT.md +138 -0
- package/docs/MIGRATION-2.0.md +36 -0
- package/docs/OMEMO_PREKEY_INTEGRATION.md +125 -0
- package/docs/ONBOARDING-VALIDATION.md +88 -0
- package/docs/ONBOARDING.md +212 -0
- package/docs/ONBOARDING_PLUGIN_CONTRACT.md +156 -0
- package/docs/PLUGIN_HANDOFF_FOLLOWUP.md +178 -0
- package/docs/PUBLIC_ONBOARDING_READINESS.md +156 -0
- package/docs/RELEASE.md +25 -0
- package/docs/WACANDO_PACKAGES.md +26 -0
- package/docs/evidence/issue-2/DOCUMENTARY_FOLLOWUP.md +107 -0
- package/docs/evidence/issue-2/QUALIFICATION_LINK_FOLLOWUP.md +105 -0
- package/docs/evidence/issue-2/REVIEW_FOLLOWUP.md +93 -0
- package/docs/evidence/issue-2/baseline.txt +76 -0
- package/docs/evidence/issue-2/check-mcp.txt +14 -0
- package/docs/evidence/issue-2/check.txt +376 -0
- package/docs/evidence/issue-2/codex-exec-help.json +11 -0
- package/docs/evidence/issue-2/codex-help.json +10 -0
- package/docs/evidence/issue-2/cursor-help.json +10 -0
- package/docs/evidence/issue-2/documentary-check.txt +376 -0
- package/docs/evidence/issue-2/documentary-validation.txt +7 -0
- package/docs/evidence/issue-2/local-build-api.txt +203 -0
- package/docs/evidence/issue-2/local-build-first.txt +87 -0
- package/docs/evidence/issue-2/local-build-policy-first.txt +34 -0
- package/docs/evidence/issue-2/local-build-policy.txt +153 -0
- package/docs/evidence/issue-2/local-build-web.txt +219 -0
- package/docs/evidence/issue-2/local-check-mcp.txt +14 -0
- package/docs/evidence/issue-2/local-check.txt +376 -0
- package/docs/evidence/issue-2/local-codex-probe.txt +3 -0
- package/docs/evidence/issue-2/local-codex-probe2.txt +3 -0
- package/docs/evidence/issue-2/local-codex.txt +2 -0
- package/docs/evidence/issue-2/local-cursor-probe.txt +1 -0
- package/docs/evidence/issue-2/local-cursor.txt +2 -0
- package/docs/evidence/issue-2/local-down.txt +32 -0
- package/docs/evidence/issue-2/local-health.txt +1 -0
- package/docs/evidence/issue-2/local-live.txt +28 -0
- package/docs/evidence/issue-2/local-seed.txt +26 -0
- package/docs/evidence/issue-2/local-status.txt +6 -0
- package/docs/evidence/issue-2/local-up.txt +18 -0
- package/docs/evidence/issue-2/prepare-local-stack.py +67 -0
- package/docs/evidence/issue-2/qualification-body-validation.txt +9 -0
- package/docs/evidence/issue-2/qualification-link-check.txt +376 -0
- package/docs/evidence/issue-2/qualification-link-validation.txt +20 -0
- package/docs/evidence/issue-2/review-check.txt +376 -0
- package/docs/evidence/issue-2/review-validation.txt +218 -0
- package/docs/evidence/issue-7/README.md +21 -0
- package/docs/evidence/issue-7/check-mcp.txt +9 -0
- package/docs/evidence/issue-7/check.txt +381 -0
- package/docs/evidence/issue-7/live.txt +23 -0
- package/docs/evidence/issue-8/README.md +56 -0
- package/docs/evidence/issue-8/check-mcp.txt +9 -0
- package/docs/evidence/issue-8/check.txt +465 -0
- package/docs/evidence/issue-8/e2e-desktop-observer-human-devices.txt +30 -0
- package/docs/evidence/issue-8/e2e-desktop-observer-main-without-109.txt +17 -0
- package/docs/evidence/issue-8/e2e-desktop-observer.txt +26 -0
- package/docs/evidence/issue-8/live-regression-human-devices.txt +24 -0
- package/docs/evidence/issue-8/live-regression.txt +23 -0
- package/docs/evidence/issue-8/review-fixes/README.md +30 -0
- package/docs/evidence/issue-8/review-fixes/check-mcp.txt +9 -0
- package/docs/evidence/issue-8/review-fixes/check.txt +484 -0
- package/docs/evidence/issue-8/review-fixes/e2e-desktop-observer.txt +28 -0
- package/docs/evidence/issue-8/review-fixes/onboarding-live.txt +15 -0
- package/docs/evidence/issue-8/review-fixes/session-replacement-live.txt +13 -0
- package/docs/evidence/npm-release/check-mcp.txt +9 -0
- package/docs/evidence/npm-release/check.txt +484 -0
- package/docs/evidence/npm-release/e2e-desktop-observer.txt +28 -0
- package/package.json +66 -4
- package/packages/core/limits.mjs +24 -0
- package/packages/core/store.mjs +92 -0
- package/packages/core/validation.mjs +25 -0
- package/packages/mcp/agent-tools.mjs +82 -0
- package/packages/mcp/server.mjs +161 -0
- package/packages/mcp/stdio.mjs +42 -0
- package/packages/onboarding/network.mjs +60 -0
- package/packages/onboarding/service.mjs +235 -0
- package/packages/onboarding/storage.mjs +99 -0
- package/packages/onboarding/version.mjs +2 -0
- package/packages/runtime/conversations.mjs +384 -0
- package/packages/runtime/device.mjs +34 -0
- package/packages/runtime/index.mjs +8 -0
- package/packages/runtime/pairing.mjs +110 -0
- package/packages/runtime/runtime.mjs +213 -0
- package/packages/runtime/session.mjs +52 -0
- package/packages/server/cli.mjs +18 -0
- package/packages/server/server.mjs +110 -0
- package/packages/server/stream.mjs +42 -0
- package/packages/web/app.js +85 -0
- package/packages/web/history.js +48 -0
- package/packages/web/index.html +17 -0
- package/packages/web/style.css +1 -0
- package/plugins/wacando/.codex-plugin/plugin.json +18 -0
- package/plugins/wacando/.mcp.json +8 -0
- package/plugins/wacando/skills/wacando/SKILL.md +33 -0
- package/plugins/wacando-claude/.claude-plugin/plugin.json +10 -0
- package/plugins/wacando-claude/.mcp.json +8 -0
- package/plugins/wacando-claude/README.md +88 -0
- package/plugins/wacando-claude/skills/open/SKILL.md +66 -0
- package/plugins/wacando-cursor/.cursor-plugin/plugin.json +11 -0
- package/plugins/wacando-cursor/README.md +48 -0
- package/plugins/wacando-cursor/commands/wacando-open.md +10 -0
- package/plugins/wacando-cursor/mcp.json +8 -0
- package/plugins/wacando-cursor/skills/wacando/SKILL.md +50 -0
- package/provenance.json +503 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { CredentialStore, digest, protectedDirectory, SafeError } from './storage.mjs';
|
|
3
|
+
import { ApiError, configuredOrigin, post, serviceUrl } from './network.mjs';
|
|
4
|
+
import { pluginVersion } from './version.mjs';
|
|
5
|
+
|
|
6
|
+
const string = (maxLength = 200) => ({ type: 'string', minLength: 1, maxLength });
|
|
7
|
+
const schema = properties => ({ type: 'object', properties, required: Object.keys(properties), additionalProperties: false });
|
|
8
|
+
export const onboardingTools = [
|
|
9
|
+
{ name: 'wacando_verify_installation', description: 'Verify this installed plugin with the Wacando installation prompt. Saves credentials privately; returns only installation status, ID and plugin version.', inputSchema: schema({ verificationUrl: string(2048), challenge: string(512), protocolVersion: { type: 'integer', enum: [1] } }) },
|
|
10
|
+
{ name: 'wacando_enroll_agent', description: 'Redeem a temporary enrollment token and connect with XMPP/OMEMO. Repeated calls resume using the saved credential without redeeming again. Never returns secrets.', inputSchema: schema({ apiBaseUrl: string(2048), agentId: string(), handle: string(), installationId: string(), enrollmentToken: string(512) }) },
|
|
11
|
+
];
|
|
12
|
+
const identifier = value => typeof value === 'string' && /^[a-zA-Z0-9_-]{1,200}$/.test(value);
|
|
13
|
+
const secret = (value, prefix) => typeof value === 'string' && value.startsWith(prefix) && /^[a-zA-Z0-9_.-]{8,512}$/.test(value);
|
|
14
|
+
const lostEnrollment = 'Enrollment result was not safely saved. Check the agent status on the site. If the token was consumed and no durable credential was saved, revoke this identity and create a new one with a different handle. No automatic recovery exists in v1.';
|
|
15
|
+
export const SESSION_REPLACED = 'SESSION_REPLACED: this identity was taken over by a newer session elsewhere (one session per agent). Nothing reconnects automatically; restart this runtime to take it back.';
|
|
16
|
+
const retryConnection = 'Agent credential is saved, but connection was not confirmed. Automatic retry is scheduled while this MCP host stays open. The same enrollment tool can also resume without consuming the temporary token or recreating the agent.';
|
|
17
|
+
|
|
18
|
+
export class OnboardingService {
|
|
19
|
+
constructor({ host, origin, store = new CredentialStore(), request = post, connect,
|
|
20
|
+
schedule = setTimeout, cancel = clearTimeout, random = Math.random, onStanza, onSession,
|
|
21
|
+
report = event => process.stderr.write(`${JSON.stringify({ component: 'wacando-connection', ...event })}\n`) } = {}) {
|
|
22
|
+
this.host = host;
|
|
23
|
+
this.originSetting = origin;
|
|
24
|
+
this.store = store;
|
|
25
|
+
this.request = request;
|
|
26
|
+
this.connect = connect ?? (async options => (await import('../runtime/session.mjs')).connectSession(options));
|
|
27
|
+
this.sessions = new Map();
|
|
28
|
+
this.timers = new Map();
|
|
29
|
+
this.failures = new Map();
|
|
30
|
+
this.states = new Map();
|
|
31
|
+
this.recoveries = new Set();
|
|
32
|
+
// Identities whose session the server replaced (<conflict/>): terminal for
|
|
33
|
+
// this process, so two runtimes never steal the session from each other.
|
|
34
|
+
this.replaced = new Set();
|
|
35
|
+
this.schedule = schedule; this.cancel = cancel; this.random = random; this.report = report;
|
|
36
|
+
this.onStanza = onStanza;
|
|
37
|
+
this.onSession = onSession;
|
|
38
|
+
this.closed = false;
|
|
39
|
+
}
|
|
40
|
+
origin(value) {
|
|
41
|
+
const expected = configuredOrigin(this.originSetting);
|
|
42
|
+
const url = serviceUrl(value, { development: expected.startsWith('http:') });
|
|
43
|
+
if (url.origin !== expected) throw new SafeError('Service origin does not match WACANDO_API_ORIGIN. Configure the intended service origin in this MCP host and reload before using its prompt.');
|
|
44
|
+
return url;
|
|
45
|
+
}
|
|
46
|
+
async verify(args) {
|
|
47
|
+
const url = this.origin(args.verificationUrl);
|
|
48
|
+
const match = /^\/v1\/users\/[a-z0-9][a-z0-9_-]{0,31}\/installations\/([a-zA-Z0-9_-]{1,200})\/verify$/.exec(url.pathname);
|
|
49
|
+
if (!match || args.protocolVersion !== 1 || !secret(args.challenge, 'wacinst_')) throw new SafeError('Invalid installation prompt. Generate a new installation prompt on the site.');
|
|
50
|
+
const id = match[1];
|
|
51
|
+
return this.store.lock(url.origin, this.host, 'installation', id, async () => {
|
|
52
|
+
if (await this.store.read(url.origin, this.host, 'installation', id)) {
|
|
53
|
+
throw new SafeError('Installation is already verified locally. Continue with the agent enrollment prompt; do not replay the challenge.');
|
|
54
|
+
}
|
|
55
|
+
let data;
|
|
56
|
+
try { data = await this.request(url, args.challenge, { protocolVersion: 1, pluginVersion }); }
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (error instanceof ApiError && error.status === 400) throw new SafeError('Installation protocol was rejected. Update the plugin and generate a new installation prompt.');
|
|
59
|
+
throw new SafeError('Installation verification failed or its response was lost. The challenge may be expired or used. Generate a new installation prompt on the site.');
|
|
60
|
+
}
|
|
61
|
+
if (data?.installationId !== id || !secret(data?.installationCredential, 'waciacc_')) throw new SafeError('Invalid installation response. Generate a new installation prompt on the site.');
|
|
62
|
+
try { await this.store.write(url.origin, this.host, 'installation', id, { origin: url.origin, installationId: id, installationCredential: data.installationCredential }); }
|
|
63
|
+
catch { throw new SafeError('Installation response could not be saved safely. Repair local storage, then generate a new installation prompt on the site.'); }
|
|
64
|
+
return { verified: true, installationId: id, pluginVersion };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async enroll(args) {
|
|
68
|
+
const url = this.origin(args.apiBaseUrl);
|
|
69
|
+
if (url.pathname !== '/' || !identifier(args.agentId) || !identifier(args.installationId) ||
|
|
70
|
+
!/^@[a-z0-9][a-z0-9_-]{0,31}\/[a-z0-9][a-z0-9_-]{0,31}$/.test(args.handle)) throw new SafeError('Invalid enrollment prompt. Request a new enrollment prompt from the site.');
|
|
71
|
+
return this.store.lock(url.origin, this.host, 'agent', args.agentId, async () => {
|
|
72
|
+
const installation = await this.store.read(url.origin, this.host, 'installation', args.installationId);
|
|
73
|
+
if (installation?.origin !== url.origin || installation.installationId !== args.installationId || !secret(installation.installationCredential, 'waciacc_')) {
|
|
74
|
+
throw new SafeError('Installation not found for this service and host. Use the installation selected on the site and verify it in this host first.');
|
|
75
|
+
}
|
|
76
|
+
let agent = await this.store.read(url.origin, this.host, 'agent', args.agentId);
|
|
77
|
+
if (agent && (agent.origin !== url.origin || agent.agentId !== args.agentId || agent.handle !== args.handle || agent.installationId !== args.installationId)) throw new SafeError('Saved identity does not match this prompt. Use the installation and identity selected on the site.');
|
|
78
|
+
if (agent?.revoked) throw new SafeError('Agent revoked. Connection attempts have stopped. Create a new identity on the site.');
|
|
79
|
+
if (!agent) {
|
|
80
|
+
if (!secret(args.enrollmentToken, 'agtenr_')) throw new SafeError('Invalid temporary token. Request a new enrollment prompt on the site.');
|
|
81
|
+
let data;
|
|
82
|
+
try { data = await this.request(new URL(`/v1/agents/${args.agentId}/enroll`, url), args.enrollmentToken, undefined, installation.installationCredential); }
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error instanceof ApiError) {
|
|
85
|
+
if (error.code === 'AGENT_REVOKED') {
|
|
86
|
+
await this.store.write(url.origin, this.host, 'agent', args.agentId, { origin: url.origin, agentId: args.agentId, handle: args.handle, installationId: args.installationId, revoked: true });
|
|
87
|
+
throw new SafeError('Agent revoked. Connection attempts have stopped.');
|
|
88
|
+
}
|
|
89
|
+
if (error.status === 401) throw new SafeError('Temporary token expired, used or invalid. Request a new enrollment prompt. If the site says already enrolled and no credential was saved, revoke this identity and create a new one with a different handle.');
|
|
90
|
+
if (error.status === 403) throw new SafeError('Wrong installation or account. Use the installation selected on the site.');
|
|
91
|
+
if (error.status === 400) throw new SafeError('Enrollment input was rejected. Request a corrected enrollment prompt.');
|
|
92
|
+
}
|
|
93
|
+
throw new SafeError(lostEnrollment);
|
|
94
|
+
}
|
|
95
|
+
if (data?.agentId !== args.agentId || data.handle !== args.handle || !secret(data.agentToken, 'agtacc_')) throw new SafeError(lostEnrollment);
|
|
96
|
+
agent = { origin: url.origin, agentId: args.agentId, handle: args.handle, installationId: args.installationId, agentToken: data.agentToken, xmpp: data.xmpp };
|
|
97
|
+
// Persist the durable credential BEFORE validating transport metadata or opening a socket.
|
|
98
|
+
try { await this.store.write(url.origin, this.host, 'agent', args.agentId, agent); }
|
|
99
|
+
catch { throw new SafeError(lostEnrollment); }
|
|
100
|
+
}
|
|
101
|
+
if (!secret(agent.agentToken, 'agtacc_')) throw new SafeError('Saved agent credential is invalid. Preserve local state and check the identity status on the site.');
|
|
102
|
+
await this.open(agent);
|
|
103
|
+
return { handle: agent.handle, connected: true };
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
async disconnect(id) {
|
|
107
|
+
this.cancel(this.timers.get(id)); this.timers.delete(id);
|
|
108
|
+
const session = this.sessions.get(id); this.sessions.delete(id);
|
|
109
|
+
await session?.stop().catch(() => {});
|
|
110
|
+
}
|
|
111
|
+
status(agent, state, step, retryInMs) {
|
|
112
|
+
const value = { agentId: agent.agentId, state, step, ...(retryInMs === undefined ? {} : { retryInMs }) };
|
|
113
|
+
this.states.set(agent.agentId, value);
|
|
114
|
+
// Only locally authored enums and public IDs enter diagnostics, never exceptions.
|
|
115
|
+
this.report(value);
|
|
116
|
+
}
|
|
117
|
+
later(agent, delay) {
|
|
118
|
+
if (this.closed) return;
|
|
119
|
+
this.cancel(this.timers.get(agent.agentId));
|
|
120
|
+
const timer = this.schedule(async () => {
|
|
121
|
+
this.timers.delete(agent.agentId);
|
|
122
|
+
if (this.closed) return;
|
|
123
|
+
const recovery = this.store.lock(agent.origin, this.host, 'agent', agent.agentId, () => this.open(agent));
|
|
124
|
+
this.recoveries.add(recovery);
|
|
125
|
+
try { await recovery; }
|
|
126
|
+
catch {
|
|
127
|
+
// open() schedules network recovery itself. Lock/storage failures also
|
|
128
|
+
// remain observable and retryable, except after terminal access rejection.
|
|
129
|
+
if (!this.closed && !this.timers.has(agent.agentId) && !['revoked', 'replaced'].includes(this.states.get(agent.agentId)?.state)) this.retry(agent, 'local operation');
|
|
130
|
+
}
|
|
131
|
+
finally { this.recoveries.delete(recovery); }
|
|
132
|
+
}, delay);
|
|
133
|
+
timer.unref?.(); this.timers.set(agent.agentId, timer);
|
|
134
|
+
}
|
|
135
|
+
retry(agent, phase) {
|
|
136
|
+
if (this.closed) return;
|
|
137
|
+
const count = (this.failures.get(agent.agentId) ?? 0) + 1;
|
|
138
|
+
this.failures.set(agent.agentId, count);
|
|
139
|
+
const delay = Math.min(60000, 1000 * 2 ** Math.min(count - 1, 6)) * (0.8 + this.random() * 0.2);
|
|
140
|
+
this.status(agent, 'retrying', phase, Math.round(delay));
|
|
141
|
+
this.later(agent, delay);
|
|
142
|
+
}
|
|
143
|
+
/** The server replaced this identity's session: stop for good in this process. */
|
|
144
|
+
async markReplaced(agent, phase = 'XMPP session') {
|
|
145
|
+
if (this.replaced.has(agent.agentId)) return;
|
|
146
|
+
this.replaced.add(agent.agentId);
|
|
147
|
+
this.cancel(this.timers.get(agent.agentId)); this.timers.delete(agent.agentId);
|
|
148
|
+
this.status(agent, 'replaced', phase);
|
|
149
|
+
await this.disconnect(agent.agentId);
|
|
150
|
+
}
|
|
151
|
+
async open(agent) {
|
|
152
|
+
this.cancel(this.timers.get(agent.agentId)); this.timers.delete(agent.agentId);
|
|
153
|
+
let release;
|
|
154
|
+
let phase = 'local identity';
|
|
155
|
+
if (this.replaced.has(agent.agentId) || this.sessions.get(agent.agentId)?.session?.isReplaced?.()) {
|
|
156
|
+
await this.markReplaced(agent);
|
|
157
|
+
throw new SafeError(SESSION_REPLACED);
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
if (this.closed) throw new Error();
|
|
161
|
+
let active = this.sessions.get(agent.agentId);
|
|
162
|
+
if (active?.isOnline?.() === false) { await this.disconnect(agent.agentId); active = null; }
|
|
163
|
+
if (!active) {
|
|
164
|
+
release = await this.store.acquire(agent.origin, this.host, 'session', agent.agentId);
|
|
165
|
+
if (this.closed) throw new Error();
|
|
166
|
+
const xmpp = agent.xmpp;
|
|
167
|
+
if (!xmpp || typeof xmpp.domain !== 'string' || !/^[a-z0-9.-]+$/.test(xmpp.domain)) throw new Error();
|
|
168
|
+
const destination = serviceUrl(xmpp.service, { websocket: true, development: agent.origin.startsWith('http:') });
|
|
169
|
+
phase = 'XMPP grant';
|
|
170
|
+
const response = await this.request(new URL('/v1/xmpp/session', agent.origin), agent.agentToken, { agentId: agent.agentId });
|
|
171
|
+
if (this.closed) throw new Error();
|
|
172
|
+
const grant = response?.grant;
|
|
173
|
+
const remaining = Date.parse(grant?.expiresAt) - Date.now();
|
|
174
|
+
if (grant?.agentId !== agent.agentId || typeof grant.credential !== 'string' || !grant.credential.length || !(remaining > 5000)) throw new Error();
|
|
175
|
+
const base = await this.store.directory(agent.origin, this.host);
|
|
176
|
+
const stateBaseDir = await protectedDirectory(join(base, `omemo-${digest(agent.agentId)}`));
|
|
177
|
+
phase = 'XMPP connection';
|
|
178
|
+
const session = await this.connect({ jid: `agt_${agent.agentId}@${xmpp.domain}`, password: grant.credential, service: destination.href, stateBaseDir,
|
|
179
|
+
onReplaced: () => { void this.markReplaced(agent); },
|
|
180
|
+
...(this.onStanza ? { onStanza: (stanza, xmpp) => this.onStanza(agent.agentId, stanza, xmpp) } : {}) });
|
|
181
|
+
const releaseSession = release; release = null;
|
|
182
|
+
active = { session, isOnline: () => session.isOnline?.() !== false, ping: () => session.ping?.(), stop: async () => { try { await session.stop(); } finally { await releaseSession(); } } };
|
|
183
|
+
this.sessions.set(agent.agentId, active);
|
|
184
|
+
this.onSession?.(agent.agentId, session);
|
|
185
|
+
if (this.closed) throw new Error();
|
|
186
|
+
phase = 'OMEMO device publication';
|
|
187
|
+
await session.registerDevice();
|
|
188
|
+
if (this.closed || session.isOnline?.() === false || Date.parse(grant.expiresAt) <= Date.now()) throw new Error();
|
|
189
|
+
active.registered = true;
|
|
190
|
+
}
|
|
191
|
+
phase = 'XMPP heartbeat';
|
|
192
|
+
await active.ping();
|
|
193
|
+
phase = 'connection callback';
|
|
194
|
+
const confirmation = await this.request(new URL(`/v1/agents/${agent.agentId}/connected`, agent.origin), agent.agentToken);
|
|
195
|
+
if (confirmation?.connected !== true) throw new Error();
|
|
196
|
+
this.failures.delete(agent.agentId);
|
|
197
|
+
if (!this.closed) {
|
|
198
|
+
this.status(agent, 'connected', 'connection callback');
|
|
199
|
+
// The v1 grant TTL bounds NEW authentication, not the lifetime of a
|
|
200
|
+
// bound XMPP stream. Heartbeat checks durable access without rotating
|
|
201
|
+
// the XMPP password or tearing down a healthy session.
|
|
202
|
+
this.later(agent, 60000);
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
await release?.();
|
|
206
|
+
if (error instanceof ApiError && (error.code === 'AGENT_REVOKED' || error.status === 401 || error.status === 403)) {
|
|
207
|
+
await this.disconnect(agent.agentId);
|
|
208
|
+
this.status(agent, 'revoked', phase);
|
|
209
|
+
await this.store.write(agent.origin, this.host, 'agent', agent.agentId, { ...agent, revoked: true });
|
|
210
|
+
throw new SafeError('Agent revoked or durable access rejected. Connection attempts have stopped. Check the identity on the site.');
|
|
211
|
+
}
|
|
212
|
+
const active = this.sessions.get(agent.agentId);
|
|
213
|
+
if (this.replaced.has(agent.agentId) || active?.session?.isReplaced?.()) {
|
|
214
|
+
// Another process took this identity over (newest session wins). Never
|
|
215
|
+
// log back in automatically: that would replace it in turn, in a loop.
|
|
216
|
+
await this.markReplaced(agent, phase);
|
|
217
|
+
throw new SafeError(SESSION_REPLACED);
|
|
218
|
+
}
|
|
219
|
+
if (!active?.registered || active.isOnline() === false || phase === 'XMPP heartbeat' || this.closed) await this.disconnect(agent.agentId);
|
|
220
|
+
this.retry(agent, phase);
|
|
221
|
+
throw new SafeError(`${retryConnection} Failed step: ${phase}${error instanceof ApiError ? ` (HTTP ${error.status})` : ''}.`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async call(name, args) {
|
|
225
|
+
try { return await (name === 'wacando_verify_installation' ? this.verify(args) : this.enroll(args)); }
|
|
226
|
+
catch (error) { throw error instanceof SafeError ? error : new SafeError('Onboarding failed. Check local state permissions and service availability; no success is confirmed.'); }
|
|
227
|
+
}
|
|
228
|
+
async close() {
|
|
229
|
+
this.closed = true;
|
|
230
|
+
for (const timer of this.timers.values()) this.cancel(timer);
|
|
231
|
+
this.timers.clear();
|
|
232
|
+
await Promise.allSettled([...this.recoveries]);
|
|
233
|
+
await Promise.all([...this.sessions.keys()].map(id => this.disconnect(id)));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { mkdir, lstat, open, readdir, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join, resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const digest = value => createHash('sha256').update(value).digest('hex');
|
|
8
|
+
export class SafeError extends Error {}
|
|
9
|
+
export const storageError = () => new SafeError('Protected local state is unavailable. Check ownership and permissions; preserve existing credentials and encryption keys.');
|
|
10
|
+
|
|
11
|
+
export async function protectedDirectory(path) {
|
|
12
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
13
|
+
const stat = await lstat(path);
|
|
14
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) ||
|
|
15
|
+
(process.getuid && stat.uid !== process.getuid())) throw storageError();
|
|
16
|
+
return path;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class CredentialStore {
|
|
20
|
+
// 2.x keeps the 1.x default directory so that saved classic identities are
|
|
21
|
+
// detected and refused with migration guidance instead of silently ignored.
|
|
22
|
+
constructor(root = process.env.WACANDO_AGENT_STATE_DIR ?? process.env.WACANDO_PLUGIN_STATE_DIR ?? join(homedir(), '.wacando-plugin')) {
|
|
23
|
+
this.root = resolve(root);
|
|
24
|
+
}
|
|
25
|
+
async directory(origin, host) {
|
|
26
|
+
await protectedDirectory(this.root);
|
|
27
|
+
return protectedDirectory(join(this.root, digest(`${origin}\n${host}`)));
|
|
28
|
+
}
|
|
29
|
+
async location(origin, host, kind, id) {
|
|
30
|
+
return join(await this.directory(origin, host), `${kind}-${digest(id)}.json`);
|
|
31
|
+
}
|
|
32
|
+
async read(origin, host, kind, id) {
|
|
33
|
+
const path = await this.location(origin, host, kind, id);
|
|
34
|
+
let file;
|
|
35
|
+
try {
|
|
36
|
+
file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
37
|
+
const stat = await file.stat();
|
|
38
|
+
if (!stat.isFile() || stat.size > 65536 || (stat.mode & 0o077) ||
|
|
39
|
+
(process.getuid && stat.uid !== process.getuid())) throw storageError();
|
|
40
|
+
return JSON.parse(await file.readFile('utf8'));
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code === 'ENOENT') return null;
|
|
43
|
+
throw storageError();
|
|
44
|
+
} finally { await file?.close(); }
|
|
45
|
+
}
|
|
46
|
+
/** Every saved record of one kind for this service origin and host. */
|
|
47
|
+
async list(origin, host, kind) {
|
|
48
|
+
const directory = await this.directory(origin, host);
|
|
49
|
+
const names = (await readdir(directory)).filter(name => new RegExp(`^${kind}-[0-9a-f]{64}\\.json$`).test(name));
|
|
50
|
+
const records = [];
|
|
51
|
+
for (const name of names.sort()) {
|
|
52
|
+
let file;
|
|
53
|
+
try {
|
|
54
|
+
file = await open(join(directory, name), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
55
|
+
const stat = await file.stat();
|
|
56
|
+
if (!stat.isFile() || stat.size > 65536 || (stat.mode & 0o077) ||
|
|
57
|
+
(process.getuid && stat.uid !== process.getuid())) throw storageError();
|
|
58
|
+
records.push(JSON.parse(await file.readFile('utf8')));
|
|
59
|
+
} catch { throw storageError(); }
|
|
60
|
+
finally { await file?.close(); }
|
|
61
|
+
}
|
|
62
|
+
return records;
|
|
63
|
+
}
|
|
64
|
+
async write(origin, host, kind, id, value) {
|
|
65
|
+
const path = await this.location(origin, host, kind, id);
|
|
66
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
67
|
+
let file;
|
|
68
|
+
try {
|
|
69
|
+
file = await open(tmp, 'wx', 0o600);
|
|
70
|
+
await file.writeFile(JSON.stringify(value));
|
|
71
|
+
await file.sync();
|
|
72
|
+
await file.close(); file = null;
|
|
73
|
+
await rename(tmp, path);
|
|
74
|
+
const directory = await open(await this.directory(origin, host), 'r');
|
|
75
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
76
|
+
} catch { throw storageError(); }
|
|
77
|
+
finally { await file?.close(); await unlink(tmp).catch(() => {}); }
|
|
78
|
+
}
|
|
79
|
+
/** Remove a record (and nothing else). Missing records are fine. */
|
|
80
|
+
async remove(origin, host, kind, id) {
|
|
81
|
+
try {
|
|
82
|
+
await unlink(await this.location(origin, host, kind, id));
|
|
83
|
+
const directory = await open(await this.directory(origin, host), 'r');
|
|
84
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
85
|
+
} catch (error) { if (error.code !== 'ENOENT') throw storageError(); }
|
|
86
|
+
}
|
|
87
|
+
async acquire(origin, host, kind, id) {
|
|
88
|
+
const path = `${await this.location(origin, host, kind, id)}.lock`;
|
|
89
|
+
let file;
|
|
90
|
+
try { file = await open(path, 'wx', 0o600); }
|
|
91
|
+
catch { throw new SafeError('This identity is locked by another operation. If its MCP process crashed, stop that process and remove only its stale .lock file before retrying.'); }
|
|
92
|
+
return async () => { await file.close(); await unlink(path); };
|
|
93
|
+
}
|
|
94
|
+
async lock(origin, host, kind, id, action) {
|
|
95
|
+
const release = await this.acquire(origin, host, kind, id);
|
|
96
|
+
try { return await action(); }
|
|
97
|
+
finally { await release(); }
|
|
98
|
+
}
|
|
99
|
+
}
|