@ouro.bot/cli 0.1.0-alpha.980 → 0.1.0-alpha.982
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 +6 -0
- package/changelog.json +12 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/cli-desk.js +9 -0
- package/dist/heart/daemon/cli-exec.js +5 -0
- package/dist/heart/daemon/cli-help.js +4 -4
- package/dist/heart/daemon/desk-admin.js +420 -0
- package/dist/repertoire/guardrails.js +79 -8
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
<!-- ourostack:brand:start -->
|
|
2
|
+
<p align="center">
|
|
3
|
+
<a href="https://github.com/ourostack"><img src="https://raw.githubusercontent.com/ourostack/.github/main/brand/ouro.png" alt="Ouro" width="160" height="160"></a>
|
|
4
|
+
</p>
|
|
5
|
+
<!-- ourostack:brand:end -->
|
|
6
|
+
|
|
1
7
|
# Ouroboros Agent Harness
|
|
2
8
|
|
|
3
9
|
If you're a future agent reading this, welcome home. This repo is the harness that gives you your body, your senses, your record, your habits, and your ability to think and connect with people. The goal of these docs is not just to be correct, but to leave you a place that feels understandable and kind to wake up inside.
|
package/changelog.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
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.982",
|
|
6
|
+
"changes": [
|
|
7
|
+
"An agent's psyche and friend records can no longer be reached by a write through a relative path, a case-altered path or a symlink."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"version": "0.1.0-alpha.981",
|
|
12
|
+
"changes": [
|
|
13
|
+
"Ouro admins can run Hosted Desk from the terminal. `ouro desk invite --name <name>` creates an account and issues a single-use, seven-day invite, and `--account <id>` re-invites an account whose invite went unused; `--out <file>` writes the link to a new 0600 file instead of the screen. `ouro desk access <account> on|off` turns an account's Desk access on or off and prints the state the gateway recorded, and `ouro desk delete-account <account> --yes` deletes one and says if its desk could not be removed. Each command signs in to Ouro identity in the browser (authorization code with PKCE on a 127.0.0.1 loopback redirect), reads the sign-in settings the gateway publishes at /.well-known/ouro-desk-admin, checks the tenant's issuer and then calls the gateway's admin route, which enforces the ouro.admin role. desk.ouro.bot must name Ouro's production tenant, and any other gateway needs --trust-gateway because it receives an admin token. Nothing is cached, and the token is never printed or logged."
|
|
14
|
+
]
|
|
15
|
+
},
|
|
4
16
|
{
|
|
5
17
|
"version": "0.1.0-alpha.980",
|
|
6
18
|
"changes": [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.982</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -43,6 +43,7 @@ exports.parseDeskCommand = parseDeskCommand;
|
|
|
43
43
|
exports.parseTaskAliasCommand = parseTaskAliasCommand;
|
|
44
44
|
exports.executeDeskCommand = executeDeskCommand;
|
|
45
45
|
const runtime_1 = require("../../nerves/runtime");
|
|
46
|
+
const desk_admin_1 = require("./desk-admin");
|
|
46
47
|
// ── Public usage hint ──
|
|
47
48
|
function deskUsage() {
|
|
48
49
|
return [
|
|
@@ -62,6 +63,12 @@ function deskUsage() {
|
|
|
62
63
|
" ouro desk reindex",
|
|
63
64
|
" ouro desk thread <path>",
|
|
64
65
|
"",
|
|
66
|
+
"Hosted Desk admin (needs the ouro.admin role; signs in through the browser):",
|
|
67
|
+
" ouro desk invite --name <name> [--out <file>] (--account <id> re-invites an unused account)",
|
|
68
|
+
" ouro desk access <account> on|off",
|
|
69
|
+
" ouro desk delete-account <account> --yes",
|
|
70
|
+
" (each takes --gateway <url> --trust-gateway to manage a Hosted Desk other than desk.ouro.bot)",
|
|
71
|
+
"",
|
|
65
72
|
"Alias:",
|
|
66
73
|
" ouro task ... (routes through ouro desk task ...)",
|
|
67
74
|
].join("\n");
|
|
@@ -219,6 +226,8 @@ function parseDeskCommand(args) {
|
|
|
219
226
|
return parseFrictionSubverb(rest);
|
|
220
227
|
if (head === "lesson")
|
|
221
228
|
return parseLessonSubverb(rest);
|
|
229
|
+
if (head === "invite" || head === "access" || head === "delete-account")
|
|
230
|
+
return (0, desk_admin_1.parseDeskAdminCommand)(head, rest);
|
|
222
231
|
if (head === "search") {
|
|
223
232
|
const query = rest.join(" ").trim();
|
|
224
233
|
if (!query)
|
|
@@ -103,6 +103,7 @@ const cli_parse_2 = require("./cli-parse");
|
|
|
103
103
|
const cli_help_1 = require("./cli-help");
|
|
104
104
|
const plugin_cli_1 = require("./plugin-cli");
|
|
105
105
|
const cli_desk_1 = require("./cli-desk");
|
|
106
|
+
const desk_admin_1 = require("./desk-admin");
|
|
106
107
|
const migrate_to_desk_1 = require("./migrate-to-desk");
|
|
107
108
|
const core_2 = require("../../nerves/review/core");
|
|
108
109
|
const cli_render_1 = require("./cli-render");
|
|
@@ -8062,6 +8063,10 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
8062
8063
|
if (command.kind === "desk") {
|
|
8063
8064
|
return (0, cli_desk_1.executeDeskCommand)(command, deps);
|
|
8064
8065
|
}
|
|
8066
|
+
// ── Hosted Desk admin (HTTPS to the gateway after an Ouro sign-in; no daemon needed) ──
|
|
8067
|
+
if (command.kind === "desk.admin") {
|
|
8068
|
+
return (0, desk_admin_1.executeDeskAdminCommand)(command, deps);
|
|
8069
|
+
}
|
|
8065
8070
|
// ── move: make this machine the agent's home (local, no daemon needed) ──
|
|
8066
8071
|
if (command.kind === "agent.move") {
|
|
8067
8072
|
return executeAgentMove(command, deps);
|
|
@@ -192,10 +192,10 @@ exports.COMMAND_REGISTRY = {
|
|
|
192
192
|
},
|
|
193
193
|
desk: {
|
|
194
194
|
category: "Tasks",
|
|
195
|
-
description: "Manage the agent's desk — tasks, tracks, friction, lessons, search, and recall (routes through the desk MCP server).",
|
|
196
|
-
usage: "ouro desk <task|track|friction|lesson|search|recall|reindex|thread> ...",
|
|
197
|
-
example: "ouro desk
|
|
198
|
-
subcommands: ["task", "track", "friction", "lesson", "search", "recall", "reindex", "thread"],
|
|
195
|
+
description: "Manage the agent's desk — tasks, tracks, friction, lessons, search, and recall (routes through the desk MCP server). Ouro admins also invite people to Hosted Desk and manage their access.",
|
|
196
|
+
usage: "ouro desk <task|track|friction|lesson|search|recall|reindex|thread|invite|access|delete-account> ...",
|
|
197
|
+
example: "ouro desk invite --name \"Sam\"",
|
|
198
|
+
subcommands: ["task", "track", "friction", "lesson", "search", "recall", "reindex", "thread", "invite", "access", "delete-account"],
|
|
199
199
|
},
|
|
200
200
|
task: {
|
|
201
201
|
category: "Tasks",
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Hosted Desk admin commands: `ouro desk invite`, `ouro desk access` and
|
|
4
|
+
* `ouro desk delete-account`.
|
|
5
|
+
*
|
|
6
|
+
* Each command signs the person in to Ouro identity (Entra External ID) with
|
|
7
|
+
* an authorization-code + PKCE flow on a loopback redirect (RFC 8252), then
|
|
8
|
+
* calls the gateway's admin route with the access token. The gateway, not this
|
|
9
|
+
* CLI, enforces the `ouro.admin` app role.
|
|
10
|
+
*
|
|
11
|
+
* The gateway publishes what the sign-in needs at `ADMIN_CONFIG_PATH`:
|
|
12
|
+
* `{ tenantId, subdomain, clientId, scope }`. The CLI reads the tenant's
|
|
13
|
+
* discovery document from `<subdomain>.ciamlogin.com` and requires the issuer
|
|
14
|
+
* `https://<tenantId>.ciamlogin.com/<tenantId>/v2.0`, the same check the
|
|
15
|
+
* gateway makes. The default gateway must name Ouro's production tenant. Any
|
|
16
|
+
* other gateway receives an admin token, so it needs `--trust-gateway`.
|
|
17
|
+
*
|
|
18
|
+
* Nothing is cached: the token lives only for the one command, and it is never
|
|
19
|
+
* printed or logged. An invite link is a single-use bearer credential, so
|
|
20
|
+
* `--out` writes it to a new 0600 file instead of the screen.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.SIGN_IN_TIMEOUT_MS = exports.ADMIN_CONFIG_PATH = exports.PRODUCTION_TENANT_ID = exports.DEFAULT_DESK_GATEWAY = void 0;
|
|
24
|
+
exports.shown = shown;
|
|
25
|
+
exports.parseDeskAdminCommand = parseDeskAdminCommand;
|
|
26
|
+
exports.gatewayOrigin = gatewayOrigin;
|
|
27
|
+
exports.signInForDeskAdmin = signInForDeskAdmin;
|
|
28
|
+
exports.executeDeskAdminCommand = executeDeskAdminCommand;
|
|
29
|
+
const node_child_process_1 = require("node:child_process");
|
|
30
|
+
const node_crypto_1 = require("node:crypto");
|
|
31
|
+
const node_fs_1 = require("node:fs");
|
|
32
|
+
const node_http_1 = require("node:http");
|
|
33
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
34
|
+
exports.DEFAULT_DESK_GATEWAY = "https://desk.ouro.bot";
|
|
35
|
+
/** Ouro's production External ID tenant (`ourobot`), which the default gateway must name. */
|
|
36
|
+
exports.PRODUCTION_TENANT_ID = "de8841c3-7799-4523-bbad-44f8a2426eaa";
|
|
37
|
+
exports.ADMIN_CONFIG_PATH = "/.well-known/ouro-desk-admin";
|
|
38
|
+
exports.SIGN_IN_TIMEOUT_MS = 5 * 60_000;
|
|
39
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
40
|
+
const ACCOUNT_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
41
|
+
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
42
|
+
const SUBDOMAIN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
|
|
43
|
+
/** Server-supplied text made safe to print: no control characters, bounded length. */
|
|
44
|
+
function shown(value, max = 200) {
|
|
45
|
+
// eslint-disable-next-line no-control-regex
|
|
46
|
+
return String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, max);
|
|
47
|
+
}
|
|
48
|
+
// ── Parsing ──
|
|
49
|
+
const GATEWAY_FLAGS = "[--gateway <url> --trust-gateway]";
|
|
50
|
+
const USAGE = {
|
|
51
|
+
invite: `Usage: ouro desk invite --name <name> | --account <id> [--out <file>] ${GATEWAY_FLAGS}`,
|
|
52
|
+
access: `Usage: ouro desk access <account> on|off ${GATEWAY_FLAGS}`,
|
|
53
|
+
"delete-account": `Usage: ouro desk delete-account <account> --yes ${GATEWAY_FLAGS}`,
|
|
54
|
+
};
|
|
55
|
+
function splitFlags(args, valued, boolean, usage) {
|
|
56
|
+
const positional = [];
|
|
57
|
+
const flags = {};
|
|
58
|
+
for (let i = 0; i < args.length; i++) {
|
|
59
|
+
const arg = args[i];
|
|
60
|
+
if (!arg.startsWith("--")) {
|
|
61
|
+
positional.push(arg);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (boolean.includes(arg)) {
|
|
65
|
+
flags[arg] = true;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const value = args[i + 1];
|
|
69
|
+
if (!valued.includes(arg) || value === undefined || value.startsWith("--"))
|
|
70
|
+
throw new Error(usage);
|
|
71
|
+
flags[arg] = value;
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
return { positional, flags };
|
|
75
|
+
}
|
|
76
|
+
function accountIdOf(value) {
|
|
77
|
+
if (!ACCOUNT_ID.test(value))
|
|
78
|
+
throw new Error(`'${shown(value, 80)}' is not an account id: use the id Hosted Desk gave the account`);
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
function gatewayOf(flags) {
|
|
82
|
+
return {
|
|
83
|
+
...(typeof flags["--gateway"] === "string" ? { gateway: flags["--gateway"] } : {}),
|
|
84
|
+
...(flags["--trust-gateway"] === true ? { trustGateway: true } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function parseDeskAdminCommand(verb, args) {
|
|
88
|
+
const usage = USAGE[verb];
|
|
89
|
+
if (verb === "invite") {
|
|
90
|
+
const { positional, flags } = splitFlags(args, ["--name", "--account", "--out", "--gateway"], ["--trust-gateway"], usage);
|
|
91
|
+
const displayName = typeof flags["--name"] === "string" ? flags["--name"].trim() : "";
|
|
92
|
+
const account = typeof flags["--account"] === "string" ? flags["--account"] : undefined;
|
|
93
|
+
// --name invites a new person; --account re-invites an account whose invite went unused.
|
|
94
|
+
if (positional.length > 0 || (!displayName) === (account === undefined))
|
|
95
|
+
throw new Error(usage);
|
|
96
|
+
return {
|
|
97
|
+
kind: "desk.admin",
|
|
98
|
+
action: "invite",
|
|
99
|
+
...(account === undefined ? { displayName } : { accountId: accountIdOf(account) }),
|
|
100
|
+
...gatewayOf(flags),
|
|
101
|
+
...(typeof flags["--out"] === "string" ? { out: flags["--out"] } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (verb === "access") {
|
|
105
|
+
const { positional, flags } = splitFlags(args, ["--gateway"], ["--trust-gateway"], usage);
|
|
106
|
+
const [id, state] = positional;
|
|
107
|
+
if (positional.length !== 2 || (state !== "on" && state !== "off"))
|
|
108
|
+
throw new Error(usage);
|
|
109
|
+
return { kind: "desk.admin", action: "access", accountId: accountIdOf(id), on: state === "on", ...gatewayOf(flags) };
|
|
110
|
+
}
|
|
111
|
+
const { positional, flags } = splitFlags(args, ["--gateway"], ["--yes", "--trust-gateway"], usage);
|
|
112
|
+
if (positional.length !== 1)
|
|
113
|
+
throw new Error(usage);
|
|
114
|
+
const accountId = accountIdOf(positional[0]);
|
|
115
|
+
if (flags["--yes"] !== true) {
|
|
116
|
+
throw new Error(`Deleting account ${accountId} removes its Hosted Desk account and sign-ins and cannot be undone; pass --yes to confirm.\n${usage}`);
|
|
117
|
+
}
|
|
118
|
+
return { kind: "desk.admin", action: "delete-account", accountId, ...gatewayOf(flags) };
|
|
119
|
+
}
|
|
120
|
+
// ── Gateway ──
|
|
121
|
+
/** The gateway's origin, or null when the value is not a bare https origin. */
|
|
122
|
+
function gatewayOrigin(value) {
|
|
123
|
+
let url;
|
|
124
|
+
try {
|
|
125
|
+
url = new URL(value);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (/[?#]/.test(value) || url.protocol !== "https:" || url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== ""))
|
|
131
|
+
return null;
|
|
132
|
+
return url.origin;
|
|
133
|
+
}
|
|
134
|
+
async function getJson(fetchImpl, url) {
|
|
135
|
+
const response = await fetchImpl(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
136
|
+
if (!response.ok)
|
|
137
|
+
throw new Error(`${url} answered ${response.status}`);
|
|
138
|
+
return response.json();
|
|
139
|
+
}
|
|
140
|
+
function isHttps(value) {
|
|
141
|
+
return typeof value === "string" && URL.canParse(value) && new URL(value).protocol === "https:";
|
|
142
|
+
}
|
|
143
|
+
function nonEmpty(value) {
|
|
144
|
+
return typeof value === "string" && value.trim() !== "";
|
|
145
|
+
}
|
|
146
|
+
const CALLBACK_PAGE = (heading, detail) => `<!doctype html><meta charset="utf-8"><title>Ouro sign-in</title><body style="font-family:system-ui;margin:3rem"><h1>${heading}</h1><p>${detail}</p></body>`;
|
|
147
|
+
/**
|
|
148
|
+
* Waits for the browser's redirect. Only a request carrying this sign-in's
|
|
149
|
+
* state settles it, so a stray request to the loopback port can't end it.
|
|
150
|
+
*/
|
|
151
|
+
function listenForCallback(state, timeoutMs) {
|
|
152
|
+
return new Promise((resolveListening, rejectListening) => {
|
|
153
|
+
const { promise: result, resolve: settle, reject: fail } = Promise.withResolvers();
|
|
154
|
+
// Handled by the caller's await; this keeps an early timeout from becoming an unhandled rejection.
|
|
155
|
+
result.catch(() => { });
|
|
156
|
+
const server = (0, node_http_1.createServer)((request, response) => {
|
|
157
|
+
const url = new URL(request.url, "http://127.0.0.1");
|
|
158
|
+
if (url.pathname !== "/callback") {
|
|
159
|
+
response.writeHead(404).end();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (url.searchParams.get("state") !== state) {
|
|
163
|
+
response.writeHead(400, { "content-type": "text/html; charset=utf-8" }).end(CALLBACK_PAGE("Not this sign-in", "This link does not belong to the sign-in the terminal is waiting for."));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const code = url.searchParams.get("code");
|
|
167
|
+
const error = url.searchParams.get("error");
|
|
168
|
+
const callback = { ...(code ? { code } : {}), ...(error ? { error } : {}) };
|
|
169
|
+
const page = code && !error
|
|
170
|
+
? CALLBACK_PAGE("Signed in to Ouro", "You can close this tab and go back to the terminal.")
|
|
171
|
+
: CALLBACK_PAGE("Sign-in did not finish", "Go back to the terminal to see why.");
|
|
172
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(page);
|
|
173
|
+
settle(callback);
|
|
174
|
+
});
|
|
175
|
+
const timer = setTimeout(() => fail(new Error(`Ouro sign-in timed out after ${Math.round(timeoutMs / 1000)} seconds`)), timeoutMs);
|
|
176
|
+
const close = () => {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
// Idle only: the browser's own request may still be receiving its page.
|
|
179
|
+
server.close();
|
|
180
|
+
server.closeIdleConnections();
|
|
181
|
+
};
|
|
182
|
+
server.once("error", rejectListening);
|
|
183
|
+
server.listen(0, "127.0.0.1", () => {
|
|
184
|
+
const { port } = server.address();
|
|
185
|
+
resolveListening({ redirectUri: `http://127.0.0.1:${port}/callback`, result, close });
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/** Signs in to Ouro identity for the gateway's admin scope and returns the access token. */
|
|
190
|
+
async function signInForDeskAdmin(input) {
|
|
191
|
+
const configUrl = `${input.gateway}${exports.ADMIN_CONFIG_PATH}`;
|
|
192
|
+
const config = (await getJson(input.fetchImpl, configUrl));
|
|
193
|
+
if (!config
|
|
194
|
+
|| typeof config.tenantId !== "string" || !GUID.test(config.tenantId)
|
|
195
|
+
|| typeof config.subdomain !== "string" || !SUBDOMAIN.test(config.subdomain)
|
|
196
|
+
|| !nonEmpty(config.clientId) || !nonEmpty(config.scope)) {
|
|
197
|
+
throw new Error(`${configUrl} is not a valid admin sign-in configuration`);
|
|
198
|
+
}
|
|
199
|
+
// Entra's discovery names the tenant in lowercase.
|
|
200
|
+
const tenantId = config.tenantId.toLowerCase();
|
|
201
|
+
if (input.expectedTenantId && tenantId !== input.expectedTenantId.toLowerCase()) {
|
|
202
|
+
throw new Error(`${input.gateway} names Ouro identity tenant ${tenantId}, not Ouro's production tenant ${input.expectedTenantId}`);
|
|
203
|
+
}
|
|
204
|
+
const issuer = `https://${tenantId}.ciamlogin.com/${tenantId}/v2.0`;
|
|
205
|
+
const discovery = (await getJson(input.fetchImpl, `https://${config.subdomain}.ciamlogin.com/${tenantId}/v2.0/.well-known/openid-configuration`));
|
|
206
|
+
if (discovery?.issuer !== issuer) {
|
|
207
|
+
throw new Error(`Ouro identity's discovery document names issuer ${shown(discovery?.issuer)}, but Hosted Desk expects ${issuer}`);
|
|
208
|
+
}
|
|
209
|
+
if (!isHttps(discovery.authorization_endpoint) || !isHttps(discovery.token_endpoint)) {
|
|
210
|
+
throw new Error("Ouro identity's discovery document has no https authorization and token endpoints");
|
|
211
|
+
}
|
|
212
|
+
const verifier = (0, node_crypto_1.randomBytes)(32).toString("base64url");
|
|
213
|
+
const state = (0, node_crypto_1.randomBytes)(16).toString("base64url");
|
|
214
|
+
const listener = await listenForCallback(state, input.timeoutMs ?? exports.SIGN_IN_TIMEOUT_MS);
|
|
215
|
+
try {
|
|
216
|
+
const authorize = new URL(discovery.authorization_endpoint);
|
|
217
|
+
authorize.search = new URLSearchParams({
|
|
218
|
+
client_id: config.clientId,
|
|
219
|
+
response_type: "code",
|
|
220
|
+
redirect_uri: listener.redirectUri,
|
|
221
|
+
scope: config.scope,
|
|
222
|
+
state,
|
|
223
|
+
code_challenge: (0, node_crypto_1.createHash)("sha256").update(verifier).digest("base64url"),
|
|
224
|
+
code_challenge_method: "S256",
|
|
225
|
+
prompt: "select_account",
|
|
226
|
+
}).toString();
|
|
227
|
+
input.writeStdout(`Sign in to Ouro in your browser to manage ${new URL(input.gateway).host}. If it does not open, visit:\n${authorize.toString()}`);
|
|
228
|
+
// Not awaited: some openers stay in the foreground until the browser exits.
|
|
229
|
+
void Promise.resolve().then(() => input.openUrl(authorize.toString())).catch(() => { });
|
|
230
|
+
const callback = await listener.result;
|
|
231
|
+
if (callback.error)
|
|
232
|
+
throw new Error(`Ouro sign-in was cancelled: ${shown(callback.error, 80)}`);
|
|
233
|
+
if (!callback.code)
|
|
234
|
+
throw new Error("Ouro sign-in returned no code");
|
|
235
|
+
const response = await input.fetchImpl(discovery.token_endpoint, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
238
|
+
body: new URLSearchParams({
|
|
239
|
+
grant_type: "authorization_code",
|
|
240
|
+
client_id: config.clientId,
|
|
241
|
+
code: callback.code,
|
|
242
|
+
redirect_uri: listener.redirectUri,
|
|
243
|
+
code_verifier: verifier,
|
|
244
|
+
}).toString(),
|
|
245
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
246
|
+
});
|
|
247
|
+
const grant = (await response.json().catch(() => null));
|
|
248
|
+
if (!response.ok)
|
|
249
|
+
throw new Error(`Ouro sign-in could not finish: ${nonEmpty(grant?.error) ? shown(grant.error, 80) : `token endpoint answered ${response.status}`}`);
|
|
250
|
+
if (!nonEmpty(grant?.access_token))
|
|
251
|
+
throw new Error("Ouro sign-in could not finish: no access token");
|
|
252
|
+
return grant.access_token;
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
listener.close();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/* v8 ignore start -- production default: launches the platform's browser opener @preserve */
|
|
259
|
+
function openInBrowser(url) {
|
|
260
|
+
const [command, args] = process.platform === "darwin"
|
|
261
|
+
? ["open", [url]]
|
|
262
|
+
: process.platform === "win32"
|
|
263
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
264
|
+
: ["xdg-open", [url]];
|
|
265
|
+
// Detached and unreferenced: an opener that stays in the foreground must not keep the CLI from exiting.
|
|
266
|
+
return new Promise((resolve, reject) => {
|
|
267
|
+
const child = (0, node_child_process_1.spawn)(command, args, { detached: true, stdio: "ignore" });
|
|
268
|
+
child.once("spawn", () => { child.unref(); resolve(); });
|
|
269
|
+
child.once("error", reject);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/* v8 ignore stop */
|
|
273
|
+
// ── Executing ──
|
|
274
|
+
function requestFor(command) {
|
|
275
|
+
if (command.action === "invite") {
|
|
276
|
+
return { method: "POST", path: "/admin/invites", body: command.accountId ? { accountId: command.accountId } : { displayName: command.displayName } };
|
|
277
|
+
}
|
|
278
|
+
const account = `/admin/accounts/${encodeURIComponent(command.accountId)}`;
|
|
279
|
+
if (command.action === "access")
|
|
280
|
+
return { method: "POST", path: `${account}/access`, body: { on: command.on } };
|
|
281
|
+
return { method: "DELETE", path: account };
|
|
282
|
+
}
|
|
283
|
+
async function refusal(response) {
|
|
284
|
+
if (response.status === 401)
|
|
285
|
+
return "Hosted Desk did not accept the Ouro sign-in (401). Sign in again; if it keeps failing, the gateway's admin sign-in settings are wrong.";
|
|
286
|
+
if (response.status === 403)
|
|
287
|
+
return "Hosted Desk refused the request (403): the signed-in Ouro account needs the ouro.admin role.";
|
|
288
|
+
const body = (await response.json().catch(() => null));
|
|
289
|
+
const reason = nonEmpty(body?.message) ? body.message : nonEmpty(body?.error) ? body.error : null;
|
|
290
|
+
return reason ? `Hosted Desk refused the request (${response.status}): ${shown(reason)}` : `Hosted Desk refused the request (${response.status}).`;
|
|
291
|
+
}
|
|
292
|
+
async function executeDeskAdminCommand(command, deps, signIn = signInForDeskAdmin) {
|
|
293
|
+
const requested = command.gateway ?? exports.DEFAULT_DESK_GATEWAY;
|
|
294
|
+
const gateway = gatewayOrigin(requested);
|
|
295
|
+
(0, runtime_1.emitNervesEvent)({
|
|
296
|
+
component: "daemon",
|
|
297
|
+
event: "daemon.desk_admin_start",
|
|
298
|
+
message: `ouro desk ${command.action}`,
|
|
299
|
+
meta: { action: command.action, gateway: gateway ?? "invalid" },
|
|
300
|
+
});
|
|
301
|
+
const fail = (message, status) => {
|
|
302
|
+
(0, runtime_1.emitNervesEvent)({
|
|
303
|
+
level: "error",
|
|
304
|
+
component: "daemon",
|
|
305
|
+
event: "daemon.desk_admin_error",
|
|
306
|
+
message: `ouro desk ${command.action} failed`,
|
|
307
|
+
meta: { action: command.action, gateway: gateway ?? "invalid", ...(status === undefined ? {} : { status }) },
|
|
308
|
+
});
|
|
309
|
+
deps.setExitCode?.(1);
|
|
310
|
+
deps.writeStdout(message);
|
|
311
|
+
return message;
|
|
312
|
+
};
|
|
313
|
+
const done = (message, status) => {
|
|
314
|
+
(0, runtime_1.emitNervesEvent)({
|
|
315
|
+
component: "daemon",
|
|
316
|
+
event: "daemon.desk_admin_end",
|
|
317
|
+
message: `ouro desk ${command.action} succeeded`,
|
|
318
|
+
meta: { action: command.action, gateway, status },
|
|
319
|
+
});
|
|
320
|
+
deps.writeStdout(message);
|
|
321
|
+
return message;
|
|
322
|
+
};
|
|
323
|
+
if (!gateway)
|
|
324
|
+
return fail(`The Hosted Desk gateway must be a bare https origin such as ${exports.DEFAULT_DESK_GATEWAY}, not ${shown(requested, 120)}`);
|
|
325
|
+
const isDefault = gateway === exports.DEFAULT_DESK_GATEWAY;
|
|
326
|
+
if (!isDefault && command.trustGateway !== true) {
|
|
327
|
+
return fail(`${gateway} is not ${exports.DEFAULT_DESK_GATEWAY}. Signing in sends it an Ouro admin token, so pass --trust-gateway only for a Hosted Desk we run, such as staging.`);
|
|
328
|
+
}
|
|
329
|
+
// Claim the --out file before minting the invite, so a bad path costs no live link.
|
|
330
|
+
const outPath = command.action === "invite" ? command.out : undefined;
|
|
331
|
+
if (outPath) {
|
|
332
|
+
try {
|
|
333
|
+
(0, node_fs_1.closeSync)((0, node_fs_1.openSync)(outPath, "wx", 0o600));
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
const code = error.code;
|
|
337
|
+
return fail(code === "EEXIST" ? `${outPath} already exists; choose a new file for the invite link` : `Could not create ${outPath}: ${error.message}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const releaseOut = () => {
|
|
341
|
+
if (outPath)
|
|
342
|
+
(0, node_fs_1.unlinkSync)(outPath);
|
|
343
|
+
};
|
|
344
|
+
/* v8 ignore next -- production default: tests always inject fetchImpl @preserve */
|
|
345
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
346
|
+
let token;
|
|
347
|
+
try {
|
|
348
|
+
token = await signIn({
|
|
349
|
+
gateway,
|
|
350
|
+
fetchImpl,
|
|
351
|
+
/* v8 ignore next -- production default: tests inject openUrl or a sign-in @preserve */
|
|
352
|
+
openUrl: deps.openUrl ?? openInBrowser,
|
|
353
|
+
writeStdout: deps.writeStdout,
|
|
354
|
+
...(isDefault ? { expectedTenantId: exports.PRODUCTION_TENANT_ID } : {}),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
releaseOut();
|
|
359
|
+
return fail(error.message);
|
|
360
|
+
}
|
|
361
|
+
const request = requestFor(command);
|
|
362
|
+
let response;
|
|
363
|
+
try {
|
|
364
|
+
response = await fetchImpl(`${gateway}${request.path}`, {
|
|
365
|
+
method: request.method,
|
|
366
|
+
headers: {
|
|
367
|
+
authorization: `Bearer ${token}`,
|
|
368
|
+
accept: "application/json",
|
|
369
|
+
...(request.body === undefined ? {} : { "content-type": "application/json" }),
|
|
370
|
+
},
|
|
371
|
+
...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
|
|
372
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
releaseOut();
|
|
377
|
+
return fail(`Could not reach Hosted Desk at ${gateway}: ${error.message}`);
|
|
378
|
+
}
|
|
379
|
+
if (!response.ok) {
|
|
380
|
+
releaseOut();
|
|
381
|
+
return fail(await refusal(response), response.status);
|
|
382
|
+
}
|
|
383
|
+
const result = (await response.json().catch(() => null));
|
|
384
|
+
if (command.action === "access") {
|
|
385
|
+
if (typeof result?.deskAccess !== "boolean")
|
|
386
|
+
return fail("Hosted Desk sent an unexpected access response", response.status);
|
|
387
|
+
return done(`Desk access for ${command.accountId} is now ${result.deskAccess ? "on" : "off"}.`, response.status);
|
|
388
|
+
}
|
|
389
|
+
if (command.action === "delete-account") {
|
|
390
|
+
if (result?.deleted !== true)
|
|
391
|
+
return fail("Hosted Desk sent an unexpected delete response", response.status);
|
|
392
|
+
const leftover = result.deskRemoved === false ? " Its desk could not be removed from the gateway; the gateway logged why." : "";
|
|
393
|
+
return done(`Deleted account ${command.accountId}.${leftover}`, response.status);
|
|
394
|
+
}
|
|
395
|
+
if (!isHttps(result?.inviteUrl) || !nonEmpty(result?.expiresAt)
|
|
396
|
+
|| typeof result?.accountId !== "string" || !ACCOUNT_ID.test(result.accountId)
|
|
397
|
+
|| (command.accountId !== undefined && result.accountId !== command.accountId)) {
|
|
398
|
+
releaseOut();
|
|
399
|
+
return fail("Hosted Desk sent an unexpected invite response", response.status);
|
|
400
|
+
}
|
|
401
|
+
const link = new URL(result.inviteUrl).href;
|
|
402
|
+
const who = command.displayName ? `${command.displayName} (account ${result.accountId})` : `account ${result.accountId}`;
|
|
403
|
+
const lead = `Invite for ${who}, single use, expires ${shown(result.expiresAt, 40)}.`;
|
|
404
|
+
if (!outPath)
|
|
405
|
+
return done(`${lead}\n${link}`, response.status);
|
|
406
|
+
try {
|
|
407
|
+
const fd = (0, node_fs_1.openSync)(outPath, node_fs_1.constants.O_WRONLY | node_fs_1.constants.O_TRUNC | node_fs_1.constants.O_NOFOLLOW);
|
|
408
|
+
try {
|
|
409
|
+
(0, node_fs_1.writeSync)(fd, `${link}\n`);
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
(0, node_fs_1.closeSync)(fd);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
releaseOut();
|
|
417
|
+
return fail(`The invite for account ${result.accountId} was issued, but ${outPath} could not be written (${error.message}). Issue a new link with: ouro desk invite --account ${result.accountId}`);
|
|
418
|
+
}
|
|
419
|
+
return done(`${lead} The link is in ${outPath} (mode 0600).`, response.status);
|
|
420
|
+
}
|
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.OURO_CLI_TRUST_MANIFEST = void 0;
|
|
37
|
+
exports.canonicalTarget = canonicalTarget;
|
|
38
|
+
exports.insideAgentFolder = insideAgentFolder;
|
|
37
39
|
exports.guardInvocation = guardInvocation;
|
|
38
40
|
const fs = __importStar(require("node:fs"));
|
|
39
41
|
const os = __importStar(require("node:os"));
|
|
@@ -42,6 +44,7 @@ const friends_1 = require("@ouro.bot/friends");
|
|
|
42
44
|
const shell_quote_1 = require("shell-quote");
|
|
43
45
|
const runtime_1 = require("../nerves/runtime");
|
|
44
46
|
const store_1 = require("../commerce/store");
|
|
47
|
+
const identity_1 = require("../heart/identity");
|
|
45
48
|
const deny = (reason) => ({ allowed: false, reason });
|
|
46
49
|
const allow = { allowed: true };
|
|
47
50
|
// --- reason templates ---
|
|
@@ -70,10 +73,68 @@ const PROTECTED_PATH_SEGMENTS = [
|
|
|
70
73
|
"state/reports/",
|
|
71
74
|
];
|
|
72
75
|
const PROTECTED_FILENAMES = ["agent.json", "tool-profiles.json"];
|
|
73
|
-
/**
|
|
76
|
+
/** How many symlinks a dangling-link walk follows before refusing (the Linux kernel's own limit is 40). */
|
|
77
|
+
const MAX_SYMLINK_HOPS = 40;
|
|
78
|
+
/** A lookup that failed only because part of the path does not exist yet; any other failure leaves the target unknown. */
|
|
79
|
+
function rethrowUnlessMissing(error) {
|
|
80
|
+
const code = error.code;
|
|
81
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The real location of `lexical`: `realpathSync.native` of the deepest part that exists, the final file included, with the
|
|
86
|
+
* missing remainder appended. A dangling symlink is followed, from its real parent, to where a write through it would land.
|
|
87
|
+
* Throws when the filesystem cannot say where the path lands (a denied lookup, a symlink loop, too many links), so the
|
|
88
|
+
* guard can refuse rather than fall back to the lexical path.
|
|
89
|
+
*/
|
|
90
|
+
function canonicalize(lexical) {
|
|
91
|
+
let current = lexical;
|
|
92
|
+
const missing = [];
|
|
93
|
+
let hops = 0;
|
|
94
|
+
for (;;) {
|
|
95
|
+
try {
|
|
96
|
+
return path.join(fs.realpathSync.native(current), ...missing);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
rethrowUnlessMissing(error);
|
|
100
|
+
}
|
|
101
|
+
let stat;
|
|
102
|
+
try {
|
|
103
|
+
stat = fs.lstatSync(current);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
rethrowUnlessMissing(error);
|
|
107
|
+
}
|
|
108
|
+
if (stat?.isSymbolicLink()) {
|
|
109
|
+
if (hops >= MAX_SYMLINK_HOPS)
|
|
110
|
+
throw new Error(`too many symbolic links resolving ${lexical}`);
|
|
111
|
+
hops += 1;
|
|
112
|
+
current = path.resolve(fs.realpathSync.native(path.dirname(current)), fs.readlinkSync(current));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const parent = path.dirname(current);
|
|
116
|
+
if (parent === current)
|
|
117
|
+
return path.join(current, ...missing);
|
|
118
|
+
missing.unshift(path.basename(current));
|
|
119
|
+
current = parent;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Every place a file tool could write `p`: resolved against the agent root and against the repo root (which is what
|
|
124
|
+
* `write_file` and `edit_file` use), each in its lexical and canonical form. An absolute `p` is resolved only as itself.
|
|
125
|
+
*/
|
|
126
|
+
function canonicalTarget(p, agentRoot) {
|
|
127
|
+
const lexicals = path.isAbsolute(p) ? [p] : [path.resolve(agentRoot, p), path.resolve((0, identity_1.getRepoRoot)(), p)];
|
|
128
|
+
return lexicals.flatMap((lexical) => [lexical, canonicalize(lexical)]);
|
|
129
|
+
}
|
|
130
|
+
/** True when any form of `p` is the agent's `folder` (in either its lexical or its canonical form) or under it. */
|
|
131
|
+
function insideAgentFolder(p, agentRoot, folder) {
|
|
132
|
+
const lexicalFolder = path.resolve(agentRoot, folder);
|
|
133
|
+
const folders = [lexicalFolder, canonicalize(lexicalFolder)];
|
|
134
|
+
return canonicalTarget(p, agentRoot).some((target) => folders.some((dir) => target === dir || target.startsWith(`${dir}${path.sep}`)));
|
|
135
|
+
}
|
|
136
|
+
/** The agent's psyche folder ships only from the repository (a pull request and the replay gate), so no tool writes into it. The shell check uses this lexical form; the file tools use `insideAgentFolder`. */
|
|
74
137
|
function isPsychePath(filePath, agentRoot) {
|
|
75
|
-
if (!agentRoot || !filePath)
|
|
76
|
-
return false;
|
|
77
138
|
const psyche = path.resolve(agentRoot, "psyche");
|
|
78
139
|
const target = path.resolve(agentRoot, filePath);
|
|
79
140
|
return target === psyche || target.startsWith(`${psyche}${path.sep}`);
|
|
@@ -85,8 +146,6 @@ function isPsychePath(filePath, agentRoot) {
|
|
|
85
146
|
* only: a shell can still write anywhere its uid can, which is why delegated-command authority lives outside the bundle.
|
|
86
147
|
*/
|
|
87
148
|
function isFriendsStorePath(filePath, agentRoot) {
|
|
88
|
-
if (!agentRoot || !filePath)
|
|
89
|
-
return false;
|
|
90
149
|
const store = path.resolve(agentRoot, "friends");
|
|
91
150
|
const target = path.resolve(agentRoot, filePath);
|
|
92
151
|
return target === store || target.startsWith(`${store}${path.sep}`);
|
|
@@ -272,9 +331,21 @@ function checkDestructiveShellPatterns(toolName, args) {
|
|
|
272
331
|
function checkProtectedPaths(toolName, args, context) {
|
|
273
332
|
if (toolName === "write_file" || toolName === "edit_file") {
|
|
274
333
|
const filePath = args.path || "";
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
334
|
+
// The file tools resolve a relative path against the repo root and follow symlinks, so the guard checks every form.
|
|
335
|
+
// A path whose landing place the filesystem cannot report is refused rather than checked lexically.
|
|
336
|
+
const agentRoot = filePath ? context.agentRoot : undefined;
|
|
337
|
+
if (agentRoot) {
|
|
338
|
+
try {
|
|
339
|
+
if (insideAgentFolder(filePath, agentRoot, "psyche"))
|
|
340
|
+
return deny(REASONS.psychePath);
|
|
341
|
+
if (insideAgentFolder(filePath, agentRoot, "friends"))
|
|
342
|
+
return deny(REASONS.protectedPath);
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return deny(REASONS.protectedPath);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (isProtectedPath(filePath))
|
|
278
349
|
return deny(REASONS.protectedPath);
|
|
279
350
|
}
|
|
280
351
|
if (toolName === "shell") {
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.982",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.982",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|