@lobu/openclaw-plugin 6.0.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/README.md +40 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1244 -0
- package/dist/index.js.map +1 -0
- package/dist/openclaw.plugin.json +55 -0
- package/dist/owletto-guidance.d.ts +5 -0
- package/dist/owletto-guidance.d.ts.map +1 -0
- package/dist/owletto-guidance.js +40 -0
- package/dist/owletto-guidance.js.map +1 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/openclaw.plugin.json +55 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
import { exec as execCallback, execSync, spawn, spawnSync, } from 'node:child_process';
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { renderFallbackSystemContext } from './owletto-guidance.js';
|
|
8
|
+
const AUTH_REQUIRED_MSG = 'Lobu memory is not connected. Call the owletto_login tool to authenticate, then show the user the login URL and code. After the user completes login in their browser, call owletto_login_check to finish authentication.';
|
|
9
|
+
const DEFAULT_RECALL_LIMIT = 6;
|
|
10
|
+
// Minimal fallback context used before the workspace instructions are fetched.
|
|
11
|
+
// Initialized lazily per mode (gateway vs standalone) in register().
|
|
12
|
+
let FALLBACK_SYSTEM_CONTEXT = null;
|
|
13
|
+
// Workspace instructions fetched from MCP server (includes entity types, event kinds, schemas).
|
|
14
|
+
let cachedWorkspaceInstructions = null;
|
|
15
|
+
const DEFAULT_RPC_VERSION = '2.0';
|
|
16
|
+
const DEFAULT_MCP_SCOPE = 'mcp:read mcp:write profile:read';
|
|
17
|
+
const execAsync = promisify(execCallback);
|
|
18
|
+
const PLUGIN_VERSION = (() => {
|
|
19
|
+
try {
|
|
20
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const pkg = JSON.parse(readFileSync(resolve(dir, '../package.json'), 'utf-8'));
|
|
22
|
+
return pkg.version || '0.0.0';
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return '0.0.0';
|
|
26
|
+
}
|
|
27
|
+
})();
|
|
28
|
+
// Session-level token obtained via device code login flow
|
|
29
|
+
let sessionToken = null;
|
|
30
|
+
// Session-level refresh token for token renewal
|
|
31
|
+
let _sessionRefreshToken = null;
|
|
32
|
+
let sessionClientId = null;
|
|
33
|
+
let sessionClientSecret = null;
|
|
34
|
+
let sessionIssuer = null;
|
|
35
|
+
// MCP Streamable HTTP session ID (obtained from initialize handshake)
|
|
36
|
+
let mcpSessionId = null;
|
|
37
|
+
const MCP_PROTOCOL_VERSION = '2025-03-26';
|
|
38
|
+
// Make an MCP JSON-RPC request with session management.
|
|
39
|
+
// Server returns plain JSON when Accept doesn't include text/event-stream.
|
|
40
|
+
async function mcpFetch(url, body, extraHeaders) {
|
|
41
|
+
const headers = {
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
Accept: 'application/json',
|
|
44
|
+
...extraHeaders,
|
|
45
|
+
};
|
|
46
|
+
if (mcpSessionId) {
|
|
47
|
+
headers['Mcp-Session-Id'] = mcpSessionId;
|
|
48
|
+
}
|
|
49
|
+
const response = await fetch(url, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers,
|
|
52
|
+
body: JSON.stringify(body),
|
|
53
|
+
});
|
|
54
|
+
const newSessionId = response.headers.get('mcp-session-id');
|
|
55
|
+
if (newSessionId) {
|
|
56
|
+
mcpSessionId = newSessionId;
|
|
57
|
+
}
|
|
58
|
+
const data = await response.json();
|
|
59
|
+
return { data, response };
|
|
60
|
+
}
|
|
61
|
+
// Worker daemon process (auto-started after login)
|
|
62
|
+
let workerProcess = null;
|
|
63
|
+
function getTokenStorePath() {
|
|
64
|
+
return resolve(homedir(), '.owletto', 'openclaw-auth.json');
|
|
65
|
+
}
|
|
66
|
+
function normalizeMcpUrl(input) {
|
|
67
|
+
const url = new URL(input);
|
|
68
|
+
url.hash = '';
|
|
69
|
+
url.search = '';
|
|
70
|
+
if (!url.pathname || url.pathname === '/') {
|
|
71
|
+
url.pathname = '/mcp';
|
|
72
|
+
}
|
|
73
|
+
return url.toString().replace(/\/+$/, '');
|
|
74
|
+
}
|
|
75
|
+
/** Strip org suffix for session lookup: /mcp/acme → /mcp */
|
|
76
|
+
function baseMcpUrl(input) {
|
|
77
|
+
const url = new URL(input);
|
|
78
|
+
url.hash = '';
|
|
79
|
+
url.search = '';
|
|
80
|
+
url.pathname = '/mcp';
|
|
81
|
+
return url.toString().replace(/\/+$/, '');
|
|
82
|
+
}
|
|
83
|
+
function loadStoredSession(mcpUrl) {
|
|
84
|
+
try {
|
|
85
|
+
const raw = readFileSync(getTokenStorePath(), 'utf-8');
|
|
86
|
+
const store = JSON.parse(raw);
|
|
87
|
+
if (!store || store.version !== 1 || !store.sessions)
|
|
88
|
+
return null;
|
|
89
|
+
// Try exact match, then fall back to base /mcp
|
|
90
|
+
const key = normalizeMcpUrl(mcpUrl);
|
|
91
|
+
return store.sessions[key] || store.sessions[baseMcpUrl(mcpUrl)] || null;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function saveStoredSession(mcpUrl, data) {
|
|
98
|
+
const storePath = getTokenStorePath();
|
|
99
|
+
let store;
|
|
100
|
+
try {
|
|
101
|
+
const raw = readFileSync(storePath, 'utf-8');
|
|
102
|
+
const parsed = JSON.parse(raw);
|
|
103
|
+
store = parsed?.version === 1 && parsed.sessions ? parsed : { version: 1, sessions: {} };
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
store = { version: 1, sessions: {} };
|
|
107
|
+
}
|
|
108
|
+
const key = normalizeMcpUrl(mcpUrl);
|
|
109
|
+
store.sessions[key] = {
|
|
110
|
+
mcpUrl: key,
|
|
111
|
+
issuer: data.issuer,
|
|
112
|
+
clientId: data.clientId,
|
|
113
|
+
...(data.clientSecret ? { clientSecret: data.clientSecret } : {}),
|
|
114
|
+
refreshToken: data.refreshToken,
|
|
115
|
+
accessToken: data.accessToken,
|
|
116
|
+
updatedAt: new Date().toISOString(),
|
|
117
|
+
};
|
|
118
|
+
store.activeServer = key;
|
|
119
|
+
// Keep legacy field for backward compat with older CLI versions
|
|
120
|
+
store.activeContext = key;
|
|
121
|
+
mkdirSync(dirname(storePath), { recursive: true });
|
|
122
|
+
writeFileSync(storePath, JSON.stringify(store, null, 2) + '\n', { mode: 0o600 });
|
|
123
|
+
}
|
|
124
|
+
const fallbackLogger = {
|
|
125
|
+
info: (msg) => console.log(`[openclaw-owletto-plugin] INFO: ${msg}`),
|
|
126
|
+
warn: (msg) => console.warn(`[openclaw-owletto-plugin] WARN: ${msg}`),
|
|
127
|
+
error: (msg) => console.error(`[openclaw-owletto-plugin] ERROR: ${msg}`),
|
|
128
|
+
debug: (msg) => console.debug(`[openclaw-owletto-plugin] DEBUG: ${msg}`),
|
|
129
|
+
};
|
|
130
|
+
function isRecord(value) {
|
|
131
|
+
return typeof value === 'object' && value !== null;
|
|
132
|
+
}
|
|
133
|
+
function asString(value) {
|
|
134
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
135
|
+
}
|
|
136
|
+
function asBoolean(value, defaultValue) {
|
|
137
|
+
return typeof value === 'boolean' ? value : defaultValue;
|
|
138
|
+
}
|
|
139
|
+
function asPositiveInt(value, fallback) {
|
|
140
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
141
|
+
return fallback;
|
|
142
|
+
const n = Math.floor(value);
|
|
143
|
+
return n > 0 ? n : fallback;
|
|
144
|
+
}
|
|
145
|
+
function getLogger(api) {
|
|
146
|
+
const logger = api.logger;
|
|
147
|
+
if (isRecord(logger) &&
|
|
148
|
+
typeof logger.info === 'function' &&
|
|
149
|
+
typeof logger.warn === 'function' &&
|
|
150
|
+
typeof logger.error === 'function') {
|
|
151
|
+
return logger;
|
|
152
|
+
}
|
|
153
|
+
return fallbackLogger;
|
|
154
|
+
}
|
|
155
|
+
function getHookRegistrar(api) {
|
|
156
|
+
const on = api.on;
|
|
157
|
+
if (typeof on === 'function') {
|
|
158
|
+
return on;
|
|
159
|
+
}
|
|
160
|
+
return () => {
|
|
161
|
+
/* no-op */
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function readPluginConfig(api, pluginId) {
|
|
165
|
+
if (isRecord(api.pluginConfig)) {
|
|
166
|
+
return api.pluginConfig;
|
|
167
|
+
}
|
|
168
|
+
if (!isRecord(api.config)) {
|
|
169
|
+
return {};
|
|
170
|
+
}
|
|
171
|
+
const cfg = api.config;
|
|
172
|
+
const plugins = isRecord(cfg.plugins) ? cfg.plugins : null;
|
|
173
|
+
const entries = plugins && isRecord(plugins.entries) ? plugins.entries : null;
|
|
174
|
+
if (!entries)
|
|
175
|
+
return {};
|
|
176
|
+
const pluginEntry = entries[pluginId];
|
|
177
|
+
if (!isRecord(pluginEntry))
|
|
178
|
+
return {};
|
|
179
|
+
const pluginCfg = pluginEntry.config;
|
|
180
|
+
if (!isRecord(pluginCfg))
|
|
181
|
+
return {};
|
|
182
|
+
return pluginCfg;
|
|
183
|
+
}
|
|
184
|
+
function resolvePluginConfig(api, pluginId) {
|
|
185
|
+
const cfg = readPluginConfig(api, pluginId);
|
|
186
|
+
const mcpUrl = asString(cfg.mcpUrl);
|
|
187
|
+
const webUrl = asString(cfg.webUrl) ?? asString(process.env.OWLETTO_WEB_URL);
|
|
188
|
+
const token = asString(cfg.token) ?? asString(process.env.OWLETTO_MCP_TOKEN);
|
|
189
|
+
const tokenCommand = asString(cfg.tokenCommand) ?? asString(process.env.OWLETTO_MCP_TOKEN_COMMAND);
|
|
190
|
+
const gatewayAuthUrl = asString(cfg.gatewayAuthUrl) ?? asString(process.env.GATEWAY_AUTH_URL);
|
|
191
|
+
const headers = {};
|
|
192
|
+
if (isRecord(cfg.headers)) {
|
|
193
|
+
for (const [k, v] of Object.entries(cfg.headers)) {
|
|
194
|
+
if (typeof v === 'string' && k.trim().length > 0) {
|
|
195
|
+
headers[k] = v;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
mcpUrl,
|
|
201
|
+
webUrl,
|
|
202
|
+
token,
|
|
203
|
+
tokenCommand,
|
|
204
|
+
gatewayAuthUrl,
|
|
205
|
+
headers,
|
|
206
|
+
autoRecall: asBoolean(cfg.autoRecall, true),
|
|
207
|
+
autoCapture: asBoolean(cfg.autoCapture, true),
|
|
208
|
+
recallLimit: asPositiveInt(cfg.recallLimit, DEFAULT_RECALL_LIMIT),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function isAuthErrorMessage(message) {
|
|
212
|
+
return /invalid.token|expired|unauthorized|authentication|forbidden/i.test(message);
|
|
213
|
+
}
|
|
214
|
+
function parseErrorMessage(payload) {
|
|
215
|
+
if (typeof payload === 'string')
|
|
216
|
+
return payload;
|
|
217
|
+
if (isRecord(payload)) {
|
|
218
|
+
if (typeof payload.message === 'string')
|
|
219
|
+
return payload.message;
|
|
220
|
+
if (typeof payload.error === 'string')
|
|
221
|
+
return payload.error;
|
|
222
|
+
if (isRecord(payload.error) && typeof payload.error.message === 'string') {
|
|
223
|
+
return payload.error.message;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return 'Unknown MCP error';
|
|
227
|
+
}
|
|
228
|
+
class OwlettoAuthError extends Error {
|
|
229
|
+
constructor(message) {
|
|
230
|
+
super(message);
|
|
231
|
+
this.name = 'OwlettoAuthError';
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function resolveAuthToken(config) {
|
|
235
|
+
// In gateway mode, use worker token to authenticate with the MCP proxy
|
|
236
|
+
if (config.gatewayAuthUrl)
|
|
237
|
+
return getWorkerToken();
|
|
238
|
+
if (sessionToken)
|
|
239
|
+
return sessionToken;
|
|
240
|
+
if (config.token)
|
|
241
|
+
return config.token;
|
|
242
|
+
if (!config.tokenCommand)
|
|
243
|
+
return null;
|
|
244
|
+
const { stdout } = await execAsync(config.tokenCommand, {
|
|
245
|
+
timeout: 10_000,
|
|
246
|
+
maxBuffer: 1024 * 1024,
|
|
247
|
+
});
|
|
248
|
+
const token = stdout.trim();
|
|
249
|
+
if (!token) {
|
|
250
|
+
throw new Error('tokenCommand returned empty output');
|
|
251
|
+
}
|
|
252
|
+
return token;
|
|
253
|
+
}
|
|
254
|
+
function hasAuthConfigured(config) {
|
|
255
|
+
// In gateway mode, always return true — the proxy manages credentials
|
|
256
|
+
// and handles auth errors automatically via device-code flow.
|
|
257
|
+
if (config.gatewayAuthUrl)
|
|
258
|
+
return true;
|
|
259
|
+
return !!(sessionToken || config.token || config.tokenCommand);
|
|
260
|
+
}
|
|
261
|
+
function getWorkerToken() {
|
|
262
|
+
return asString(process.env.WORKER_TOKEN);
|
|
263
|
+
}
|
|
264
|
+
async function gatewayDeviceAuthStart(gatewayAuthUrl) {
|
|
265
|
+
const workerToken = getWorkerToken();
|
|
266
|
+
if (!workerToken)
|
|
267
|
+
throw new Error('WORKER_TOKEN not set');
|
|
268
|
+
const response = await fetch(`${gatewayAuthUrl}/internal/device-auth/start`, {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
headers: {
|
|
271
|
+
'Content-Type': 'application/json',
|
|
272
|
+
Authorization: `Bearer ${workerToken}`,
|
|
273
|
+
},
|
|
274
|
+
body: JSON.stringify({ mcpId: 'owletto' }),
|
|
275
|
+
});
|
|
276
|
+
if (!response.ok) {
|
|
277
|
+
const errText = await response.text();
|
|
278
|
+
throw new Error(`Gateway device auth start failed: ${errText}`);
|
|
279
|
+
}
|
|
280
|
+
return (await response.json());
|
|
281
|
+
}
|
|
282
|
+
async function gatewayDeviceAuthPoll(gatewayAuthUrl) {
|
|
283
|
+
const workerToken = getWorkerToken();
|
|
284
|
+
if (!workerToken)
|
|
285
|
+
throw new Error('WORKER_TOKEN not set');
|
|
286
|
+
const response = await fetch(`${gatewayAuthUrl}/internal/device-auth/poll`, {
|
|
287
|
+
method: 'POST',
|
|
288
|
+
headers: {
|
|
289
|
+
'Content-Type': 'application/json',
|
|
290
|
+
Authorization: `Bearer ${workerToken}`,
|
|
291
|
+
},
|
|
292
|
+
body: JSON.stringify({ mcpId: 'owletto' }),
|
|
293
|
+
});
|
|
294
|
+
return (await response.json());
|
|
295
|
+
}
|
|
296
|
+
async function gatewayDeviceAuthCheck(gatewayAuthUrl) {
|
|
297
|
+
const workerToken = getWorkerToken();
|
|
298
|
+
if (!workerToken)
|
|
299
|
+
return false;
|
|
300
|
+
try {
|
|
301
|
+
const response = await fetch(`${gatewayAuthUrl}/internal/device-auth/status?mcpId=owletto`, {
|
|
302
|
+
headers: { Authorization: `Bearer ${workerToken}` },
|
|
303
|
+
});
|
|
304
|
+
if (!response.ok)
|
|
305
|
+
return false;
|
|
306
|
+
const data = (await response.json());
|
|
307
|
+
return !!data.authenticated;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function clearSessionTokens() {
|
|
314
|
+
sessionToken = null;
|
|
315
|
+
_sessionRefreshToken = null;
|
|
316
|
+
}
|
|
317
|
+
function deriveOAuthBaseUrl(mcpUrl) {
|
|
318
|
+
const base = new URL(mcpUrl);
|
|
319
|
+
base.pathname = '/';
|
|
320
|
+
base.search = '';
|
|
321
|
+
base.hash = '';
|
|
322
|
+
return base.toString().replace(/\/$/, '');
|
|
323
|
+
}
|
|
324
|
+
function spawnWorkerDaemon(mcpUrl, accessToken, log) {
|
|
325
|
+
if (workerProcess) {
|
|
326
|
+
// Already running — check if the process is still alive
|
|
327
|
+
if (workerProcess.exitCode === null && !workerProcess.killed) {
|
|
328
|
+
log.info('owletto: worker daemon already running');
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
workerProcess = null;
|
|
332
|
+
}
|
|
333
|
+
const apiUrl = deriveOAuthBaseUrl(mcpUrl);
|
|
334
|
+
try {
|
|
335
|
+
workerProcess = spawn('npx', ['connector-worker', 'daemon', '--api-url', apiUrl], {
|
|
336
|
+
detached: true,
|
|
337
|
+
stdio: 'ignore',
|
|
338
|
+
env: { ...process.env, WORKER_API_TOKEN: accessToken },
|
|
339
|
+
});
|
|
340
|
+
workerProcess.unref();
|
|
341
|
+
log.info(`owletto: worker daemon spawned (pid=${workerProcess.pid})`);
|
|
342
|
+
// Clean up on process exit
|
|
343
|
+
const cleanup = () => {
|
|
344
|
+
if (workerProcess && workerProcess.exitCode === null && !workerProcess.killed) {
|
|
345
|
+
try {
|
|
346
|
+
workerProcess.kill();
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
// Best-effort cleanup
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
process.on('exit', cleanup);
|
|
354
|
+
process.on('SIGINT', cleanup);
|
|
355
|
+
process.on('SIGTERM', cleanup);
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
log.warn(`owletto: failed to spawn worker daemon: ${err instanceof Error ? err.message : String(err)}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async function initiateDeviceLogin(mcpUrl, scope, resource) {
|
|
362
|
+
const issuer = deriveOAuthBaseUrl(mcpUrl);
|
|
363
|
+
// Step 1: Dynamic client registration
|
|
364
|
+
const regResponse = await fetch(`${issuer}/oauth/register`, {
|
|
365
|
+
method: 'POST',
|
|
366
|
+
headers: { 'Content-Type': 'application/json' },
|
|
367
|
+
body: JSON.stringify({
|
|
368
|
+
grant_types: ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'],
|
|
369
|
+
token_endpoint_auth_method: 'none',
|
|
370
|
+
client_name: 'OpenClaw Owletto Plugin',
|
|
371
|
+
software_id: 'openclaw',
|
|
372
|
+
software_version: PLUGIN_VERSION,
|
|
373
|
+
scope,
|
|
374
|
+
}),
|
|
375
|
+
});
|
|
376
|
+
if (!regResponse.ok) {
|
|
377
|
+
const errText = await regResponse.text();
|
|
378
|
+
throw new Error(`Client registration failed: ${errText}`);
|
|
379
|
+
}
|
|
380
|
+
const registration = (await regResponse.json());
|
|
381
|
+
// Step 2: Request device authorization
|
|
382
|
+
const deviceResponse = await fetch(`${issuer}/oauth/device_authorization`, {
|
|
383
|
+
method: 'POST',
|
|
384
|
+
headers: { 'Content-Type': 'application/json' },
|
|
385
|
+
body: JSON.stringify({
|
|
386
|
+
client_id: registration.client_id,
|
|
387
|
+
scope,
|
|
388
|
+
resource,
|
|
389
|
+
}),
|
|
390
|
+
});
|
|
391
|
+
if (!deviceResponse.ok) {
|
|
392
|
+
const errText = await deviceResponse.text();
|
|
393
|
+
throw new Error(`Device authorization failed: ${errText}`);
|
|
394
|
+
}
|
|
395
|
+
const deviceAuth = (await deviceResponse.json());
|
|
396
|
+
return {
|
|
397
|
+
deviceCode: deviceAuth.device_code,
|
|
398
|
+
userCode: deviceAuth.user_code,
|
|
399
|
+
verificationUri: deviceAuth.verification_uri,
|
|
400
|
+
verificationUriComplete: deviceAuth.verification_uri_complete,
|
|
401
|
+
expiresIn: deviceAuth.expires_in,
|
|
402
|
+
interval: deviceAuth.interval,
|
|
403
|
+
clientId: registration.client_id,
|
|
404
|
+
clientSecret: registration.client_secret,
|
|
405
|
+
issuer,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
async function pollDeviceLogin(state) {
|
|
409
|
+
const body = {
|
|
410
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
411
|
+
client_id: state.clientId,
|
|
412
|
+
device_code: state.deviceCode,
|
|
413
|
+
};
|
|
414
|
+
if (state.clientSecret) {
|
|
415
|
+
body.client_secret = state.clientSecret;
|
|
416
|
+
}
|
|
417
|
+
const tokenResponse = await fetch(`${state.issuer}/oauth/token`, {
|
|
418
|
+
method: 'POST',
|
|
419
|
+
headers: { 'Content-Type': 'application/json' },
|
|
420
|
+
body: JSON.stringify(body),
|
|
421
|
+
});
|
|
422
|
+
const data = (await tokenResponse.json());
|
|
423
|
+
if (tokenResponse.ok && typeof data.access_token === 'string') {
|
|
424
|
+
return {
|
|
425
|
+
status: 'complete',
|
|
426
|
+
accessToken: data.access_token,
|
|
427
|
+
refreshToken: typeof data.refresh_token === 'string' ? data.refresh_token : undefined,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
const error = typeof data.error === 'string' ? data.error : '';
|
|
431
|
+
if (error === 'authorization_pending') {
|
|
432
|
+
return { status: 'pending', message: 'Waiting for user to approve in browser...' };
|
|
433
|
+
}
|
|
434
|
+
if (error === 'slow_down') {
|
|
435
|
+
return { status: 'pending', message: 'Polling too fast, slowing down...' };
|
|
436
|
+
}
|
|
437
|
+
if (error === 'expired_token') {
|
|
438
|
+
return { status: 'error', message: 'Device code expired. Please start login again.' };
|
|
439
|
+
}
|
|
440
|
+
if (error === 'access_denied') {
|
|
441
|
+
return { status: 'error', message: 'User denied the authorization request.' };
|
|
442
|
+
}
|
|
443
|
+
const desc = typeof data.error_description === 'string' ? data.error_description : error;
|
|
444
|
+
return { status: 'error', message: desc || 'Unknown error during login' };
|
|
445
|
+
}
|
|
446
|
+
async function tryRefreshToken(mcpUrl) {
|
|
447
|
+
if (!_sessionRefreshToken || !sessionClientId || !sessionIssuer)
|
|
448
|
+
return false;
|
|
449
|
+
try {
|
|
450
|
+
const body = {
|
|
451
|
+
grant_type: 'refresh_token',
|
|
452
|
+
client_id: sessionClientId,
|
|
453
|
+
refresh_token: _sessionRefreshToken,
|
|
454
|
+
};
|
|
455
|
+
if (sessionClientSecret) {
|
|
456
|
+
body.client_secret = sessionClientSecret;
|
|
457
|
+
}
|
|
458
|
+
const response = await fetch(`${sessionIssuer}/oauth/token`, {
|
|
459
|
+
method: 'POST',
|
|
460
|
+
headers: { 'Content-Type': 'application/json' },
|
|
461
|
+
body: JSON.stringify(body),
|
|
462
|
+
});
|
|
463
|
+
if (!response.ok)
|
|
464
|
+
return false;
|
|
465
|
+
const data = (await response.json());
|
|
466
|
+
if (typeof data.access_token !== 'string')
|
|
467
|
+
return false;
|
|
468
|
+
sessionToken = data.access_token;
|
|
469
|
+
if (typeof data.refresh_token === 'string') {
|
|
470
|
+
_sessionRefreshToken = data.refresh_token;
|
|
471
|
+
}
|
|
472
|
+
// Persist refreshed tokens
|
|
473
|
+
try {
|
|
474
|
+
saveStoredSession(mcpUrl, {
|
|
475
|
+
issuer: sessionIssuer,
|
|
476
|
+
clientId: sessionClientId,
|
|
477
|
+
clientSecret: sessionClientSecret,
|
|
478
|
+
refreshToken: _sessionRefreshToken,
|
|
479
|
+
accessToken: sessionToken,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
// Best-effort persist
|
|
484
|
+
}
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
async function reinitializeMcpSession(config) {
|
|
492
|
+
if (!config.mcpUrl)
|
|
493
|
+
return false;
|
|
494
|
+
const token = await resolveAuthToken(config);
|
|
495
|
+
const headers = {
|
|
496
|
+
'Content-Type': 'application/json',
|
|
497
|
+
Accept: 'application/json',
|
|
498
|
+
...config.headers,
|
|
499
|
+
};
|
|
500
|
+
if (token)
|
|
501
|
+
headers.Authorization = `Bearer ${token}`;
|
|
502
|
+
try {
|
|
503
|
+
const initRes = await fetch(config.mcpUrl, {
|
|
504
|
+
method: 'POST',
|
|
505
|
+
headers,
|
|
506
|
+
body: JSON.stringify({
|
|
507
|
+
jsonrpc: '2.0',
|
|
508
|
+
id: 'reinit',
|
|
509
|
+
method: 'initialize',
|
|
510
|
+
params: {
|
|
511
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
512
|
+
capabilities: {},
|
|
513
|
+
clientInfo: { name: 'openclaw-owletto', version: '1.0.0' },
|
|
514
|
+
},
|
|
515
|
+
}),
|
|
516
|
+
});
|
|
517
|
+
const sid = initRes.headers.get('mcp-session-id');
|
|
518
|
+
if (sid) {
|
|
519
|
+
mcpSessionId = sid;
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
async function callMcpTool(config, toolName, args) {
|
|
529
|
+
if (!config.mcpUrl)
|
|
530
|
+
return null;
|
|
531
|
+
const token = await resolveAuthToken(config);
|
|
532
|
+
const rpcId = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
533
|
+
const authHeaders = { ...config.headers };
|
|
534
|
+
if (token) {
|
|
535
|
+
authHeaders.Authorization = `Bearer ${token}`;
|
|
536
|
+
}
|
|
537
|
+
const rpcBody = {
|
|
538
|
+
jsonrpc: DEFAULT_RPC_VERSION,
|
|
539
|
+
id: rpcId,
|
|
540
|
+
method: 'tools/call',
|
|
541
|
+
params: { name: toolName, arguments: args },
|
|
542
|
+
};
|
|
543
|
+
let result;
|
|
544
|
+
try {
|
|
545
|
+
result = await mcpFetch(config.mcpUrl, rpcBody, authHeaders);
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
throw new Error(`MCP fetch failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
549
|
+
}
|
|
550
|
+
let { data, response } = result;
|
|
551
|
+
// Auto-refresh on 401/403 if we have a refresh token
|
|
552
|
+
if ((response.status === 401 || response.status === 403) && config.mcpUrl) {
|
|
553
|
+
const refreshed = await tryRefreshToken(config.mcpUrl);
|
|
554
|
+
if (refreshed && sessionToken) {
|
|
555
|
+
authHeaders.Authorization = `Bearer ${sessionToken}`;
|
|
556
|
+
const retryBody = { ...rpcBody, id: `${rpcId}-retry` };
|
|
557
|
+
const retry = await mcpFetch(config.mcpUrl, retryBody, authHeaders);
|
|
558
|
+
data = retry.data;
|
|
559
|
+
response = retry.response;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (response.status === 401 || response.status === 403) {
|
|
563
|
+
clearSessionTokens();
|
|
564
|
+
throw new OwlettoAuthError(AUTH_REQUIRED_MSG);
|
|
565
|
+
}
|
|
566
|
+
// Re-initialize MCP session on stale/missing session errors
|
|
567
|
+
if (response.status === 400 || response.status === 404) {
|
|
568
|
+
const errMsg = parseErrorMessage(data);
|
|
569
|
+
if (errMsg.includes('not initialized') ||
|
|
570
|
+
errMsg.includes('Unknown session') ||
|
|
571
|
+
errMsg.includes('Session not found')) {
|
|
572
|
+
const newSession = await reinitializeMcpSession(config);
|
|
573
|
+
if (newSession) {
|
|
574
|
+
const retryBody = { ...rpcBody, id: `${rpcId}-reinit` };
|
|
575
|
+
const retry = await mcpFetch(config.mcpUrl, retryBody, authHeaders);
|
|
576
|
+
data = retry.data;
|
|
577
|
+
response = retry.response;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
if (!response.ok) {
|
|
582
|
+
const errMsg = parseErrorMessage(data);
|
|
583
|
+
if (isAuthErrorMessage(errMsg)) {
|
|
584
|
+
clearSessionTokens();
|
|
585
|
+
throw new OwlettoAuthError(errMsg);
|
|
586
|
+
}
|
|
587
|
+
throw new Error(errMsg);
|
|
588
|
+
}
|
|
589
|
+
const rpcResponse = isRecord(data) ? data : {};
|
|
590
|
+
if (isRecord(rpcResponse.error) || typeof rpcResponse.error === 'string') {
|
|
591
|
+
const errMsg = parseErrorMessage(rpcResponse.error);
|
|
592
|
+
if (isAuthErrorMessage(errMsg)) {
|
|
593
|
+
clearSessionTokens();
|
|
594
|
+
throw new OwlettoAuthError(errMsg);
|
|
595
|
+
}
|
|
596
|
+
throw new Error(errMsg);
|
|
597
|
+
}
|
|
598
|
+
const rpcResult = isRecord(rpcResponse.result)
|
|
599
|
+
? rpcResponse.result
|
|
600
|
+
: rpcResponse;
|
|
601
|
+
if (rpcResult.isError === true) {
|
|
602
|
+
// Error text may be in rpcResult.error or in rpcResult.content[0].text
|
|
603
|
+
const contentText = Array.isArray(rpcResult.content)
|
|
604
|
+
? rpcResult.content
|
|
605
|
+
.filter((c) => c.type === 'text')
|
|
606
|
+
.map((c) => c.text)
|
|
607
|
+
.join('\n')
|
|
608
|
+
: '';
|
|
609
|
+
const errMsg = contentText || parseErrorMessage(rpcResult.error);
|
|
610
|
+
if (isAuthErrorMessage(errMsg)) {
|
|
611
|
+
clearSessionTokens();
|
|
612
|
+
throw new OwlettoAuthError(errMsg);
|
|
613
|
+
}
|
|
614
|
+
throw new Error(errMsg);
|
|
615
|
+
}
|
|
616
|
+
const content = Array.isArray(rpcResult.content)
|
|
617
|
+
? rpcResult.content
|
|
618
|
+
: [];
|
|
619
|
+
return { content, isError: false };
|
|
620
|
+
}
|
|
621
|
+
function extractTextFromContent(content) {
|
|
622
|
+
return content
|
|
623
|
+
.filter((c) => c.type === 'text' && typeof c.text === 'string')
|
|
624
|
+
.map((c) => c.text)
|
|
625
|
+
.join('\n');
|
|
626
|
+
}
|
|
627
|
+
async function fetchWorkspaceInstructions(config, log) {
|
|
628
|
+
try {
|
|
629
|
+
const token = await resolveAuthToken(config);
|
|
630
|
+
const authHeaders = { ...config.headers };
|
|
631
|
+
if (token)
|
|
632
|
+
authHeaders.Authorization = `Bearer ${token}`;
|
|
633
|
+
const { data, response } = await mcpFetch(config.mcpUrl, {
|
|
634
|
+
jsonrpc: '2.0',
|
|
635
|
+
id: 'init',
|
|
636
|
+
method: 'initialize',
|
|
637
|
+
params: {
|
|
638
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
639
|
+
capabilities: {},
|
|
640
|
+
clientInfo: { name: 'openclaw-owletto', version: '1.0.0' },
|
|
641
|
+
},
|
|
642
|
+
}, authHeaders);
|
|
643
|
+
if (!response.ok)
|
|
644
|
+
return;
|
|
645
|
+
const rpcResponse = isRecord(data) ? data : null;
|
|
646
|
+
const result = rpcResponse && isRecord(rpcResponse.result)
|
|
647
|
+
? rpcResponse.result
|
|
648
|
+
: null;
|
|
649
|
+
if (result && typeof result.instructions === 'string') {
|
|
650
|
+
cachedWorkspaceInstructions = result.instructions;
|
|
651
|
+
log.info('owletto: loaded workspace instructions after login');
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
catch (err) {
|
|
655
|
+
log.warn(`owletto: failed to fetch workspace instructions: ${err instanceof Error ? err.message : String(err)}`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
function fetchMcpBootstrapSync(config) {
|
|
659
|
+
if (!config.mcpUrl) {
|
|
660
|
+
return { tools: [], instructions: null, sessionId: null };
|
|
661
|
+
}
|
|
662
|
+
let token = sessionToken || config.token || null;
|
|
663
|
+
if (!token && config.tokenCommand) {
|
|
664
|
+
try {
|
|
665
|
+
token = execSync(config.tokenCommand, {
|
|
666
|
+
timeout: 10_000,
|
|
667
|
+
maxBuffer: 1024 * 1024,
|
|
668
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
669
|
+
})
|
|
670
|
+
.toString()
|
|
671
|
+
.trim();
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
return { tools: [], instructions: null, sessionId: null };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// Pass mcpUrl + auth token through env vars so neither the shell nor the
|
|
678
|
+
// node -e argument carries attacker-controlled text.
|
|
679
|
+
const script = `
|
|
680
|
+
const url = process.env.__MCP_URL;
|
|
681
|
+
const token = process.env.__MCP_TOKEN;
|
|
682
|
+
const base = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
|
683
|
+
if (token) base.Authorization = 'Bearer ' + token;
|
|
684
|
+
async function run() {
|
|
685
|
+
const initRes = await fetch(url, { method: 'POST', headers: base, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'openclaw-owletto', version: '1.0.0' } } }) });
|
|
686
|
+
const initData = await initRes.json();
|
|
687
|
+
const sid = initRes.headers.get('mcp-session-id');
|
|
688
|
+
const h2 = { ...base };
|
|
689
|
+
if (sid) h2['Mcp-Session-Id'] = sid;
|
|
690
|
+
const tlRes = await fetch(url, { method: 'POST', headers: h2, body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }) });
|
|
691
|
+
const tlData = await tlRes.json();
|
|
692
|
+
process.stdout.write(JSON.stringify({ tools: tlData?.result?.tools || [], instructions: initData?.result?.instructions || null, sessionId: sid || null }));
|
|
693
|
+
}
|
|
694
|
+
run().catch(() => process.stdout.write(JSON.stringify({ tools: [], instructions: null, sessionId: null })));
|
|
695
|
+
`;
|
|
696
|
+
try {
|
|
697
|
+
const output = spawnSync('node', ['-e', script], {
|
|
698
|
+
timeout: 15_000,
|
|
699
|
+
maxBuffer: 1024 * 1024,
|
|
700
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
701
|
+
env: {
|
|
702
|
+
...process.env,
|
|
703
|
+
__MCP_URL: config.mcpUrl,
|
|
704
|
+
__MCP_TOKEN: token ?? '',
|
|
705
|
+
},
|
|
706
|
+
})
|
|
707
|
+
.stdout?.toString()
|
|
708
|
+
.trim();
|
|
709
|
+
if (!output)
|
|
710
|
+
return { tools: [], instructions: null, sessionId: null };
|
|
711
|
+
return JSON.parse(output);
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
return { tools: [], instructions: null, sessionId: null };
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
function registerMcpTools(config, registerTool, log) {
|
|
718
|
+
const { tools, instructions, sessionId } = fetchMcpBootstrapSync(config);
|
|
719
|
+
if (sessionId) {
|
|
720
|
+
mcpSessionId = sessionId;
|
|
721
|
+
}
|
|
722
|
+
if (instructions) {
|
|
723
|
+
cachedWorkspaceInstructions = instructions;
|
|
724
|
+
log.info('owletto: loaded workspace instructions from MCP server');
|
|
725
|
+
}
|
|
726
|
+
if (tools.length === 0) {
|
|
727
|
+
log.warn('owletto: no MCP tools found (or fetch failed)');
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
for (const tool of tools) {
|
|
731
|
+
registerTool({
|
|
732
|
+
name: `owletto_${tool.name}`,
|
|
733
|
+
label: tool.name.replace(/_/g, ' '),
|
|
734
|
+
description: tool.description || `Owletto MCP tool: ${tool.name}`,
|
|
735
|
+
parameters: tool.inputSchema || { type: 'object', properties: {} },
|
|
736
|
+
execute: async (_id, args) => {
|
|
737
|
+
const result = await callMcpTool(config, tool.name, args);
|
|
738
|
+
return { content: result?.content ?? [], details: {} };
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
log.info(`owletto: registered ${tools.length} MCP tools`);
|
|
743
|
+
}
|
|
744
|
+
const plugin = {
|
|
745
|
+
id: 'openclaw-owletto',
|
|
746
|
+
name: 'Lobu Memory',
|
|
747
|
+
description: 'Lobu long-term memory plugin via MCP.',
|
|
748
|
+
kind: 'memory',
|
|
749
|
+
register(api) {
|
|
750
|
+
const log = getLogger(api);
|
|
751
|
+
const on = getHookRegistrar(api);
|
|
752
|
+
const registerTool = typeof api.registerTool === 'function'
|
|
753
|
+
? api.registerTool
|
|
754
|
+
: undefined;
|
|
755
|
+
const config = resolvePluginConfig(api, plugin.id);
|
|
756
|
+
if (!config.mcpUrl) {
|
|
757
|
+
log.warn('owletto: missing config.mcpUrl (plugins.entries.openclaw-owletto.config.mcpUrl)');
|
|
758
|
+
}
|
|
759
|
+
// Initialize fallback system context based on mode
|
|
760
|
+
FALLBACK_SYSTEM_CONTEXT = renderFallbackSystemContext({
|
|
761
|
+
gatewayMode: !!config.gatewayAuthUrl,
|
|
762
|
+
});
|
|
763
|
+
// Gateway mode: proxy handles auth + tools. Nothing to check at startup.
|
|
764
|
+
// Load persisted token if no auth is configured via config/env (standalone mode only)
|
|
765
|
+
if (config.mcpUrl &&
|
|
766
|
+
!config.gatewayAuthUrl &&
|
|
767
|
+
!config.token &&
|
|
768
|
+
!config.tokenCommand &&
|
|
769
|
+
!sessionToken) {
|
|
770
|
+
const stored = loadStoredSession(config.mcpUrl);
|
|
771
|
+
if (stored?.accessToken) {
|
|
772
|
+
sessionToken = stored.accessToken;
|
|
773
|
+
_sessionRefreshToken = stored.refreshToken || null;
|
|
774
|
+
sessionClientId = stored.clientId || null;
|
|
775
|
+
sessionClientSecret = stored.clientSecret || null;
|
|
776
|
+
sessionIssuer = stored.issuer || null;
|
|
777
|
+
// Proactively refresh the token — the persisted access token may be expired
|
|
778
|
+
if (_sessionRefreshToken && sessionIssuer && sessionClientId) {
|
|
779
|
+
try {
|
|
780
|
+
const body = {
|
|
781
|
+
grant_type: 'refresh_token',
|
|
782
|
+
client_id: sessionClientId,
|
|
783
|
+
refresh_token: _sessionRefreshToken,
|
|
784
|
+
};
|
|
785
|
+
if (sessionClientSecret)
|
|
786
|
+
body.client_secret = sessionClientSecret;
|
|
787
|
+
// spawnSync imported at top-level (ESM-safe)
|
|
788
|
+
const scriptCode = [
|
|
789
|
+
'async function run() {',
|
|
790
|
+
` const r = await fetch(${JSON.stringify(sessionIssuer + '/oauth/token')}, {`,
|
|
791
|
+
' method: "POST",',
|
|
792
|
+
' headers: { "Content-Type": "application/json" },',
|
|
793
|
+
` body: ${JSON.stringify(JSON.stringify(body))},`,
|
|
794
|
+
' });',
|
|
795
|
+
' if (!r.ok) return;',
|
|
796
|
+
' const d = await r.json();',
|
|
797
|
+
' process.stdout.write(JSON.stringify({ access_token: d.access_token, refresh_token: d.refresh_token }));',
|
|
798
|
+
'}',
|
|
799
|
+
'run().catch(() => {});',
|
|
800
|
+
].join('\n');
|
|
801
|
+
const proc = spawnSync('node', ['-e', scriptCode], {
|
|
802
|
+
timeout: 10_000,
|
|
803
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
804
|
+
});
|
|
805
|
+
const out = proc.stdout?.toString().trim() ?? '';
|
|
806
|
+
if (out) {
|
|
807
|
+
const tokens = JSON.parse(out);
|
|
808
|
+
if (tokens.access_token) {
|
|
809
|
+
sessionToken = tokens.access_token;
|
|
810
|
+
if (tokens.refresh_token)
|
|
811
|
+
_sessionRefreshToken = tokens.refresh_token;
|
|
812
|
+
saveStoredSession(config.mcpUrl, {
|
|
813
|
+
issuer: sessionIssuer,
|
|
814
|
+
clientId: sessionClientId,
|
|
815
|
+
clientSecret: sessionClientSecret,
|
|
816
|
+
refreshToken: _sessionRefreshToken,
|
|
817
|
+
accessToken: sessionToken,
|
|
818
|
+
});
|
|
819
|
+
log.info('owletto: refreshed expired access token');
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
catch (refreshErr) {
|
|
824
|
+
log.warn(`owletto: token refresh failed: ${refreshErr instanceof Error ? refreshErr.message : String(refreshErr)}`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
// Auto-start worker daemon with (possibly refreshed) token
|
|
828
|
+
spawnWorkerDaemon(config.mcpUrl, sessionToken, log);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// Track active device login state for the session
|
|
832
|
+
let activeDeviceLogin = null;
|
|
833
|
+
// Register login tools (standalone mode only — in gateway mode the proxy
|
|
834
|
+
// auto-completes device-auth, so these tools are unnecessary)
|
|
835
|
+
if (registerTool && config.mcpUrl && !config.gatewayAuthUrl) {
|
|
836
|
+
const mcpUrl = config.mcpUrl;
|
|
837
|
+
registerTool({
|
|
838
|
+
name: 'owletto_login',
|
|
839
|
+
label: 'Owletto Login',
|
|
840
|
+
description: 'Start Lobu memory authentication. Only call this if other Lobu memory tools return authentication errors. If Lobu memory is already connected, skip this step. Returns a URL and code for the user to complete login in their browser. After the user completes login, call owletto_login_check to finish.',
|
|
841
|
+
parameters: {
|
|
842
|
+
type: 'object',
|
|
843
|
+
properties: {},
|
|
844
|
+
},
|
|
845
|
+
execute: async () => {
|
|
846
|
+
try {
|
|
847
|
+
// Gateway mode: delegate to gateway device-auth endpoints
|
|
848
|
+
if (config.gatewayAuthUrl) {
|
|
849
|
+
// Check if already authenticated via gateway
|
|
850
|
+
const alreadyAuth = await gatewayDeviceAuthCheck(config.gatewayAuthUrl);
|
|
851
|
+
if (alreadyAuth) {
|
|
852
|
+
return {
|
|
853
|
+
content: [
|
|
854
|
+
{
|
|
855
|
+
type: 'text',
|
|
856
|
+
text: JSON.stringify({
|
|
857
|
+
status: 'already_authenticated',
|
|
858
|
+
message: "You are already authenticated with Owletto. Do NOT call owletto_login again. Proceed directly with the user's request using the available owletto tools.",
|
|
859
|
+
}),
|
|
860
|
+
},
|
|
861
|
+
],
|
|
862
|
+
details: {},
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
const started = await gatewayDeviceAuthStart(config.gatewayAuthUrl);
|
|
866
|
+
return {
|
|
867
|
+
content: [
|
|
868
|
+
{
|
|
869
|
+
type: 'text',
|
|
870
|
+
text: JSON.stringify({
|
|
871
|
+
status: 'login_started',
|
|
872
|
+
message: 'Open this URL in your browser and enter the code to connect Owletto:',
|
|
873
|
+
verification_url: started.verificationUriComplete || started.verificationUri,
|
|
874
|
+
user_code: started.userCode,
|
|
875
|
+
expires_in_seconds: started.expiresIn,
|
|
876
|
+
next_step: 'After the user completes login in their browser, call owletto_login_check to finish authentication.',
|
|
877
|
+
}),
|
|
878
|
+
},
|
|
879
|
+
],
|
|
880
|
+
details: {},
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
// Standalone mode: direct device flow
|
|
884
|
+
if (sessionToken) {
|
|
885
|
+
return {
|
|
886
|
+
content: [
|
|
887
|
+
{
|
|
888
|
+
type: 'text',
|
|
889
|
+
text: JSON.stringify({
|
|
890
|
+
status: 'already_authenticated',
|
|
891
|
+
message: "You are already authenticated with Owletto. Do NOT call owletto_login again. Proceed directly with the user's request using the available owletto tools (owletto_search to discover SDK methods, owletto_execute to run TypeScript over the typed client SDK, owletto_search_knowledge for entity/knowledge search, owletto_save_knowledge to persist).",
|
|
892
|
+
}),
|
|
893
|
+
},
|
|
894
|
+
],
|
|
895
|
+
details: {},
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
const resource = mcpUrl;
|
|
899
|
+
activeDeviceLogin = await initiateDeviceLogin(mcpUrl, DEFAULT_MCP_SCOPE, resource);
|
|
900
|
+
return {
|
|
901
|
+
content: [
|
|
902
|
+
{
|
|
903
|
+
type: 'text',
|
|
904
|
+
text: JSON.stringify({
|
|
905
|
+
status: 'login_started',
|
|
906
|
+
message: 'Open this URL in your browser and enter the code to connect Owletto:',
|
|
907
|
+
verification_url: activeDeviceLogin.verificationUriComplete,
|
|
908
|
+
user_code: activeDeviceLogin.userCode,
|
|
909
|
+
expires_in_seconds: activeDeviceLogin.expiresIn,
|
|
910
|
+
next_step: 'After the user completes login in their browser, call owletto_login_check to finish authentication.',
|
|
911
|
+
}),
|
|
912
|
+
},
|
|
913
|
+
],
|
|
914
|
+
details: {},
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
catch (err) {
|
|
918
|
+
return {
|
|
919
|
+
content: [
|
|
920
|
+
{
|
|
921
|
+
type: 'text',
|
|
922
|
+
text: JSON.stringify({
|
|
923
|
+
status: 'error',
|
|
924
|
+
message: `Login failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
925
|
+
}),
|
|
926
|
+
},
|
|
927
|
+
],
|
|
928
|
+
details: {},
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
registerTool({
|
|
934
|
+
name: 'owletto_login_check',
|
|
935
|
+
label: 'Owletto Login Check',
|
|
936
|
+
description: 'Check if the user has completed Owletto login in their browser. Call this after owletto_login. Returns success when authenticated, or pending if still waiting.',
|
|
937
|
+
parameters: {
|
|
938
|
+
type: 'object',
|
|
939
|
+
properties: {},
|
|
940
|
+
},
|
|
941
|
+
execute: async () => {
|
|
942
|
+
try {
|
|
943
|
+
// Gateway mode: poll gateway for completion
|
|
944
|
+
if (config.gatewayAuthUrl) {
|
|
945
|
+
const result = await gatewayDeviceAuthPoll(config.gatewayAuthUrl);
|
|
946
|
+
if (result.status === 'complete') {
|
|
947
|
+
log.info('owletto: gateway device auth completed');
|
|
948
|
+
// Fetch workspace instructions now that we're authenticated
|
|
949
|
+
if (!cachedWorkspaceInstructions) {
|
|
950
|
+
fetchWorkspaceInstructions(config, log);
|
|
951
|
+
}
|
|
952
|
+
return {
|
|
953
|
+
content: [
|
|
954
|
+
{
|
|
955
|
+
type: 'text',
|
|
956
|
+
text: JSON.stringify({
|
|
957
|
+
status: 'authenticated',
|
|
958
|
+
message: 'Owletto login successful! Memory tools are now available for this session.',
|
|
959
|
+
}),
|
|
960
|
+
},
|
|
961
|
+
],
|
|
962
|
+
details: {},
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
if (result.status === 'pending') {
|
|
966
|
+
return {
|
|
967
|
+
content: [
|
|
968
|
+
{
|
|
969
|
+
type: 'text',
|
|
970
|
+
text: JSON.stringify({
|
|
971
|
+
status: 'pending',
|
|
972
|
+
message: 'Waiting for user to approve in browser...',
|
|
973
|
+
next_step: 'Wait a few seconds, then call owletto_login_check again.',
|
|
974
|
+
}),
|
|
975
|
+
},
|
|
976
|
+
],
|
|
977
|
+
details: {},
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
return {
|
|
981
|
+
content: [
|
|
982
|
+
{
|
|
983
|
+
type: 'text',
|
|
984
|
+
text: JSON.stringify({
|
|
985
|
+
status: 'error',
|
|
986
|
+
message: result.message || 'Device auth failed',
|
|
987
|
+
}),
|
|
988
|
+
},
|
|
989
|
+
],
|
|
990
|
+
details: {},
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
// Standalone mode: direct device flow polling
|
|
994
|
+
if (!activeDeviceLogin) {
|
|
995
|
+
return {
|
|
996
|
+
content: [
|
|
997
|
+
{
|
|
998
|
+
type: 'text',
|
|
999
|
+
text: JSON.stringify({
|
|
1000
|
+
status: 'error',
|
|
1001
|
+
message: 'No login in progress. Call owletto_login first.',
|
|
1002
|
+
}),
|
|
1003
|
+
},
|
|
1004
|
+
],
|
|
1005
|
+
details: {},
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
const result = await pollDeviceLogin(activeDeviceLogin);
|
|
1009
|
+
if (result.status === 'complete') {
|
|
1010
|
+
sessionToken = result.accessToken;
|
|
1011
|
+
_sessionRefreshToken = result.refreshToken || null;
|
|
1012
|
+
sessionClientId = activeDeviceLogin.clientId;
|
|
1013
|
+
sessionClientSecret = activeDeviceLogin.clientSecret || null;
|
|
1014
|
+
sessionIssuer = activeDeviceLogin.issuer;
|
|
1015
|
+
if (result.refreshToken) {
|
|
1016
|
+
try {
|
|
1017
|
+
saveStoredSession(mcpUrl, {
|
|
1018
|
+
issuer: sessionIssuer,
|
|
1019
|
+
clientId: sessionClientId,
|
|
1020
|
+
clientSecret: sessionClientSecret,
|
|
1021
|
+
refreshToken: result.refreshToken,
|
|
1022
|
+
accessToken: result.accessToken,
|
|
1023
|
+
});
|
|
1024
|
+
log.info('owletto: persisted auth token to disk');
|
|
1025
|
+
}
|
|
1026
|
+
catch (err) {
|
|
1027
|
+
log.warn(`owletto: failed to persist auth token: ${err instanceof Error ? err.message : String(err)}`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
config.token = result.accessToken;
|
|
1031
|
+
activeDeviceLogin = null;
|
|
1032
|
+
spawnWorkerDaemon(mcpUrl, result.accessToken, log);
|
|
1033
|
+
if (!cachedWorkspaceInstructions) {
|
|
1034
|
+
fetchWorkspaceInstructions(config, log);
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
content: [
|
|
1038
|
+
{
|
|
1039
|
+
type: 'text',
|
|
1040
|
+
text: JSON.stringify({
|
|
1041
|
+
status: 'authenticated',
|
|
1042
|
+
message: 'Owletto login successful! Memory tools are now available for this session.',
|
|
1043
|
+
}),
|
|
1044
|
+
},
|
|
1045
|
+
],
|
|
1046
|
+
details: {},
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
if (result.status === 'pending') {
|
|
1050
|
+
return {
|
|
1051
|
+
content: [
|
|
1052
|
+
{
|
|
1053
|
+
type: 'text',
|
|
1054
|
+
text: JSON.stringify({
|
|
1055
|
+
status: 'pending',
|
|
1056
|
+
message: result.message,
|
|
1057
|
+
next_step: 'Wait a few seconds, then call owletto_login_check again.',
|
|
1058
|
+
}),
|
|
1059
|
+
},
|
|
1060
|
+
],
|
|
1061
|
+
details: {},
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
activeDeviceLogin = null;
|
|
1065
|
+
return {
|
|
1066
|
+
content: [
|
|
1067
|
+
{
|
|
1068
|
+
type: 'text',
|
|
1069
|
+
text: JSON.stringify({
|
|
1070
|
+
status: 'error',
|
|
1071
|
+
message: result.message,
|
|
1072
|
+
}),
|
|
1073
|
+
},
|
|
1074
|
+
],
|
|
1075
|
+
details: {},
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
catch (err) {
|
|
1079
|
+
return {
|
|
1080
|
+
content: [
|
|
1081
|
+
{
|
|
1082
|
+
type: 'text',
|
|
1083
|
+
text: JSON.stringify({
|
|
1084
|
+
status: 'error',
|
|
1085
|
+
message: `Login check failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1086
|
+
}),
|
|
1087
|
+
},
|
|
1088
|
+
],
|
|
1089
|
+
details: {},
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
},
|
|
1093
|
+
});
|
|
1094
|
+
log.info('owletto: registered login tools (owletto_login, owletto_login_check)');
|
|
1095
|
+
}
|
|
1096
|
+
// Dynamic tool registration from MCP server (synchronous so tools are
|
|
1097
|
+
// available before OpenClaw builds the prompt).
|
|
1098
|
+
// In gateway mode, tools are already registered above.
|
|
1099
|
+
if (registerTool && config.mcpUrl && !config.gatewayAuthUrl && hasAuthConfigured(config)) {
|
|
1100
|
+
registerMcpTools(config, registerTool, log);
|
|
1101
|
+
}
|
|
1102
|
+
// Inject workspace instructions (dynamic from server) or fallback (static).
|
|
1103
|
+
// When autoRecall is enabled, also inject recalled memories.
|
|
1104
|
+
{
|
|
1105
|
+
const getSystemContext = () => cachedWorkspaceInstructions
|
|
1106
|
+
? `<owletto-system>\n${cachedWorkspaceInstructions}\n</owletto-system>`
|
|
1107
|
+
: FALLBACK_SYSTEM_CONTEXT;
|
|
1108
|
+
const doRecall = async (query) => {
|
|
1109
|
+
if (!config.autoRecall || !hasAuthConfigured(config)) {
|
|
1110
|
+
return '';
|
|
1111
|
+
}
|
|
1112
|
+
try {
|
|
1113
|
+
const result = await callMcpTool(config, 'search_knowledge', {
|
|
1114
|
+
query,
|
|
1115
|
+
include_content: true,
|
|
1116
|
+
content_limit: config.recallLimit,
|
|
1117
|
+
include_connections: false,
|
|
1118
|
+
limit: 3,
|
|
1119
|
+
});
|
|
1120
|
+
if (!result)
|
|
1121
|
+
return '';
|
|
1122
|
+
const text = extractTextFromContent(result.content);
|
|
1123
|
+
if (!text.trim())
|
|
1124
|
+
return '';
|
|
1125
|
+
return ('<owletto-memory>\n' +
|
|
1126
|
+
"Use these long-term memories only when directly relevant to the user's request.\n" +
|
|
1127
|
+
'Do not mention this memory block unless needed.\n\n' +
|
|
1128
|
+
text +
|
|
1129
|
+
'\n</owletto-memory>');
|
|
1130
|
+
}
|
|
1131
|
+
catch (err) {
|
|
1132
|
+
if (err instanceof OwlettoAuthError) {
|
|
1133
|
+
return '';
|
|
1134
|
+
}
|
|
1135
|
+
log.error(`owletto recall failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1136
|
+
return '';
|
|
1137
|
+
}
|
|
1138
|
+
};
|
|
1139
|
+
const buildPrependContext = (recallBlock) => ({
|
|
1140
|
+
prependContext: getSystemContext() + (recallBlock ? '\n' + recallBlock : ''),
|
|
1141
|
+
});
|
|
1142
|
+
on('before_prompt_build', async (event) => {
|
|
1143
|
+
const prompt = event.prompt;
|
|
1144
|
+
const messages = event.messages;
|
|
1145
|
+
let query = null;
|
|
1146
|
+
if (Array.isArray(messages)) {
|
|
1147
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1148
|
+
const m = messages[i];
|
|
1149
|
+
if (!isRecord(m) || m.role !== 'user')
|
|
1150
|
+
continue;
|
|
1151
|
+
if (typeof m.content === 'string' && m.content.trim()) {
|
|
1152
|
+
query = m.content.trim();
|
|
1153
|
+
break;
|
|
1154
|
+
}
|
|
1155
|
+
if (Array.isArray(m.content)) {
|
|
1156
|
+
const textParts = m.content
|
|
1157
|
+
.filter((part) => isRecord(part) && part.type === 'text')
|
|
1158
|
+
.map((part) => (isRecord(part) && typeof part.text === 'string' ? part.text : ''))
|
|
1159
|
+
.filter((text) => text.trim().length > 0);
|
|
1160
|
+
if (textParts.length > 0) {
|
|
1161
|
+
query = textParts.join('\n').trim();
|
|
1162
|
+
break;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
if (!query && typeof prompt === 'string' && prompt.trim()) {
|
|
1168
|
+
query = prompt.trim();
|
|
1169
|
+
}
|
|
1170
|
+
if (!query)
|
|
1171
|
+
return;
|
|
1172
|
+
// Skip injection for heartbeats and internal events
|
|
1173
|
+
if (/heartbeat|question:q_/i.test(query))
|
|
1174
|
+
return;
|
|
1175
|
+
const recallBlock = await doRecall(query);
|
|
1176
|
+
return buildPrependContext(recallBlock);
|
|
1177
|
+
});
|
|
1178
|
+
on('before_agent_start', async (event) => {
|
|
1179
|
+
const prompt = event.prompt;
|
|
1180
|
+
if (typeof prompt !== 'string' || !prompt.trim())
|
|
1181
|
+
return;
|
|
1182
|
+
if (/heartbeat|question:q_/i.test(prompt))
|
|
1183
|
+
return;
|
|
1184
|
+
const recallBlock = await doRecall(prompt.trim());
|
|
1185
|
+
return buildPrependContext(recallBlock);
|
|
1186
|
+
});
|
|
1187
|
+
}
|
|
1188
|
+
if (config.autoCapture) {
|
|
1189
|
+
let lastCapturedLen = 0;
|
|
1190
|
+
on('before_prompt_build', async (event) => {
|
|
1191
|
+
if (!hasAuthConfigured(config))
|
|
1192
|
+
return;
|
|
1193
|
+
const messages = event.messages;
|
|
1194
|
+
if (!Array.isArray(messages) || messages.length < 2)
|
|
1195
|
+
return;
|
|
1196
|
+
// Only capture when new messages appeared since last capture
|
|
1197
|
+
if (messages.length <= lastCapturedLen)
|
|
1198
|
+
return;
|
|
1199
|
+
// Find the most recent assistant+user pair (the previous turn)
|
|
1200
|
+
let lastUser = null;
|
|
1201
|
+
let lastAssistant = null;
|
|
1202
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1203
|
+
const m = messages[i];
|
|
1204
|
+
if (!isRecord(m))
|
|
1205
|
+
continue;
|
|
1206
|
+
const text = typeof m.content === 'string'
|
|
1207
|
+
? m.content
|
|
1208
|
+
: Array.isArray(m.content)
|
|
1209
|
+
? m.content
|
|
1210
|
+
.filter((p) => isRecord(p) && p.type === 'text')
|
|
1211
|
+
.map((p) => (isRecord(p) && typeof p.text === 'string' ? p.text : ''))
|
|
1212
|
+
.join('\n')
|
|
1213
|
+
: '';
|
|
1214
|
+
if (!text.trim())
|
|
1215
|
+
continue;
|
|
1216
|
+
if (m.role === 'assistant' && !lastAssistant)
|
|
1217
|
+
lastAssistant = text.trim();
|
|
1218
|
+
if (m.role === 'user' && !lastUser)
|
|
1219
|
+
lastUser = text.trim();
|
|
1220
|
+
if (lastUser && lastAssistant)
|
|
1221
|
+
break;
|
|
1222
|
+
}
|
|
1223
|
+
if (!lastUser || !lastAssistant)
|
|
1224
|
+
return;
|
|
1225
|
+
const combined = `User: ${lastUser}\nAssistant: ${lastAssistant}`;
|
|
1226
|
+
if (combined.length < 16 || combined.includes('<owletto-memory>'))
|
|
1227
|
+
return;
|
|
1228
|
+
lastCapturedLen = messages.length;
|
|
1229
|
+
const content = combined.length > 2000 ? combined.slice(0, 2000) : combined;
|
|
1230
|
+
// Fire-and-forget — don't block prompt build
|
|
1231
|
+
callMcpTool(config, 'save_knowledge', {
|
|
1232
|
+
content,
|
|
1233
|
+
semantic_type: 'observation',
|
|
1234
|
+
metadata: {},
|
|
1235
|
+
})
|
|
1236
|
+
.then(() => log.info('owletto: captured conversation observation'))
|
|
1237
|
+
.catch((err) => log.warn(`owletto: autoCapture failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
log.info(`owletto: initialized (configured=${!!config.mcpUrl}, token=${!!config.token}, tokenCommand=${!!config.tokenCommand}, tools=${!!registerTool})`);
|
|
1241
|
+
},
|
|
1242
|
+
};
|
|
1243
|
+
export default plugin;
|
|
1244
|
+
//# sourceMappingURL=index.js.map
|