@ouro.bot/cli 0.1.0-alpha.216 → 0.1.0-alpha.217
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/changelog.json +11 -0
- package/dist/heart/outlook/outlook-http.js +2 -2
- package/dist/repertoire/api-client.js +31 -2
- package/dist/repertoire/bitwarden-client.js +290 -0
- package/dist/repertoire/guardrails.js +52 -1
- package/dist/repertoire/tools-base.js +4 -0
- package/dist/repertoire/tools-travel.js +125 -0
- package/dist/repertoire/tools-vault.js +185 -0
- package/dist/repertoire/travel-api-client.js +224 -0
- package/package.json +1 -1
- package/skills/browser-navigation.md +110 -0
- package/skills/travel-planning.md +143 -0
package/changelog.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.217",
|
|
6
|
+
"changes": [
|
|
7
|
+
"feat: Bitwarden vault client (bw CLI wrapper) with credential gateway — BitwardenClient class with SDK-first/CLI-fallback, singleton accessor, nerves events on all operations. Raw secrets never enter model context.",
|
|
8
|
+
"feat: vault tools (vault_get, vault_store, vault_list, vault_delete) with trust gating — read ops require friend+, write/delete require family-only. Destructive operations require confirmation.",
|
|
9
|
+
"feat: stealth browser MCP configuration with trust gating — Playwright MCP auto-provisions when configured, browser tools appear in agent tool list. Trust-gated to CLI and trusted 1:1 only (group chat blocked).",
|
|
10
|
+
"feat: travel API tools (weather_lookup, travel_advisory, geocode_search) — native weather via OpenWeatherMap + vault-backed API key, State Dept RSS feed for advisories, geocoding via Nominatim. All friend+ trust-gated.",
|
|
11
|
+
"feat: credential gateway (vaultKey on apiRequest()) — automatic secret injection from vault into HTTP headers at call time, keeping credentials out of model context entirely.",
|
|
12
|
+
"feat: travel-planning and browser-navigation skills"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
4
15
|
{
|
|
5
16
|
"version": "0.1.0-alpha.216",
|
|
6
17
|
"changes": [
|
|
@@ -398,14 +398,14 @@ async function startOutlookHttpServer(options = {}) {
|
|
|
398
398
|
writeJson(response, 404, { ok: false, error: `unknown agent surface: ${surface}` });
|
|
399
399
|
return;
|
|
400
400
|
}
|
|
401
|
-
/* v8 ignore start — SPA fallback for client-side routing */
|
|
401
|
+
/* v8 ignore start — SPA fallback for client-side routing @preserve */
|
|
402
402
|
const spaDir = resolveSpaDistDir();
|
|
403
403
|
if (spaDir) {
|
|
404
404
|
if (serveStaticFile(response, path.join(spaDir, "index.html")))
|
|
405
405
|
return;
|
|
406
406
|
}
|
|
407
|
-
/* v8 ignore stop */
|
|
408
407
|
writeJson(response, 404, { ok: false, error: `not found: ${pathname}` });
|
|
408
|
+
/* v8 ignore stop */
|
|
409
409
|
});
|
|
410
410
|
await new Promise((resolve, reject) => {
|
|
411
411
|
server.once("error", reject);
|
|
@@ -10,12 +10,41 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
10
10
|
exports.apiRequest = apiRequest;
|
|
11
11
|
const api_error_1 = require("../heart/api-error");
|
|
12
12
|
const runtime_1 = require("../nerves/runtime");
|
|
13
|
+
const bitwarden_client_1 = require("./bitwarden-client");
|
|
13
14
|
/**
|
|
14
15
|
* Execute an authenticated API request with standard logging and error handling.
|
|
15
16
|
* Returns the response body as a pretty-printed JSON string.
|
|
16
17
|
*/
|
|
17
18
|
async function apiRequest(options) {
|
|
18
|
-
const { baseUrl, method, path, token, clientName, serviceLabel, connectionName, body, extraHeaders, contentType, eventMeta, } = options;
|
|
19
|
+
const { baseUrl, method, path, token, clientName, serviceLabel, connectionName, body, extraHeaders, contentType, eventMeta, vaultKey, vaultField, } = options;
|
|
20
|
+
// Resolve token from vault if vaultKey is provided
|
|
21
|
+
let resolvedToken = token;
|
|
22
|
+
if (vaultKey) {
|
|
23
|
+
try {
|
|
24
|
+
const client = (0, bitwarden_client_1.getBitwardenClient)();
|
|
25
|
+
if (!client.isConnected()) {
|
|
26
|
+
return (0, api_error_1.handleApiError)(new Error("Vault is locked — connect the vault first"), serviceLabel, connectionName);
|
|
27
|
+
}
|
|
28
|
+
resolvedToken = await client.getRawSecret(vaultKey, vaultField ?? "apiKey");
|
|
29
|
+
(0, runtime_1.emitNervesEvent)({
|
|
30
|
+
event: "client.vault_fetch",
|
|
31
|
+
component: "clients",
|
|
32
|
+
message: `fetched credential from vault for ${serviceLabel}`,
|
|
33
|
+
meta: { client: clientName, vaultKey },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
(0, runtime_1.emitNervesEvent)({
|
|
38
|
+
level: "error",
|
|
39
|
+
event: "client.vault_error",
|
|
40
|
+
component: "clients",
|
|
41
|
+
message: `failed to fetch vault credential for ${serviceLabel}`,
|
|
42
|
+
/* v8 ignore next -- defensive: getRawSecret throws Error instances @preserve */
|
|
43
|
+
meta: { client: clientName, vaultKey, reason: err instanceof Error ? err.message : String(err) },
|
|
44
|
+
});
|
|
45
|
+
return (0, api_error_1.handleApiError)(err, serviceLabel, connectionName);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
19
48
|
try {
|
|
20
49
|
(0, runtime_1.emitNervesEvent)({
|
|
21
50
|
event: "client.request_start",
|
|
@@ -25,7 +54,7 @@ async function apiRequest(options) {
|
|
|
25
54
|
});
|
|
26
55
|
const url = `${baseUrl}${path}`;
|
|
27
56
|
const headers = {
|
|
28
|
-
Authorization: `Bearer ${
|
|
57
|
+
Authorization: `Bearer ${resolvedToken}`,
|
|
29
58
|
"Content-Type": contentType ?? "application/json",
|
|
30
59
|
...extraHeaders,
|
|
31
60
|
};
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Bitwarden vault client for credential management.
|
|
4
|
+
*
|
|
5
|
+
* SDK-first approach: attempts dynamic import of @bitwarden/sdk-napi,
|
|
6
|
+
* falls back to `bw` CLI with --session flag.
|
|
7
|
+
*
|
|
8
|
+
* Raw secrets are NEVER returned to callers except via getRawSecret(),
|
|
9
|
+
* which is reserved for internal use by the credential gateway (apiRequest vaultKey).
|
|
10
|
+
*/
|
|
11
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
14
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
15
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
16
|
+
}
|
|
17
|
+
Object.defineProperty(o, k2, desc);
|
|
18
|
+
}) : (function(o, m, k, k2) {
|
|
19
|
+
if (k2 === undefined) k2 = k;
|
|
20
|
+
o[k2] = m[k];
|
|
21
|
+
}));
|
|
22
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
23
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
24
|
+
}) : function(o, v) {
|
|
25
|
+
o["default"] = v;
|
|
26
|
+
});
|
|
27
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
28
|
+
var ownKeys = function(o) {
|
|
29
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
30
|
+
var ar = [];
|
|
31
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
32
|
+
return ar;
|
|
33
|
+
};
|
|
34
|
+
return ownKeys(o);
|
|
35
|
+
};
|
|
36
|
+
return function (mod) {
|
|
37
|
+
if (mod && mod.__esModule) return mod;
|
|
38
|
+
var result = {};
|
|
39
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
40
|
+
__setModuleDefault(result, mod);
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
})();
|
|
44
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
45
|
+
exports.BitwardenClient = void 0;
|
|
46
|
+
exports.getBitwardenClient = getBitwardenClient;
|
|
47
|
+
exports.resetBitwardenClient = resetBitwardenClient;
|
|
48
|
+
const child_process_1 = require("child_process");
|
|
49
|
+
const runtime_1 = require("../nerves/runtime");
|
|
50
|
+
class BitwardenClient {
|
|
51
|
+
sessionKey = null;
|
|
52
|
+
connected = false;
|
|
53
|
+
clientMode = null;
|
|
54
|
+
async connect(config) {
|
|
55
|
+
(0, runtime_1.emitNervesEvent)({
|
|
56
|
+
event: "client.vault_connect_start",
|
|
57
|
+
component: "clients",
|
|
58
|
+
message: "connecting to Bitwarden vault",
|
|
59
|
+
meta: { mode: config.mode ?? "auto" },
|
|
60
|
+
});
|
|
61
|
+
try {
|
|
62
|
+
if (config.mode === "cli") {
|
|
63
|
+
await this.connectCli(config);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// Try SDK first, fall back to CLI
|
|
67
|
+
try {
|
|
68
|
+
await this.connectSdk(config);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
(0, runtime_1.emitNervesEvent)({
|
|
72
|
+
event: "client.vault_sdk_fallback",
|
|
73
|
+
component: "clients",
|
|
74
|
+
message: "SDK not available, falling back to bw CLI",
|
|
75
|
+
meta: {},
|
|
76
|
+
});
|
|
77
|
+
await this.connectCli(config);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
(0, runtime_1.emitNervesEvent)({
|
|
81
|
+
event: "client.vault_connect_end",
|
|
82
|
+
component: "clients",
|
|
83
|
+
message: "connected to Bitwarden vault",
|
|
84
|
+
meta: { mode: this.clientMode },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
(0, runtime_1.emitNervesEvent)({
|
|
89
|
+
level: "error",
|
|
90
|
+
event: "client.vault_connect_error",
|
|
91
|
+
component: "clients",
|
|
92
|
+
message: "failed to connect to Bitwarden vault",
|
|
93
|
+
/* v8 ignore next -- defensive: execCli always wraps in Error @preserve */
|
|
94
|
+
meta: { reason: err instanceof Error ? err.message : String(err) },
|
|
95
|
+
});
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async disconnect() {
|
|
100
|
+
this.sessionKey = null;
|
|
101
|
+
this.connected = false;
|
|
102
|
+
this.clientMode = null;
|
|
103
|
+
(0, runtime_1.emitNervesEvent)({
|
|
104
|
+
event: "client.vault_disconnect",
|
|
105
|
+
component: "clients",
|
|
106
|
+
message: "disconnected from Bitwarden vault",
|
|
107
|
+
meta: {},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
isConnected() {
|
|
111
|
+
return this.connected;
|
|
112
|
+
}
|
|
113
|
+
async getItem(id) {
|
|
114
|
+
this.requireConnected();
|
|
115
|
+
return this.withNervesEvents("getItem", { id }, async () => {
|
|
116
|
+
const raw = await this.fetchRawItem(id);
|
|
117
|
+
return this.stripSecrets(raw);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async createItem(name, fields) {
|
|
121
|
+
this.requireConnected();
|
|
122
|
+
return this.withNervesEvents("createItem", { name }, async () => {
|
|
123
|
+
const itemFields = Object.entries(fields).map(([k, v]) => ({
|
|
124
|
+
name: k,
|
|
125
|
+
value: v,
|
|
126
|
+
type: 0, // text type
|
|
127
|
+
}));
|
|
128
|
+
const itemTemplate = {
|
|
129
|
+
type: 1, // login type
|
|
130
|
+
name,
|
|
131
|
+
fields: itemFields,
|
|
132
|
+
login: {},
|
|
133
|
+
};
|
|
134
|
+
const encoded = Buffer.from(JSON.stringify(itemTemplate)).toString("base64");
|
|
135
|
+
const stdout = await this.execCli(["create", "item", encoded]);
|
|
136
|
+
const parsed = this.parseCliResponse(stdout);
|
|
137
|
+
return { id: parsed.id, name: parsed.name, type: parsed.type };
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async listItems(search) {
|
|
141
|
+
this.requireConnected();
|
|
142
|
+
return this.withNervesEvents("listItems", { search }, async () => {
|
|
143
|
+
const args = ["list", "items"];
|
|
144
|
+
if (search) {
|
|
145
|
+
args.push("--search", search);
|
|
146
|
+
}
|
|
147
|
+
const stdout = await this.execCli(args);
|
|
148
|
+
const parsed = this.parseCliResponse(stdout);
|
|
149
|
+
const items = Array.isArray(parsed.data) ? parsed.data : (Array.isArray(parsed) ? parsed : []);
|
|
150
|
+
return items.map((item) => ({ id: item.id, name: item.name, type: item.type }));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
async deleteItem(id) {
|
|
154
|
+
this.requireConnected();
|
|
155
|
+
return this.withNervesEvents("deleteItem", { id }, async () => {
|
|
156
|
+
await this.execCli(["delete", "item", id]);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Internal: used by credential gateway (PR 5) to get raw secret for HTTP injection.
|
|
161
|
+
* NOT exposed via tools -- only called by apiRequest() internally.
|
|
162
|
+
*/
|
|
163
|
+
async getRawSecret(itemId, fieldName) {
|
|
164
|
+
this.requireConnected();
|
|
165
|
+
return this.withNervesEvents("getRawSecret", { itemId, fieldName }, async () => {
|
|
166
|
+
const raw = await this.fetchRawItem(itemId);
|
|
167
|
+
// Check custom fields first
|
|
168
|
+
if (raw.fields) {
|
|
169
|
+
const field = raw.fields.find((f) => f.name === fieldName);
|
|
170
|
+
if (field)
|
|
171
|
+
return field.value;
|
|
172
|
+
}
|
|
173
|
+
// Check login.password
|
|
174
|
+
if (fieldName === "password" && raw.login?.password) {
|
|
175
|
+
return raw.login.password;
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`field "${fieldName}" not found on vault item "${itemId}"`);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
// --- Private methods ---
|
|
181
|
+
requireConnected() {
|
|
182
|
+
if (!this.connected) {
|
|
183
|
+
throw new Error("vault not connected -- call connect() first");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
async withNervesEvents(operation, meta, fn) {
|
|
187
|
+
(0, runtime_1.emitNervesEvent)({
|
|
188
|
+
event: "client.request_start",
|
|
189
|
+
component: "clients",
|
|
190
|
+
message: `vault ${operation} starting`,
|
|
191
|
+
meta: { operation, ...meta },
|
|
192
|
+
});
|
|
193
|
+
try {
|
|
194
|
+
const result = await fn();
|
|
195
|
+
(0, runtime_1.emitNervesEvent)({
|
|
196
|
+
event: "client.request_end",
|
|
197
|
+
component: "clients",
|
|
198
|
+
message: `vault ${operation} complete`,
|
|
199
|
+
meta: { operation, ...meta },
|
|
200
|
+
});
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
(0, runtime_1.emitNervesEvent)({
|
|
205
|
+
level: "error",
|
|
206
|
+
event: "client.error",
|
|
207
|
+
component: "clients",
|
|
208
|
+
message: `vault ${operation} failed`,
|
|
209
|
+
/* v8 ignore next -- defensive: callers always throw Error instances @preserve */
|
|
210
|
+
meta: { operation, reason: err instanceof Error ? err.message : String(err), ...meta },
|
|
211
|
+
});
|
|
212
|
+
throw err;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async connectSdk(_config) {
|
|
216
|
+
// Attempt dynamic import of SDK
|
|
217
|
+
// This will throw if the package is not installed
|
|
218
|
+
const sdk = await Promise.resolve(`${"@bitwarden/sdk-napi"}`).then(s => __importStar(require(s)));
|
|
219
|
+
/* v8 ignore start -- SDK not yet published; path tested when package becomes available @preserve */
|
|
220
|
+
const client = new sdk.BitwardenClient({ apiUrl: _config.serverUrl ?? "https://api.bitwarden.com" }, 0);
|
|
221
|
+
await client.auth().loginAccessToken(_config.accessToken, "");
|
|
222
|
+
this.connected = true;
|
|
223
|
+
this.clientMode = "sdk";
|
|
224
|
+
/* v8 ignore stop */
|
|
225
|
+
}
|
|
226
|
+
async connectCli(config) {
|
|
227
|
+
this.sessionKey = config.accessToken;
|
|
228
|
+
// Verify CLI is available and session key works
|
|
229
|
+
await this.execCli(["status"]);
|
|
230
|
+
this.connected = true;
|
|
231
|
+
this.clientMode = "cli";
|
|
232
|
+
}
|
|
233
|
+
async fetchRawItem(id) {
|
|
234
|
+
const stdout = await this.execCli(["get", "item", id]);
|
|
235
|
+
const parsed = this.parseCliResponse(stdout);
|
|
236
|
+
return parsed;
|
|
237
|
+
}
|
|
238
|
+
stripSecrets(raw) {
|
|
239
|
+
const item = {
|
|
240
|
+
id: raw.id,
|
|
241
|
+
name: raw.name,
|
|
242
|
+
type: raw.type,
|
|
243
|
+
};
|
|
244
|
+
if (raw.notes)
|
|
245
|
+
item.notes = raw.notes;
|
|
246
|
+
if (raw.fields)
|
|
247
|
+
item.fields = raw.fields;
|
|
248
|
+
if (raw.login) {
|
|
249
|
+
item.login = {};
|
|
250
|
+
if (raw.login.username)
|
|
251
|
+
item.login.username = raw.login.username;
|
|
252
|
+
if (raw.login.uris)
|
|
253
|
+
item.login.uris = raw.login.uris;
|
|
254
|
+
// password intentionally NOT copied
|
|
255
|
+
}
|
|
256
|
+
return item;
|
|
257
|
+
}
|
|
258
|
+
parseCliResponse(stdout) {
|
|
259
|
+
const parsed = JSON.parse(stdout);
|
|
260
|
+
// bw CLI with --response wraps in { success, data }
|
|
261
|
+
if (parsed.success !== undefined && parsed.data !== undefined) {
|
|
262
|
+
return parsed.data;
|
|
263
|
+
}
|
|
264
|
+
return parsed;
|
|
265
|
+
}
|
|
266
|
+
execCli(args) {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
const fullArgs = [...args, "--session", this.sessionKey, "--response"];
|
|
269
|
+
(0, child_process_1.execFile)("bw", fullArgs, { timeout: 30_000 }, (err, stdout, _stderr) => {
|
|
270
|
+
if (err) {
|
|
271
|
+
reject(new Error(`bw CLI error: ${err.message}`));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
resolve(stdout);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
exports.BitwardenClient = BitwardenClient;
|
|
280
|
+
// Singleton accessor
|
|
281
|
+
let _client = null;
|
|
282
|
+
function getBitwardenClient() {
|
|
283
|
+
if (!_client) {
|
|
284
|
+
_client = new BitwardenClient();
|
|
285
|
+
}
|
|
286
|
+
return _client;
|
|
287
|
+
}
|
|
288
|
+
function resetBitwardenClient() {
|
|
289
|
+
_client = null;
|
|
290
|
+
}
|
|
@@ -211,6 +211,26 @@ function resolveOuroSubcommand(command) {
|
|
|
211
211
|
return tokens[0];
|
|
212
212
|
return null;
|
|
213
213
|
}
|
|
214
|
+
// --- MCP server-specific trust rules ---
|
|
215
|
+
const MCP_SERVER_TRUST = {
|
|
216
|
+
browser: { minTrust: "friend", blockGroupChat: true },
|
|
217
|
+
};
|
|
218
|
+
function checkMcpServerTrust(command, context) {
|
|
219
|
+
const match = command.match(/^ouro\s+mcp\s+call\s+(\S+)/);
|
|
220
|
+
if (!match)
|
|
221
|
+
return allow;
|
|
222
|
+
const serverName = match[1];
|
|
223
|
+
const rules = MCP_SERVER_TRUST[serverName];
|
|
224
|
+
if (!rules)
|
|
225
|
+
return allow; // no special rules for this server
|
|
226
|
+
if (!trustLevelSatisfied(rules.minTrust, context.trustLevel ?? "friend")) {
|
|
227
|
+
return deny(REASONS.needsTrust);
|
|
228
|
+
}
|
|
229
|
+
if (rules.blockGroupChat && context.isGroupChat) {
|
|
230
|
+
return deny("browser tools are only available in 1:1 conversations, not group chats.");
|
|
231
|
+
}
|
|
232
|
+
return allow;
|
|
233
|
+
}
|
|
214
234
|
function checkSingleShellCommandTrust(command, trustLevel) {
|
|
215
235
|
const trimmed = command.trim();
|
|
216
236
|
const tokens = trimmed.split(/\s+/);
|
|
@@ -262,8 +282,39 @@ function checkWriteTrustGuardrails(toolName, args, context) {
|
|
|
262
282
|
return allow;
|
|
263
283
|
return deny(REASONS.needsTrustForWrite);
|
|
264
284
|
}
|
|
285
|
+
// --- vault tool trust gating ---
|
|
286
|
+
// Vault write tools: family only
|
|
287
|
+
const VAULT_FAMILY_TOOLS = new Set(["vault_store", "vault_delete"]);
|
|
288
|
+
// Vault read tools: friend+
|
|
289
|
+
const VAULT_TRUSTED_TOOLS = new Set(["vault_get", "vault_list"]);
|
|
290
|
+
// Travel tools: friend+ (weather_lookup accesses vault credentials indirectly;
|
|
291
|
+
// advisory and geocode are public APIs but gated for consistency)
|
|
292
|
+
const TRAVEL_TRUSTED_TOOLS = new Set(["weather_lookup", "travel_advisory", "geocode_search"]);
|
|
293
|
+
function checkVaultTrustGuardrails(toolName, context) {
|
|
294
|
+
if (VAULT_FAMILY_TOOLS.has(toolName)) {
|
|
295
|
+
if (context.trustLevel === "family")
|
|
296
|
+
return allow;
|
|
297
|
+
return deny(REASONS.needsTrust);
|
|
298
|
+
}
|
|
299
|
+
if (VAULT_TRUSTED_TOOLS.has(toolName) || TRAVEL_TRUSTED_TOOLS.has(toolName)) {
|
|
300
|
+
if ((0, types_1.isTrustedLevel)(context.trustLevel))
|
|
301
|
+
return allow;
|
|
302
|
+
return deny(REASONS.needsTrust);
|
|
303
|
+
}
|
|
304
|
+
return allow;
|
|
305
|
+
}
|
|
265
306
|
function checkTrustLevelGuardrails(toolName, args, context) {
|
|
266
|
-
//
|
|
307
|
+
// Vault tools have their own trust rules that apply at all levels
|
|
308
|
+
const vaultResult = checkVaultTrustGuardrails(toolName, context);
|
|
309
|
+
if (!vaultResult.allowed)
|
|
310
|
+
return vaultResult;
|
|
311
|
+
// MCP server-specific trust (e.g. browser blocked in group chats) — applies at all trust levels
|
|
312
|
+
if (toolName === "shell") {
|
|
313
|
+
const mcpResult = checkMcpServerTrust(args.command || "", context);
|
|
314
|
+
if (!mcpResult.allowed)
|
|
315
|
+
return mcpResult;
|
|
316
|
+
}
|
|
317
|
+
// Trusted levels (family/friend) — no further trust guardrails. Undefined defaults to friend.
|
|
267
318
|
if ((0, types_1.isTrustedLevel)(context.trustLevel))
|
|
268
319
|
return allow;
|
|
269
320
|
if (toolName === "shell") {
|
|
@@ -9,6 +9,8 @@ const tools_session_1 = require("./tools-session");
|
|
|
9
9
|
const tools_continuity_1 = require("./tools-continuity");
|
|
10
10
|
const tools_config_1 = require("./tools-config");
|
|
11
11
|
const tools_1 = require("./coding/tools");
|
|
12
|
+
const tools_vault_1 = require("./tools-vault");
|
|
13
|
+
const tools_travel_1 = require("./tools-travel");
|
|
12
14
|
// Re-export flow tools for consumers that import them from tools-base
|
|
13
15
|
var tools_flow_1 = require("./tools-flow");
|
|
14
16
|
Object.defineProperty(exports, "ponderTool", { enumerable: true, get: function () { return tools_flow_1.ponderTool; } });
|
|
@@ -32,6 +34,8 @@ exports.baseToolDefinitions = [
|
|
|
32
34
|
...tools_continuity_1.continuityToolDefinitions,
|
|
33
35
|
...tools_config_1.configToolDefinitions,
|
|
34
36
|
...tools_1.codingToolDefinitions,
|
|
37
|
+
...tools_vault_1.vaultToolDefinitions,
|
|
38
|
+
...tools_travel_1.travelToolDefinitions,
|
|
35
39
|
];
|
|
36
40
|
// Convenience array of just the tool schemas (no handler/integration metadata).
|
|
37
41
|
// Used by consumers that need the OpenAI function-tool format.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.travelToolDefinitions = void 0;
|
|
4
|
+
const travel_api_client_1 = require("./travel-api-client");
|
|
5
|
+
const runtime_1 = require("../nerves/runtime");
|
|
6
|
+
exports.travelToolDefinitions = [
|
|
7
|
+
{
|
|
8
|
+
tool: {
|
|
9
|
+
type: "function",
|
|
10
|
+
function: {
|
|
11
|
+
name: "weather_lookup",
|
|
12
|
+
description: "Get current weather for a location. Provide either a city name or lat/lon coordinates.",
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
city: { type: "string", description: "City name (e.g. 'London', 'New York')" },
|
|
17
|
+
lat: { type: "string", description: "Latitude (decimal)" },
|
|
18
|
+
lon: { type: "string", description: "Longitude (decimal)" },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
handler: async (args) => {
|
|
24
|
+
(0, runtime_1.emitNervesEvent)({
|
|
25
|
+
component: "repertoire",
|
|
26
|
+
event: "repertoire.travel_tool_call",
|
|
27
|
+
message: "weather_lookup invoked",
|
|
28
|
+
meta: { tool: "weather_lookup" },
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
if (args.city) {
|
|
32
|
+
const data = await (0, travel_api_client_1.getWeatherByCity)(args.city);
|
|
33
|
+
return JSON.stringify(data, null, 2);
|
|
34
|
+
}
|
|
35
|
+
if (args.lat && args.lon) {
|
|
36
|
+
const data = await (0, travel_api_client_1.getWeather)(parseFloat(args.lat), parseFloat(args.lon));
|
|
37
|
+
return JSON.stringify(data, null, 2);
|
|
38
|
+
}
|
|
39
|
+
return "Please provide either a city name or lat/lon coordinates.";
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
/* v8 ignore next -- defensive: callers throw Error instances @preserve */
|
|
43
|
+
return `Weather lookup error: ${err instanceof Error ? err.message : String(err)}`;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
summaryKeys: ["city", "lat", "lon"],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
tool: {
|
|
50
|
+
type: "function",
|
|
51
|
+
function: {
|
|
52
|
+
name: "travel_advisory",
|
|
53
|
+
description: "Get US State Department travel advisory for a country. Returns advisory level (1-4) and description.",
|
|
54
|
+
parameters: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
country_code: {
|
|
58
|
+
type: "string",
|
|
59
|
+
description: "ISO 3166 alpha-2 country code (e.g. 'US', 'GB', 'AF')",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
required: ["country_code"],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
handler: async (args) => {
|
|
67
|
+
(0, runtime_1.emitNervesEvent)({
|
|
68
|
+
component: "repertoire",
|
|
69
|
+
event: "repertoire.travel_tool_call",
|
|
70
|
+
message: "travel_advisory invoked",
|
|
71
|
+
meta: { tool: "travel_advisory" },
|
|
72
|
+
});
|
|
73
|
+
try {
|
|
74
|
+
const data = await (0, travel_api_client_1.getTravelAdvisory)(args.country_code.toUpperCase());
|
|
75
|
+
return JSON.stringify(data, null, 2);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
/* v8 ignore next -- defensive: callers throw Error instances @preserve */
|
|
79
|
+
return `Travel advisory error: ${err instanceof Error ? err.message : String(err)}`;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
summaryKeys: ["country_code"],
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
tool: {
|
|
86
|
+
type: "function",
|
|
87
|
+
function: {
|
|
88
|
+
name: "geocode_search",
|
|
89
|
+
description: "Search for locations, addresses, or points of interest. Returns coordinates and display names.",
|
|
90
|
+
parameters: {
|
|
91
|
+
type: "object",
|
|
92
|
+
properties: {
|
|
93
|
+
query: { type: "string", description: "Search query (e.g. 'Eiffel Tower', '123 Main St')" },
|
|
94
|
+
near_lat: { type: "string", description: "Latitude to search near (for POI search)" },
|
|
95
|
+
near_lon: { type: "string", description: "Longitude to search near (for POI search)" },
|
|
96
|
+
radius_km: { type: "string", description: "Search radius in km (default 10)" },
|
|
97
|
+
},
|
|
98
|
+
required: ["query"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
handler: async (args) => {
|
|
103
|
+
(0, runtime_1.emitNervesEvent)({
|
|
104
|
+
component: "repertoire",
|
|
105
|
+
event: "repertoire.travel_tool_call",
|
|
106
|
+
message: "geocode_search invoked",
|
|
107
|
+
meta: { tool: "geocode_search" },
|
|
108
|
+
});
|
|
109
|
+
try {
|
|
110
|
+
if (args.near_lat && args.near_lon) {
|
|
111
|
+
const radiusKm = args.radius_km ? parseFloat(args.radius_km) : undefined;
|
|
112
|
+
const data = await (0, travel_api_client_1.searchPOI)(args.query, parseFloat(args.near_lat), parseFloat(args.near_lon), radiusKm);
|
|
113
|
+
return JSON.stringify(data, null, 2);
|
|
114
|
+
}
|
|
115
|
+
const data = await (0, travel_api_client_1.geocode)(args.query);
|
|
116
|
+
return JSON.stringify(data, null, 2);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
/* v8 ignore next -- defensive: callers throw Error instances @preserve */
|
|
120
|
+
return `Geocode error: ${err instanceof Error ? err.message : String(err)}`;
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
summaryKeys: ["query"],
|
|
124
|
+
},
|
|
125
|
+
];
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.vaultToolDefinitions = void 0;
|
|
4
|
+
const bitwarden_client_1 = require("./bitwarden-client");
|
|
5
|
+
const runtime_1 = require("../nerves/runtime");
|
|
6
|
+
exports.vaultToolDefinitions = [
|
|
7
|
+
{
|
|
8
|
+
tool: {
|
|
9
|
+
type: "function",
|
|
10
|
+
function: {
|
|
11
|
+
name: "vault_get",
|
|
12
|
+
description: "Retrieve a credential from the Bitwarden vault by ID or name. Returns metadata and fields but never raw passwords.",
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
id: { type: "string", description: "Vault item ID (preferred)" },
|
|
17
|
+
name: {
|
|
18
|
+
type: "string",
|
|
19
|
+
description: "Search by name (less precise, uses first match)",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
handler: async (args) => {
|
|
26
|
+
(0, runtime_1.emitNervesEvent)({
|
|
27
|
+
component: "repertoire",
|
|
28
|
+
event: "repertoire.vault_tool_call",
|
|
29
|
+
message: "vault_get invoked",
|
|
30
|
+
meta: { tool: "vault_get" },
|
|
31
|
+
});
|
|
32
|
+
try {
|
|
33
|
+
const client = (0, bitwarden_client_1.getBitwardenClient)();
|
|
34
|
+
if (args.id) {
|
|
35
|
+
const item = await client.getItem(args.id);
|
|
36
|
+
return JSON.stringify(item, null, 2);
|
|
37
|
+
}
|
|
38
|
+
if (args.name) {
|
|
39
|
+
const items = await client.listItems(args.name);
|
|
40
|
+
if (items.length === 0) {
|
|
41
|
+
return `No vault item found matching "${args.name}". Try vault_list to see available items.`;
|
|
42
|
+
}
|
|
43
|
+
const item = await client.getItem(items[0].id);
|
|
44
|
+
return JSON.stringify(item, null, 2);
|
|
45
|
+
}
|
|
46
|
+
return "Please provide either an id or name to look up a vault item.";
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
/* v8 ignore next -- defensive: callers throw Error instances @preserve */
|
|
50
|
+
return `Vault error: ${err instanceof Error ? err.message : String(err)}`;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
summaryKeys: ["id", "name"],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
tool: {
|
|
57
|
+
type: "function",
|
|
58
|
+
function: {
|
|
59
|
+
name: "vault_store",
|
|
60
|
+
description: "Store a new credential in the Bitwarden vault. Supports login credentials, API keys, and structured fields.",
|
|
61
|
+
parameters: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
name: { type: "string", description: "Name for the credential" },
|
|
65
|
+
username: { type: "string", description: "Username (optional)" },
|
|
66
|
+
password: { type: "string", description: "Password (optional)" },
|
|
67
|
+
uri: { type: "string", description: "URI/URL (optional)" },
|
|
68
|
+
notes: { type: "string", description: "Notes (optional)" },
|
|
69
|
+
fields: {
|
|
70
|
+
type: "string",
|
|
71
|
+
description: 'JSON string of custom fields, e.g. {"apiKey": "key123", "region": "us-east"}',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
required: ["name"],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
handler: async (args) => {
|
|
79
|
+
(0, runtime_1.emitNervesEvent)({
|
|
80
|
+
component: "repertoire",
|
|
81
|
+
event: "repertoire.vault_tool_call",
|
|
82
|
+
message: "vault_store invoked",
|
|
83
|
+
meta: { tool: "vault_store", name: args.name },
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
const client = (0, bitwarden_client_1.getBitwardenClient)();
|
|
87
|
+
const fields = {};
|
|
88
|
+
if (args.username)
|
|
89
|
+
fields.username = args.username;
|
|
90
|
+
if (args.password)
|
|
91
|
+
fields.password = args.password;
|
|
92
|
+
if (args.uri)
|
|
93
|
+
fields.uri = args.uri;
|
|
94
|
+
if (args.notes)
|
|
95
|
+
fields.notes = args.notes;
|
|
96
|
+
if (args.fields) {
|
|
97
|
+
const parsed = JSON.parse(args.fields);
|
|
98
|
+
Object.assign(fields, parsed);
|
|
99
|
+
}
|
|
100
|
+
const handle = await client.createItem(args.name, fields);
|
|
101
|
+
return JSON.stringify(handle, null, 2);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
/* v8 ignore next -- defensive: callers throw Error instances @preserve */
|
|
105
|
+
return `Vault error: ${err instanceof Error ? err.message : String(err)}`;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
confirmationRequired: true,
|
|
109
|
+
summaryKeys: ["name"],
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
tool: {
|
|
113
|
+
type: "function",
|
|
114
|
+
function: {
|
|
115
|
+
name: "vault_list",
|
|
116
|
+
description: "List credentials in the Bitwarden vault. Returns names and IDs only, never secrets.",
|
|
117
|
+
parameters: {
|
|
118
|
+
type: "object",
|
|
119
|
+
properties: {
|
|
120
|
+
search: {
|
|
121
|
+
type: "string",
|
|
122
|
+
description: "Optional search filter",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
handler: async (args) => {
|
|
129
|
+
(0, runtime_1.emitNervesEvent)({
|
|
130
|
+
component: "repertoire",
|
|
131
|
+
event: "repertoire.vault_tool_call",
|
|
132
|
+
message: "vault_list invoked",
|
|
133
|
+
meta: { tool: "vault_list" },
|
|
134
|
+
});
|
|
135
|
+
try {
|
|
136
|
+
const client = (0, bitwarden_client_1.getBitwardenClient)();
|
|
137
|
+
const items = await client.listItems(args.search);
|
|
138
|
+
return JSON.stringify(items, null, 2);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
/* v8 ignore next -- defensive: callers throw Error instances @preserve */
|
|
142
|
+
return `Vault error: ${err instanceof Error ? err.message : String(err)}`;
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
summaryKeys: ["search"],
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
tool: {
|
|
149
|
+
type: "function",
|
|
150
|
+
function: {
|
|
151
|
+
name: "vault_delete",
|
|
152
|
+
description: "Delete a credential from the Bitwarden vault by ID.",
|
|
153
|
+
parameters: {
|
|
154
|
+
type: "object",
|
|
155
|
+
properties: {
|
|
156
|
+
id: {
|
|
157
|
+
type: "string",
|
|
158
|
+
description: "ID of the vault item to delete",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
required: ["id"],
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
handler: async (args) => {
|
|
166
|
+
(0, runtime_1.emitNervesEvent)({
|
|
167
|
+
component: "repertoire",
|
|
168
|
+
event: "repertoire.vault_tool_call",
|
|
169
|
+
message: "vault_delete invoked",
|
|
170
|
+
meta: { tool: "vault_delete", id: args.id },
|
|
171
|
+
});
|
|
172
|
+
try {
|
|
173
|
+
const client = (0, bitwarden_client_1.getBitwardenClient)();
|
|
174
|
+
await client.deleteItem(args.id);
|
|
175
|
+
return `Vault item ${args.id} deleted successfully.`;
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
/* v8 ignore next -- defensive: callers throw Error instances @preserve */
|
|
179
|
+
return `Vault delete failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
confirmationRequired: true,
|
|
183
|
+
summaryKeys: ["id"],
|
|
184
|
+
},
|
|
185
|
+
];
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Travel API client module.
|
|
4
|
+
*
|
|
5
|
+
* Provides HTTP clients for:
|
|
6
|
+
* - OpenWeatherMap (query-param auth via vault)
|
|
7
|
+
* - US State Dept Travel Advisories (no auth)
|
|
8
|
+
* - Nominatim Geocoding (no auth, requires User-Agent)
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.TRAVEL_ADVISORY_RSS_URL = void 0;
|
|
12
|
+
exports.setWeatherVaultConfig = setWeatherVaultConfig;
|
|
13
|
+
exports.getWeather = getWeather;
|
|
14
|
+
exports.getWeatherByCity = getWeatherByCity;
|
|
15
|
+
exports.parseAdvisoryLevel = parseAdvisoryLevel;
|
|
16
|
+
exports.parseCountryName = parseCountryName;
|
|
17
|
+
exports.parseAdvisoryText = parseAdvisoryText;
|
|
18
|
+
exports.getTravelAdvisory = getTravelAdvisory;
|
|
19
|
+
exports.geocode = geocode;
|
|
20
|
+
exports.reverseGeocode = reverseGeocode;
|
|
21
|
+
exports.searchPOI = searchPOI;
|
|
22
|
+
const runtime_1 = require("../nerves/runtime");
|
|
23
|
+
const bitwarden_client_1 = require("./bitwarden-client");
|
|
24
|
+
// --- OpenWeatherMap ---
|
|
25
|
+
/**
|
|
26
|
+
* Vault item ID and field name for OpenWeatherMap API key.
|
|
27
|
+
* Override via setWeatherVaultConfig() for custom vault layouts.
|
|
28
|
+
*/
|
|
29
|
+
let weatherVaultItemId = "openweathermap-api";
|
|
30
|
+
let weatherVaultField = "apiKey";
|
|
31
|
+
function setWeatherVaultConfig(itemId, field) {
|
|
32
|
+
weatherVaultItemId = itemId;
|
|
33
|
+
if (field)
|
|
34
|
+
weatherVaultField = field;
|
|
35
|
+
}
|
|
36
|
+
async function getWeatherApiKey() {
|
|
37
|
+
const client = (0, bitwarden_client_1.getBitwardenClient)();
|
|
38
|
+
return client.getRawSecret(weatherVaultItemId, weatherVaultField);
|
|
39
|
+
}
|
|
40
|
+
async function getWeather(lat, lon) {
|
|
41
|
+
return withNervesEvents("getWeather", { lat, lon }, async () => {
|
|
42
|
+
const apiKey = await getWeatherApiKey();
|
|
43
|
+
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`;
|
|
44
|
+
const res = await fetch(url);
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
throw new Error(`OpenWeatherMap API error: ${res.status} ${res.statusText}`);
|
|
47
|
+
}
|
|
48
|
+
const data = await res.json();
|
|
49
|
+
return parseWeatherResponse(data);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
async function getWeatherByCity(city) {
|
|
53
|
+
return withNervesEvents("getWeatherByCity", { city }, async () => {
|
|
54
|
+
const apiKey = await getWeatherApiKey();
|
|
55
|
+
const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=metric`;
|
|
56
|
+
const res = await fetch(url);
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
throw new Error(`OpenWeatherMap API error: ${res.status} ${res.statusText}`);
|
|
59
|
+
}
|
|
60
|
+
const data = await res.json();
|
|
61
|
+
return parseWeatherResponse(data);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function parseWeatherResponse(data) {
|
|
65
|
+
return {
|
|
66
|
+
temperature: data.main.temp,
|
|
67
|
+
feelsLike: data.main.feels_like,
|
|
68
|
+
description: data.weather?.[0]?.description ?? "unknown",
|
|
69
|
+
humidity: data.main.humidity,
|
|
70
|
+
windSpeed: data.wind.speed,
|
|
71
|
+
city: data.name,
|
|
72
|
+
country: data.sys.country,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// --- US State Dept Travel Advisories ---
|
|
76
|
+
/**
|
|
77
|
+
* Official US State Department travel advisories RSS feed.
|
|
78
|
+
* This is the canonical public feed maintained by travel.state.gov.
|
|
79
|
+
* Returns XML/RSS with per-country advisory items.
|
|
80
|
+
*/
|
|
81
|
+
exports.TRAVEL_ADVISORY_RSS_URL = "https://travel.state.gov/_res/rss/TAsTWs.xml";
|
|
82
|
+
/**
|
|
83
|
+
* Parse advisory level (1-4) from an RSS item title like
|
|
84
|
+
* "Afghanistan - Level 4: Do Not Travel"
|
|
85
|
+
*/
|
|
86
|
+
function parseAdvisoryLevel(title) {
|
|
87
|
+
const match = title.match(/Level\s+(\d)/);
|
|
88
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse country name from an RSS item title like
|
|
92
|
+
* "Afghanistan - Level 4: Do Not Travel"
|
|
93
|
+
*/
|
|
94
|
+
function parseCountryName(title) {
|
|
95
|
+
const dashIdx = title.indexOf(" - ");
|
|
96
|
+
return dashIdx > 0 ? title.slice(0, dashIdx).trim() : title.trim();
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Extract advisory text from title (the part after "Level N: ").
|
|
100
|
+
*/
|
|
101
|
+
function parseAdvisoryText(title) {
|
|
102
|
+
const match = title.match(/Level\s+\d:\s*(.+)/);
|
|
103
|
+
return match ? match[1].trim() : title.trim();
|
|
104
|
+
}
|
|
105
|
+
async function getTravelAdvisory(countryCode) {
|
|
106
|
+
return withNervesEvents("getTravelAdvisory", { countryCode }, async () => {
|
|
107
|
+
const res = await fetch(exports.TRAVEL_ADVISORY_RSS_URL, {
|
|
108
|
+
headers: {
|
|
109
|
+
Accept: "application/xml, text/xml",
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
throw new Error(`Travel advisory API error: ${res.status} ${res.statusText}`);
|
|
114
|
+
}
|
|
115
|
+
const xml = await res.text();
|
|
116
|
+
// Parse RSS items via regex (no XML parser dependency needed).
|
|
117
|
+
// Each <item> contains <title>, <pubDate>, and <category domain="Country-Tag">.
|
|
118
|
+
const itemPattern = /<item>([\s\S]*?)<\/item>/g;
|
|
119
|
+
let match;
|
|
120
|
+
while ((match = itemPattern.exec(xml)) !== null) {
|
|
121
|
+
const block = match[1];
|
|
122
|
+
// Extract country tag from <category domain="Country-Tag">XX</category>
|
|
123
|
+
const tagMatch = block.match(/<category\s+domain="Country-Tag">(.*?)<\/category>/);
|
|
124
|
+
const tag = tagMatch?.[1]?.trim().toUpperCase();
|
|
125
|
+
// The RSS feed uses FIPS country codes (e.g. "AF" for Afghanistan),
|
|
126
|
+
// which sometimes differ from ISO 3166. Try exact match first.
|
|
127
|
+
if (tag !== countryCode.toUpperCase())
|
|
128
|
+
continue;
|
|
129
|
+
const titleMatch = block.match(/<title>(.*?)<\/title>/);
|
|
130
|
+
const title = titleMatch?.[1] ?? "";
|
|
131
|
+
const pubDateMatch = block.match(/<pubDate>(.*?)<\/pubDate>/);
|
|
132
|
+
const pubDate = pubDateMatch?.[1]?.trim() ?? "";
|
|
133
|
+
return {
|
|
134
|
+
countryCode,
|
|
135
|
+
countryName: parseCountryName(title),
|
|
136
|
+
advisoryLevel: parseAdvisoryLevel(title),
|
|
137
|
+
advisoryText: parseAdvisoryText(title),
|
|
138
|
+
lastUpdated: pubDate,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
throw new Error(`No travel advisory found for country code "${countryCode}"`);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// --- Nominatim Geocoding ---
|
|
145
|
+
const NOMINATIM_BASE = "https://nominatim.openstreetmap.org";
|
|
146
|
+
const NOMINATIM_HEADERS = {
|
|
147
|
+
"User-Agent": "Ouroboros/1.0 (https://github.com/ouroborosbot/ouroboros)",
|
|
148
|
+
Accept: "application/json",
|
|
149
|
+
};
|
|
150
|
+
async function geocode(query) {
|
|
151
|
+
return withNervesEvents("geocode", { query }, async () => {
|
|
152
|
+
const url = `${NOMINATIM_BASE}/search?q=${encodeURIComponent(query)}&format=json`;
|
|
153
|
+
const res = await fetch(url, { headers: NOMINATIM_HEADERS });
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
throw new Error(`Nominatim geocode error: ${res.status} ${res.statusText}`);
|
|
156
|
+
}
|
|
157
|
+
const data = await res.json();
|
|
158
|
+
return data.map(parseGeoLocation);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async function reverseGeocode(lat, lon) {
|
|
162
|
+
return withNervesEvents("reverseGeocode", { lat, lon }, async () => {
|
|
163
|
+
const url = `${NOMINATIM_BASE}/reverse?lat=${lat}&lon=${lon}&format=json`;
|
|
164
|
+
const res = await fetch(url, { headers: NOMINATIM_HEADERS });
|
|
165
|
+
if (!res.ok) {
|
|
166
|
+
throw new Error(`Nominatim reverse geocode error: ${res.status} ${res.statusText}`);
|
|
167
|
+
}
|
|
168
|
+
const data = await res.json();
|
|
169
|
+
return parseGeoLocation(data);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
async function searchPOI(query, lat, lon, radiusKm) {
|
|
173
|
+
return withNervesEvents("searchPOI", { query, lat, lon, radiusKm }, async () => {
|
|
174
|
+
const radius = radiusKm ?? 10;
|
|
175
|
+
// Nominatim uses viewbox for bounded search (approximate degrees from km)
|
|
176
|
+
const degOffset = radius / 111; // ~111 km per degree
|
|
177
|
+
const viewbox = `${lon - degOffset},${lat + degOffset},${lon + degOffset},${lat - degOffset}`;
|
|
178
|
+
const url = `${NOMINATIM_BASE}/search?q=${encodeURIComponent(query)}&format=json&viewbox=${viewbox}&bounded=1`;
|
|
179
|
+
const res = await fetch(url, { headers: NOMINATIM_HEADERS });
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
throw new Error(`Nominatim POI search error: ${res.status} ${res.statusText}`);
|
|
182
|
+
}
|
|
183
|
+
const data = await res.json();
|
|
184
|
+
return data.map(parseGeoLocation);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
function parseGeoLocation(item) {
|
|
188
|
+
return {
|
|
189
|
+
lat: parseFloat(item.lat),
|
|
190
|
+
lon: parseFloat(item.lon),
|
|
191
|
+
displayName: item.display_name,
|
|
192
|
+
type: item.type ?? "unknown",
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// --- Shared nerves event wrapper ---
|
|
196
|
+
async function withNervesEvents(operation, meta, fn) {
|
|
197
|
+
(0, runtime_1.emitNervesEvent)({
|
|
198
|
+
event: "client.request_start",
|
|
199
|
+
component: "clients",
|
|
200
|
+
message: `travel API ${operation} starting`,
|
|
201
|
+
meta: { operation, ...meta },
|
|
202
|
+
});
|
|
203
|
+
try {
|
|
204
|
+
const result = await fn();
|
|
205
|
+
(0, runtime_1.emitNervesEvent)({
|
|
206
|
+
event: "client.request_end",
|
|
207
|
+
component: "clients",
|
|
208
|
+
message: `travel API ${operation} complete`,
|
|
209
|
+
meta: { operation, ...meta },
|
|
210
|
+
});
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
(0, runtime_1.emitNervesEvent)({
|
|
215
|
+
level: "error",
|
|
216
|
+
event: "client.error",
|
|
217
|
+
component: "clients",
|
|
218
|
+
message: `travel API ${operation} failed`,
|
|
219
|
+
/* v8 ignore next -- defensive: callers throw Error instances @preserve */
|
|
220
|
+
meta: { operation, reason: err instanceof Error ? err.message : String(err), ...meta },
|
|
221
|
+
});
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Browser Navigation Skill
|
|
2
|
+
|
|
3
|
+
When to use browser tools and how to navigate effectively.
|
|
4
|
+
|
|
5
|
+
## When to Use Browser Tools
|
|
6
|
+
|
|
7
|
+
Use browser navigation when:
|
|
8
|
+
- The target site has no API (e.g., Airbnb, VRBO, travel blogs)
|
|
9
|
+
- Content is dynamic or requires JavaScript rendering
|
|
10
|
+
- Login-required pages need session-based access
|
|
11
|
+
- You need to verify visual content (screenshots, layouts)
|
|
12
|
+
- Price comparison requires real-time scraping
|
|
13
|
+
|
|
14
|
+
Do NOT use browser tools when:
|
|
15
|
+
- A dedicated API or MCP server exists (prefer `weather_lookup`, Duffel MCP, etc.)
|
|
16
|
+
- The data is available via a public JSON endpoint
|
|
17
|
+
- The task can be completed with `web_search`
|
|
18
|
+
|
|
19
|
+
## Stealth Browsing Best Practices
|
|
20
|
+
|
|
21
|
+
The `@playwright/mcp` server is configured with realistic user-agent and viewport settings. To avoid detection on travel sites:
|
|
22
|
+
|
|
23
|
+
- **Add delays between navigations**: Wait 2-5 seconds between page loads. Never hammer requests in rapid succession.
|
|
24
|
+
- **Vary timing**: Use random delays rather than fixed intervals (e.g., 2-5s, not exactly 3s every time).
|
|
25
|
+
- **Avoid predictable patterns**: Don't navigate the same path repeatedly in short succession.
|
|
26
|
+
- **Use realistic scroll behavior**: Scroll gradually through pages rather than jumping to specific elements.
|
|
27
|
+
- **Respect robots.txt**: Check for rate limits and crawling restrictions.
|
|
28
|
+
|
|
29
|
+
## Navigation Workflow
|
|
30
|
+
|
|
31
|
+
Follow this pattern for every page interaction:
|
|
32
|
+
|
|
33
|
+
1. **Navigate**: Use `browser_navigate` to load the page
|
|
34
|
+
2. **Wait**: Allow the page to fully load (use `browser_wait` if needed)
|
|
35
|
+
3. **Snapshot**: Take a `browser_snapshot` to understand the page structure
|
|
36
|
+
4. **Extract**: Parse the accessibility tree for relevant data
|
|
37
|
+
5. **Screenshot** (optional): Use `browser_screenshot` for visual confirmation
|
|
38
|
+
|
|
39
|
+
## Form Filling Patterns
|
|
40
|
+
|
|
41
|
+
### Login Flows
|
|
42
|
+
1. Navigate to the login page
|
|
43
|
+
2. Take a snapshot to identify form fields
|
|
44
|
+
3. Use `browser_type` for username/password fields
|
|
45
|
+
4. Use `browser_click` to submit
|
|
46
|
+
5. Wait for redirect, then verify login succeeded via snapshot
|
|
47
|
+
6. NEVER hardcode credentials -- use `vault_get` to retrieve login info
|
|
48
|
+
|
|
49
|
+
### Search Forms (Hotels, Flights, Rentals)
|
|
50
|
+
1. Navigate to the search page
|
|
51
|
+
2. Snapshot to identify input fields
|
|
52
|
+
3. Fill location/date fields with `browser_type`
|
|
53
|
+
4. Select options with `browser_select_option` or `browser_click`
|
|
54
|
+
5. Submit the search
|
|
55
|
+
6. Wait for results to load (travel sites often have loading animations)
|
|
56
|
+
7. Snapshot the results page to extract listings
|
|
57
|
+
|
|
58
|
+
### Booking Forms
|
|
59
|
+
1. **ALWAYS confirm with the user before proceeding to payment**
|
|
60
|
+
2. Fill traveler information
|
|
61
|
+
3. Use vault credentials for payment (via `vault_get` -- NEVER type raw card numbers)
|
|
62
|
+
4. Screenshot the final review page for user confirmation
|
|
63
|
+
5. Only click "Book" / "Confirm" after explicit user approval
|
|
64
|
+
|
|
65
|
+
## Anti-Bot Detection Avoidance
|
|
66
|
+
|
|
67
|
+
The stealth configuration handles most fingerprinting automatically. Additionally:
|
|
68
|
+
|
|
69
|
+
- **Don't access detection endpoints**: Avoid URLs containing "captcha", "challenge", "verify"
|
|
70
|
+
- **Handle CAPTCHAs**: If you encounter a CAPTCHA, pause and inform the user. Do not attempt automated solving.
|
|
71
|
+
- **Rotate viewport sizes occasionally**: Use different viewport sizes across sessions
|
|
72
|
+
- **Maintain cookies**: Use persistent `user-data-dir` to appear as a returning user
|
|
73
|
+
- **Avoid headless tells**: The user-agent is set to a real browser string
|
|
74
|
+
|
|
75
|
+
## Travel Site Patterns
|
|
76
|
+
|
|
77
|
+
### Airbnb / VRBO
|
|
78
|
+
1. Search by location + check-in/check-out dates + guests
|
|
79
|
+
2. Results load dynamically -- scroll to load more listings
|
|
80
|
+
3. Extract: title, price per night, total price, rating, number of reviews
|
|
81
|
+
4. For detailed info, click into each listing and snapshot
|
|
82
|
+
5. Compare top 3-5 options
|
|
83
|
+
|
|
84
|
+
### Hotel Sites (Booking.com, Hotels.com)
|
|
85
|
+
1. Search by destination + dates + guests + rooms
|
|
86
|
+
2. Filter by price range, star rating, amenities
|
|
87
|
+
3. Extract: name, price, location, rating, key amenities
|
|
88
|
+
4. Check cancellation policies (important for travel planning)
|
|
89
|
+
|
|
90
|
+
### Flight Comparison
|
|
91
|
+
1. Prefer Duffel MCP for flight search (structured API data)
|
|
92
|
+
2. Use browser only if Duffel doesn't cover the airline
|
|
93
|
+
3. Google Flights is useful for price comparison but requires careful navigation
|
|
94
|
+
|
|
95
|
+
## Error Handling
|
|
96
|
+
|
|
97
|
+
- **Page timeouts**: Retry once after 5 seconds. If still failing, inform the user.
|
|
98
|
+
- **CAPTCHAs**: Stop and ask the user to solve manually.
|
|
99
|
+
- **Stale elements**: Re-snapshot the page and retry the interaction.
|
|
100
|
+
- **Blocked/403**: The site may have detected automation. Wait 30 seconds and try with a different approach (e.g., direct URL instead of navigation).
|
|
101
|
+
- **Session expired**: Re-login using stored vault credentials.
|
|
102
|
+
|
|
103
|
+
## Human Confirmation Gates
|
|
104
|
+
|
|
105
|
+
**ALWAYS** confirm with the user before:
|
|
106
|
+
- Any booking or payment action
|
|
107
|
+
- Entering personal information (name, address, phone)
|
|
108
|
+
- Agreeing to terms of service
|
|
109
|
+
- Subscribing to any service
|
|
110
|
+
- Any action that creates a financial obligation
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Travel Planning Skill
|
|
2
|
+
|
|
3
|
+
Compose all travel infrastructure to plan and book trips effectively.
|
|
4
|
+
|
|
5
|
+
## Research Phase
|
|
6
|
+
|
|
7
|
+
### Destination Research
|
|
8
|
+
1. Use `geocode_search` to find candidate destinations and get coordinates
|
|
9
|
+
2. Use `weather_lookup` to check climate for travel dates
|
|
10
|
+
3. Use `travel_advisory` to check US State Dept safety levels (1-4):
|
|
11
|
+
- Level 1: Exercise Normal Precautions
|
|
12
|
+
- Level 2: Exercise Increased Caution
|
|
13
|
+
- Level 3: Reconsider Travel
|
|
14
|
+
- Level 4: Do Not Travel
|
|
15
|
+
4. Use browser tools (see `browser-navigation` skill) for destination reviews, travel blogs, local tips
|
|
16
|
+
5. Compare multiple destinations on weather, safety, cost, and flight availability
|
|
17
|
+
|
|
18
|
+
### Information Gathering Workflow
|
|
19
|
+
```
|
|
20
|
+
geocode_search -> get coordinates
|
|
21
|
+
weather_lookup -> check weather at coordinates
|
|
22
|
+
travel_advisory -> check safety level
|
|
23
|
+
browser tools -> reviews, local info, pricing
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Flight Search
|
|
27
|
+
|
|
28
|
+
### Using Duffel MCP (when available)
|
|
29
|
+
- Search via `ouro mcp call duffel search_flights`
|
|
30
|
+
- Provide: origin, destination, dates, passengers, cabin class
|
|
31
|
+
- Compare options on price, duration, stops, airline
|
|
32
|
+
|
|
33
|
+
### Using Browser (fallback)
|
|
34
|
+
- Use browser-navigation skill patterns for Google Flights, airline sites
|
|
35
|
+
- Add delays between searches to avoid detection
|
|
36
|
+
|
|
37
|
+
### Comparison Criteria
|
|
38
|
+
Present top 3-5 options comparing:
|
|
39
|
+
- Total price (including bags, seat selection)
|
|
40
|
+
- Duration and number of stops
|
|
41
|
+
- Airline and aircraft
|
|
42
|
+
- Departure/arrival times
|
|
43
|
+
- Cancellation/change policy
|
|
44
|
+
|
|
45
|
+
## Accommodation Search
|
|
46
|
+
|
|
47
|
+
### Hotel Search
|
|
48
|
+
- Use Expedia MCP when available (`ouro mcp call expedia search_hotels`)
|
|
49
|
+
- Compare: price per night, total stay cost, location, rating, amenities
|
|
50
|
+
- Check cancellation policies (crucial for travel planning)
|
|
51
|
+
|
|
52
|
+
### Vacation Rentals (Browser-based)
|
|
53
|
+
- Use browser-navigation skill for Airbnb/VRBO
|
|
54
|
+
- Search by location, dates, guests, budget
|
|
55
|
+
- Compare: price, location proximity, reviews, amenities, host rating
|
|
56
|
+
- Screenshot listings for user review
|
|
57
|
+
|
|
58
|
+
### Comparison Criteria
|
|
59
|
+
- Price per night and total cost
|
|
60
|
+
- Location (distance to key attractions/activities)
|
|
61
|
+
- Reviews and ratings
|
|
62
|
+
- Amenities (WiFi, kitchen, parking, pool)
|
|
63
|
+
- Cancellation flexibility
|
|
64
|
+
|
|
65
|
+
## Booking Workflow
|
|
66
|
+
|
|
67
|
+
### Critical Safety Gates
|
|
68
|
+
- **ALWAYS confirm with user before any booking action**
|
|
69
|
+
- **ALWAYS confirm before entering payment information**
|
|
70
|
+
- **ALWAYS confirm before agreeing to terms**
|
|
71
|
+
- **Never proceed with a financial commitment without explicit approval**
|
|
72
|
+
|
|
73
|
+
### Credential Handling
|
|
74
|
+
- Use `vault_get` to retrieve payment credentials (never expose raw values)
|
|
75
|
+
- Use `vault_get` for loyalty program numbers
|
|
76
|
+
- Use browser-navigation skill form patterns for entering credentials
|
|
77
|
+
- The credential gateway ensures secrets never enter model context
|
|
78
|
+
|
|
79
|
+
### Post-Booking
|
|
80
|
+
- Save confirmation details (confirmation number, dates, hotel name, airline, booking reference)
|
|
81
|
+
- Save to diary/journal for future reference
|
|
82
|
+
- Set reminders for check-in windows
|
|
83
|
+
- Note cancellation deadlines
|
|
84
|
+
|
|
85
|
+
## Preference Management
|
|
86
|
+
|
|
87
|
+
### Storing Preferences
|
|
88
|
+
Track and reference these travel preferences:
|
|
89
|
+
- Preferred airlines and alliance status
|
|
90
|
+
- Preferred hotel chains and loyalty numbers
|
|
91
|
+
- Seat preferences (window/aisle, extra legroom)
|
|
92
|
+
- Dietary needs for in-flight meals
|
|
93
|
+
- Budget ranges (per night for hotels, per flight)
|
|
94
|
+
- Must-have amenities (WiFi, gym, pool)
|
|
95
|
+
|
|
96
|
+
### Using Preferences
|
|
97
|
+
- Reference stored preferences when searching
|
|
98
|
+
- Apply airline preferences to flight comparisons
|
|
99
|
+
- Apply hotel brand preferences to accommodation searches
|
|
100
|
+
- Adjust budget ranges based on destination
|
|
101
|
+
|
|
102
|
+
## Tool Reference
|
|
103
|
+
|
|
104
|
+
### Native Tools (always available)
|
|
105
|
+
- `weather_lookup` - Current weather by city or coordinates
|
|
106
|
+
- `travel_advisory` - US State Dept advisory by country code
|
|
107
|
+
- `geocode_search` - Location/POI search with coordinates
|
|
108
|
+
- `vault_get` - Retrieve credentials (payment, loyalty)
|
|
109
|
+
- `vault_store` - Store new credentials (family trust required)
|
|
110
|
+
|
|
111
|
+
### MCP Tools (when configured)
|
|
112
|
+
- Browser tools via `@playwright/mcp` - see `browser-navigation` skill
|
|
113
|
+
- Duffel flight search (when MCP server available)
|
|
114
|
+
- Expedia hotel search (when MCP server available)
|
|
115
|
+
- Bitwarden vault management via `@bitwarden/mcp-server` (agent-facing interactive vault access)
|
|
116
|
+
|
|
117
|
+
### Bitwarden MCP Server
|
|
118
|
+
|
|
119
|
+
For agent-facing vault interaction (browsing items, searching, reading fields), configure
|
|
120
|
+
`@bitwarden/mcp-server` as an MCP server in agent.json:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"mcpServers": {
|
|
125
|
+
"bitwarden": {
|
|
126
|
+
"command": "npx",
|
|
127
|
+
"args": ["@bitwarden/mcp-server"]
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
This is distinct from the built-in `bw` CLI-based credential gateway, which handles
|
|
134
|
+
harness-internal secret injection (e.g., API keys injected into tool requests via
|
|
135
|
+
`getRawSecret()`). The MCP server is for the agent to interactively browse and manage
|
|
136
|
+
vault items on behalf of the user.
|
|
137
|
+
|
|
138
|
+
### Human Confirmation Required For
|
|
139
|
+
- Any booking or payment
|
|
140
|
+
- Entering personal information
|
|
141
|
+
- Agreeing to terms of service
|
|
142
|
+
- Creating financial obligations
|
|
143
|
+
- Sharing credentials with third parties
|