@ouro.bot/cli 0.1.0-alpha.918 → 0.1.0-alpha.919
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 +8 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/cli-exec.js +83 -14
- package/dist/heart/daemon/cli-help.js +3 -3
- package/dist/heart/daemon/cli-parse.js +67 -3
- package/dist/heart/identity.js +17 -1
- package/dist/mind/friend-link-codes.js +209 -0
- package/dist/mind/friend-merge.js +330 -0
- package/dist/repertoire/guardrails.js +8 -2
- package/dist/repertoire/tools-base.js +2 -0
- package/dist/repertoire/tools-friend-link.js +57 -0
- package/dist/repertoire/tools-shepherd.js +89 -7
- package/dist/senses/pipeline.js +30 -8
- package/dist/senses/shepherd/cmux.js +5 -3
- package/dist/senses/shepherd/herdr.js +1 -1
- package/dist/senses/shepherd/owner-notice.js +245 -0
- package/dist/senses/shepherd/redact.js +29 -0
- package/dist/senses/shepherd/sense.js +27 -15
- package/dist/senses/teams.js +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
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.919",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Shepherd now sends the agent's owner a short Teams message for every coding-agent return it judges, saying what it did and why; set senses.shepherd.notify to needs-me or off to hear less.",
|
|
8
|
+
"Link a new account, such as Teams, to yourself by sending your agent a one-time code from it, and merge duplicate friend records with ouro friend dedupe.",
|
|
9
|
+
"Your agent can now type an instruction or press a key in one of your coding agents' terminals when you ask it to, so you can steer them from your phone."
|
|
10
|
+
]
|
|
11
|
+
},
|
|
4
12
|
{
|
|
5
13
|
"version": "0.1.0-alpha.918",
|
|
6
14
|
"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.919</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -62,6 +62,8 @@ const semver = __importStar(require("semver"));
|
|
|
62
62
|
const identity_1 = require("../identity");
|
|
63
63
|
const runtime_1 = require("../../nerves/runtime");
|
|
64
64
|
const friends_1 = require("@ouro.bot/friends");
|
|
65
|
+
const friend_merge_1 = require("../../mind/friend-merge");
|
|
66
|
+
const friend_link_codes_1 = require("../../mind/friend-link-codes");
|
|
65
67
|
const runtime_metadata_1 = require("./runtime-metadata");
|
|
66
68
|
const runtime_mode_1 = require("./runtime-mode");
|
|
67
69
|
const platform_1 = require("../platform");
|
|
@@ -506,6 +508,9 @@ function agentResolutionFailureMode(command) {
|
|
|
506
508
|
case "friend.show":
|
|
507
509
|
case "friend.create":
|
|
508
510
|
case "friend.update":
|
|
511
|
+
case "friend.linkCode":
|
|
512
|
+
case "friend.merge":
|
|
513
|
+
case "friend.dedupe":
|
|
509
514
|
case "habit.list":
|
|
510
515
|
case "habit.create":
|
|
511
516
|
case "habit.runs":
|
|
@@ -6145,7 +6150,30 @@ async function performSystemSetup(deps) {
|
|
|
6145
6150
|
await registerOuroBundleTypeNonBlocking(deps);
|
|
6146
6151
|
}
|
|
6147
6152
|
// ── Friend command execution ──
|
|
6148
|
-
async function executeFriendCommand(command, store) {
|
|
6153
|
+
async function executeFriendCommand(command, store, paths) {
|
|
6154
|
+
if (command.kind === "friend.linkCode")
|
|
6155
|
+
return executeFriendLinkCode(command, store, paths.agentRoot);
|
|
6156
|
+
if (command.kind === "friend.merge") {
|
|
6157
|
+
const result = await (0, friend_merge_1.mergeFriends)({
|
|
6158
|
+
store,
|
|
6159
|
+
friendsDir: paths.friendsDir,
|
|
6160
|
+
targetId: command.targetId,
|
|
6161
|
+
sourceIds: command.sourceIds,
|
|
6162
|
+
actor: "cli:friend-merge",
|
|
6163
|
+
reason: "owner merged records from the CLI",
|
|
6164
|
+
dryRun: !command.apply,
|
|
6165
|
+
});
|
|
6166
|
+
if (!result.ok)
|
|
6167
|
+
return `error: ${result.message}`;
|
|
6168
|
+
return renderFriendMerge(result.target, result.retired.map((retired) => retired.friendId), result.preservedNoteKeys, result.addedExternalIds, result.dryRun);
|
|
6169
|
+
}
|
|
6170
|
+
if (command.kind === "friend.dedupe") {
|
|
6171
|
+
const result = await (0, friend_merge_1.dedupeFriends)({ store, friendsDir: paths.friendsDir, actor: "cli:friend-dedupe", dryRun: !command.apply });
|
|
6172
|
+
if (result.groups.length === 0)
|
|
6173
|
+
return "no duplicate friend records found";
|
|
6174
|
+
const blocks = result.groups.map((group) => renderFriendMerge(group.canonical, group.duplicateIds, group.preservedNoteKeys, group.addedExternalIds, result.dryRun));
|
|
6175
|
+
return [...blocks, ...(result.dryRun ? ["", "dry run: nothing changed. Re-run with --apply to merge."] : [])].join("\n");
|
|
6176
|
+
}
|
|
6149
6177
|
if (command.kind === "friend.list") {
|
|
6150
6178
|
const listAll = store.listAll;
|
|
6151
6179
|
if (!listAll)
|
|
@@ -6229,6 +6257,50 @@ async function executeFriendCommand(command, store) {
|
|
|
6229
6257
|
return `identity not linked: ${command.provider}:${command.externalId}`;
|
|
6230
6258
|
return `unlinked ${command.provider}:${command.externalId} from ${command.friendId}`;
|
|
6231
6259
|
}
|
|
6260
|
+
function renderFriendMerge(target, retiredIds, preservedNoteKeys, addedExternalIds, dryRun) {
|
|
6261
|
+
const verb = dryRun ? "would merge" : "merged";
|
|
6262
|
+
const lines = [`${verb} ${retiredIds.length} record(s) into ${target.id} (${target.name}, ${target.trustLevel})`];
|
|
6263
|
+
for (const id of retiredIds)
|
|
6264
|
+
lines.push(` ${dryRun ? "would retire" : "retired"} ${id}`);
|
|
6265
|
+
for (const externalId of addedExternalIds)
|
|
6266
|
+
lines.push(` ${dryRun ? "would link" : "linked"} ${externalId.provider}:${externalId.externalId}${externalId.tenantId ? ` (tenant ${externalId.tenantId})` : ""}`);
|
|
6267
|
+
if (preservedNoteKeys.length > 0)
|
|
6268
|
+
lines.push(` kept conflicting notes as: ${preservedNoteKeys.join(", ")}`);
|
|
6269
|
+
return lines.join("\n");
|
|
6270
|
+
}
|
|
6271
|
+
async function executeFriendLinkCode(command, store, agentRoot) {
|
|
6272
|
+
let friend;
|
|
6273
|
+
if (command.friendId) {
|
|
6274
|
+
friend = await store.get(command.friendId);
|
|
6275
|
+
if (!friend || friend.id !== command.friendId)
|
|
6276
|
+
return `friend not found: ${command.friendId}`;
|
|
6277
|
+
}
|
|
6278
|
+
else {
|
|
6279
|
+
// The person at this terminal owns the agent: their family record carries a local identity for this OS user.
|
|
6280
|
+
const owner = (0, friends_1.machineOwnerUsername)();
|
|
6281
|
+
const all = store.listAll ? await store.listAll() : [];
|
|
6282
|
+
const candidates = all.filter((record) => record.trustLevel === "family"
|
|
6283
|
+
&& record.externalIds.some((externalId) => (0, friends_1.isLocalMachineOwnerIdentity)(externalId.provider, externalId.externalId, owner)));
|
|
6284
|
+
if (candidates.length === 0)
|
|
6285
|
+
return "error: no family record names this machine's owner. Pass --friend <friend-id>.";
|
|
6286
|
+
if (candidates.length > 1)
|
|
6287
|
+
return `error: ${candidates.length} family records name this machine's owner. Run \`ouro friend dedupe --apply\` first, or pass --friend <friend-id>.`;
|
|
6288
|
+
friend = candidates[0];
|
|
6289
|
+
}
|
|
6290
|
+
const issued = (0, friend_link_codes_1.issueLinkCode)({
|
|
6291
|
+
agentRoot,
|
|
6292
|
+
friend,
|
|
6293
|
+
...(command.channel ? { channel: command.channel } : {}),
|
|
6294
|
+
...(command.ttlMinutes ? { ttlMs: command.ttlMinutes * 60 * 1000 } : {}),
|
|
6295
|
+
issuedBy: "cli",
|
|
6296
|
+
});
|
|
6297
|
+
const minutes = Math.round((Date.parse(issued.expiresAt) - Date.now()) / 60000);
|
|
6298
|
+
const where = issued.channel ? `from your ${issued.channel} account` : "from the account you want to link";
|
|
6299
|
+
return [
|
|
6300
|
+
`link code for ${friend.name} (${friend.trustLevel}): ${issued.code}`,
|
|
6301
|
+
`Send exactly this code to ${path.basename(agentRoot, ".ouro")} ${where}, in a 1:1 chat, within ${minutes} minutes. It works once.`,
|
|
6302
|
+
].join("\n");
|
|
6303
|
+
}
|
|
6232
6304
|
async function executeA2AClientCommand(command, deps) {
|
|
6233
6305
|
const { loadOrMintA2AIdentityFile } = await Promise.resolve().then(() => __importStar(require("../../a2a/identity")));
|
|
6234
6306
|
/* v8 ignore next -- production CLI keeps the client key under the home dir; tests pass an explicit file @preserve */
|
|
@@ -8107,19 +8179,16 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
|
|
|
8107
8179
|
}
|
|
8108
8180
|
// ── friend subcommands (local, no daemon socket needed) ──
|
|
8109
8181
|
if (command.kind === "friend.list" || command.kind === "friend.show" || command.kind === "friend.create" ||
|
|
8110
|
-
command.kind === "friend.update" || command.kind === "friend.link" || command.kind === "friend.unlink"
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
|
|
8120
|
-
}
|
|
8121
|
-
/* v8 ignore stop */
|
|
8122
|
-
const message = await executeFriendCommand(command, store);
|
|
8182
|
+
command.kind === "friend.update" || command.kind === "friend.link" || command.kind === "friend.unlink" ||
|
|
8183
|
+
command.kind === "friend.linkCode" || command.kind === "friend.merge" || command.kind === "friend.dedupe") {
|
|
8184
|
+
// Derive agent-scoped friends dir from --agent flag or link/unlink's agent field
|
|
8185
|
+
const agentName = ("agent" in command && command.agent) ? command.agent : undefined;
|
|
8186
|
+
const bundlesRoot = deps.bundlesRoot ?? /* v8 ignore next -- production default @preserve */ (0, identity_1.getAgentBundlesRoot)();
|
|
8187
|
+
const agentRoot = agentName ? path.join(bundlesRoot, `${agentName}.ouro`) : bundlesRoot;
|
|
8188
|
+
const friendsDir = path.join(agentRoot, "friends");
|
|
8189
|
+
/* v8 ignore next -- production default: requires full identity setup @preserve */
|
|
8190
|
+
const store = deps.friendStore ?? new friends_1.FileFriendStore(friendsDir);
|
|
8191
|
+
const message = await executeFriendCommand(command, store, { agentRoot, friendsDir });
|
|
8123
8192
|
deps.writeStdout(message);
|
|
8124
8193
|
return message;
|
|
8125
8194
|
}
|
|
@@ -240,9 +240,9 @@ exports.COMMAND_REGISTRY = {
|
|
|
240
240
|
friend: {
|
|
241
241
|
category: "Friends",
|
|
242
242
|
description: "Manage agent friends and identity links",
|
|
243
|
-
usage: "ouro friend <subcommand> [--agent <name>]",
|
|
244
|
-
example: "ouro friend
|
|
245
|
-
subcommands: ["list", "show", "create", "update", "link", "unlink"],
|
|
243
|
+
usage: "ouro friend <subcommand> [--agent <name>] (link --channel <channel> issues a one-time code; merge/dedupe preview unless --apply)",
|
|
244
|
+
example: "ouro friend link --agent ouroboros --channel teams",
|
|
245
|
+
subcommands: ["list", "show", "create", "update", "link", "unlink", "merge", "dedupe"],
|
|
246
246
|
},
|
|
247
247
|
link: {
|
|
248
248
|
category: "Friends",
|
|
@@ -1631,12 +1631,76 @@ function parseFriendCommand(args) {
|
|
|
1631
1631
|
...(agent ? { agent } : {}),
|
|
1632
1632
|
};
|
|
1633
1633
|
}
|
|
1634
|
-
if (sub === "link")
|
|
1635
|
-
|
|
1634
|
+
if (sub === "link") {
|
|
1635
|
+
// Without an explicit identity, `link` issues a code the person sends from the new account.
|
|
1636
|
+
if (!rest.includes("--provider") && !rest.includes("--external-id") && (agent || rest[0]?.startsWith("--"))) {
|
|
1637
|
+
return parseFriendLinkCodeCommand(rest, agent);
|
|
1638
|
+
}
|
|
1639
|
+
return parseLinkCommand(agent ? [agent, ...rest] : rest, "friend.link");
|
|
1640
|
+
}
|
|
1636
1641
|
if (sub === "unlink")
|
|
1637
|
-
return parseLinkCommand(rest, "friend.unlink");
|
|
1642
|
+
return parseLinkCommand(agent ? [agent, ...rest] : rest, "friend.unlink");
|
|
1643
|
+
if (sub === "merge") {
|
|
1644
|
+
const usage = "Usage: ouro friend merge --into <friend-id> <friend-id>... [--agent <name>] [--apply]";
|
|
1645
|
+
let targetId;
|
|
1646
|
+
let apply = false;
|
|
1647
|
+
const sourceIds = [];
|
|
1648
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1649
|
+
const token = rest[i];
|
|
1650
|
+
if (token === "--into") {
|
|
1651
|
+
targetId = rest[i + 1];
|
|
1652
|
+
i += 1;
|
|
1653
|
+
}
|
|
1654
|
+
else if (token === "--apply") {
|
|
1655
|
+
apply = true;
|
|
1656
|
+
}
|
|
1657
|
+
else if (token.startsWith("--")) {
|
|
1658
|
+
throw new Error(usage);
|
|
1659
|
+
}
|
|
1660
|
+
else {
|
|
1661
|
+
sourceIds.push(token);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
if (!targetId || sourceIds.length === 0)
|
|
1665
|
+
throw new Error(usage);
|
|
1666
|
+
return { kind: "friend.merge", targetId, sourceIds, apply, ...(agent ? { agent } : {}) };
|
|
1667
|
+
}
|
|
1668
|
+
if (sub === "dedupe") {
|
|
1669
|
+
const usage = "Usage: ouro friend dedupe [--agent <name>] [--apply]";
|
|
1670
|
+
if (rest.some((token) => token !== "--apply"))
|
|
1671
|
+
throw new Error(usage);
|
|
1672
|
+
return { kind: "friend.dedupe", apply: rest.includes("--apply"), ...(agent ? { agent } : {}) };
|
|
1673
|
+
}
|
|
1638
1674
|
throw new Error(`Usage\n${usage()}`);
|
|
1639
1675
|
}
|
|
1676
|
+
const FRIEND_LINK_CODE_USAGE = "Usage: ouro friend link --agent <name> [--channel <teams|bluebubbles|telegram|mail|voice|mcp|cli>] [--friend <friend-id>] [--ttl-minutes <n>]";
|
|
1677
|
+
const LINK_CODE_CHANNELS = ["cli", "teams", "bluebubbles", "mail", "voice", "a2a", "mcp", "telegram"];
|
|
1678
|
+
function parseFriendLinkCodeCommand(rest, agent) {
|
|
1679
|
+
let friendId;
|
|
1680
|
+
let channel;
|
|
1681
|
+
let ttlMinutes;
|
|
1682
|
+
for (let i = 0; i < rest.length; i += 2) {
|
|
1683
|
+
const token = rest[i];
|
|
1684
|
+
const value = rest[i + 1];
|
|
1685
|
+
if (!value)
|
|
1686
|
+
throw new Error(FRIEND_LINK_CODE_USAGE);
|
|
1687
|
+
if (token === "--friend")
|
|
1688
|
+
friendId = value;
|
|
1689
|
+
else if (token === "--channel" && LINK_CODE_CHANNELS.includes(value))
|
|
1690
|
+
channel = value;
|
|
1691
|
+
else if (token === "--ttl-minutes" && /^[1-9][0-9]*$/.test(value))
|
|
1692
|
+
ttlMinutes = Number(value);
|
|
1693
|
+
else
|
|
1694
|
+
throw new Error(FRIEND_LINK_CODE_USAGE);
|
|
1695
|
+
}
|
|
1696
|
+
return {
|
|
1697
|
+
kind: "friend.linkCode",
|
|
1698
|
+
...(agent ? { agent } : {}),
|
|
1699
|
+
...(friendId ? { friendId } : {}),
|
|
1700
|
+
...(channel ? { channel } : {}),
|
|
1701
|
+
...(ttlMinutes ? { ttlMinutes } : {}),
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1640
1704
|
function parseA2ACommand(args) {
|
|
1641
1705
|
const { agent, rest: cleaned } = extractAgentFlag(args);
|
|
1642
1706
|
const [sub, ...rest] = cleaned;
|
package/dist/heart/identity.js
CHANGED
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.HARNESS_CANONICAL_REPO_URL = exports.DEFAULT_AGENT_SENSES = exports.LEGACY_VAULT_SERVER_URL_ALIASES = exports.DEFAULT_VAULT_SERVER_URL = exports.DEFAULT_AGENT_PHRASES = exports.DEFAULT_AGENT_CONTEXT = exports.PROVIDER_CREDENTIALS = exports.normalizeHabitPaidTurnsPerDay = void 0;
|
|
36
|
+
exports.HARNESS_CANONICAL_REPO_URL = exports.DEFAULT_AGENT_SENSES = exports.LEGACY_VAULT_SERVER_URL_ALIASES = exports.DEFAULT_VAULT_SERVER_URL = exports.DEFAULT_AGENT_PHRASES = exports.DEFAULT_AGENT_CONTEXT = exports.SHEPHERD_NOTICE_POLICIES = exports.PROVIDER_CREDENTIALS = exports.normalizeHabitPaidTurnsPerDay = void 0;
|
|
37
37
|
exports.normalizeVaultServerUrl = normalizeVaultServerUrl;
|
|
38
38
|
exports.getVaultServerUrlCandidates = getVaultServerUrlCandidates;
|
|
39
39
|
exports.defaultStableVaultEmail = defaultStableVaultEmail;
|
|
@@ -75,6 +75,7 @@ exports.PROVIDER_CREDENTIALS = {
|
|
|
75
75
|
"openai-compatible": { required: ["apiKey", "baseUrl"], envVars: {}, promptLabels: { apiKey: "Z.ai API key", baseUrl: "Z.ai canonical base URL" } },
|
|
76
76
|
"openai-compatible-gemini": { required: ["apiKey", "baseUrl"], envVars: {}, promptLabels: { apiKey: "Gemini API key", baseUrl: "Gemini canonical base URL" } },
|
|
77
77
|
};
|
|
78
|
+
exports.SHEPHERD_NOTICE_POLICIES = ["every", "needs-me", "off"];
|
|
78
79
|
exports.DEFAULT_AGENT_CONTEXT = {
|
|
79
80
|
maxTokens: 80000,
|
|
80
81
|
contextMargin: 20,
|
|
@@ -215,6 +216,21 @@ function normalizeSenses(value, configFile) {
|
|
|
215
216
|
defaults[senseName] = { enabled, hostUsername: hostUsername.trim() };
|
|
216
217
|
continue;
|
|
217
218
|
}
|
|
219
|
+
const notify = rawSense.notify;
|
|
220
|
+
if (senseName === "shepherd" && notify !== undefined) {
|
|
221
|
+
if (!exports.SHEPHERD_NOTICE_POLICIES.includes(notify)) {
|
|
222
|
+
(0, runtime_1.emitNervesEvent)({
|
|
223
|
+
level: "error",
|
|
224
|
+
event: "config_identity.error",
|
|
225
|
+
component: "config/identity",
|
|
226
|
+
message: "agent config has invalid Shepherd notice policy",
|
|
227
|
+
meta: { path: configFile, sense: senseName },
|
|
228
|
+
});
|
|
229
|
+
throw new Error(`agent.json at ${configFile}: senses.shepherd.notify must be one of ${exports.SHEPHERD_NOTICE_POLICIES.join(", ")}.`);
|
|
230
|
+
}
|
|
231
|
+
defaults[senseName] = { enabled, notify: notify };
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
218
234
|
defaults[senseName] = { enabled };
|
|
219
235
|
}
|
|
220
236
|
return defaults;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.DEFAULT_LINK_CODE_TTL_MS = void 0;
|
|
37
|
+
exports.parseLinkCode = parseLinkCode;
|
|
38
|
+
exports.formatLinkCode = formatLinkCode;
|
|
39
|
+
exports.issueLinkCode = issueLinkCode;
|
|
40
|
+
exports.redeemLinkCode = redeemLinkCode;
|
|
41
|
+
// Identity link codes: how a person proves that a new channel identity is theirs.
|
|
42
|
+
//
|
|
43
|
+
// A friend on a surface the agent already trusts (the owner's CLI, or a family chat)
|
|
44
|
+
// asks for a short code. When that exact code arrives as a 1:1 message from an
|
|
45
|
+
// identity the agent does not yet trust, that identity's record merges into the
|
|
46
|
+
// friend who asked, and the new identity is treated as them from then on.
|
|
47
|
+
//
|
|
48
|
+
// Codes are single use and expire. Only a SHA-256 digest of each code is stored, under
|
|
49
|
+
// the agent's `state/` directory, which bundle sync does not carry. A code is consumed
|
|
50
|
+
// by its first presentation, whatever the outcome, by an atomic rename, so a replay or a
|
|
51
|
+
// race between two senders finds nothing to redeem.
|
|
52
|
+
const fs = __importStar(require("node:fs"));
|
|
53
|
+
const path = __importStar(require("node:path"));
|
|
54
|
+
const node_crypto_1 = require("node:crypto");
|
|
55
|
+
const runtime_1 = require("../nerves/runtime");
|
|
56
|
+
const friend_merge_1 = require("./friend-merge");
|
|
57
|
+
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
58
|
+
const CODE_BODY_LENGTH = 8;
|
|
59
|
+
exports.DEFAULT_LINK_CODE_TTL_MS = 10 * 60 * 1000;
|
|
60
|
+
const MAX_LINK_CODE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
61
|
+
const CODE_SHAPE = new RegExp(`^OURO-?([${CODE_ALPHABET}]{4})-?([${CODE_ALPHABET}]{4})$`);
|
|
62
|
+
function codesRoot(agentRoot) {
|
|
63
|
+
return path.join(agentRoot, "state", "friend-link-codes");
|
|
64
|
+
}
|
|
65
|
+
function digest(codeBody) {
|
|
66
|
+
return (0, node_crypto_1.createHash)("sha256").update(codeBody).digest("hex");
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The code body when `text` is exactly one link code (case, spacing, dashes and simple
|
|
70
|
+
* markup ignored), otherwise null. Anything else in the message means it is not a code.
|
|
71
|
+
*/
|
|
72
|
+
function parseLinkCode(text) {
|
|
73
|
+
const plain = text
|
|
74
|
+
.replace(/<at>[^<]*<\/at>/gi, " ")
|
|
75
|
+
.replace(/<[^>]*>/g, " ")
|
|
76
|
+
.replace(/ /g, " ")
|
|
77
|
+
.replace(/\s+/g, "")
|
|
78
|
+
.toUpperCase();
|
|
79
|
+
const match = CODE_SHAPE.exec(plain);
|
|
80
|
+
return match ? `${match[1]}${match[2]}` : null;
|
|
81
|
+
}
|
|
82
|
+
function formatLinkCode(codeBody) {
|
|
83
|
+
return `OURO-${codeBody.slice(0, 4)}-${codeBody.slice(4)}`;
|
|
84
|
+
}
|
|
85
|
+
function issueLinkCode(input) {
|
|
86
|
+
const now = (input.now ?? (() => new Date()))();
|
|
87
|
+
const ttlMs = Math.min(Math.max(input.ttlMs ?? exports.DEFAULT_LINK_CODE_TTL_MS, 60 * 1000), MAX_LINK_CODE_TTL_MS);
|
|
88
|
+
let codeBody = "";
|
|
89
|
+
for (let index = 0; index < CODE_BODY_LENGTH; index += 1)
|
|
90
|
+
codeBody += CODE_ALPHABET[(0, node_crypto_1.randomInt)(CODE_ALPHABET.length)];
|
|
91
|
+
const record = {
|
|
92
|
+
schemaVersion: 1,
|
|
93
|
+
friendId: input.friend.id,
|
|
94
|
+
friendName: input.friend.name,
|
|
95
|
+
...(input.channel ? { channel: input.channel } : {}),
|
|
96
|
+
issuedAt: now.toISOString(),
|
|
97
|
+
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
98
|
+
issuedBy: input.issuedBy,
|
|
99
|
+
};
|
|
100
|
+
const pendingDir = path.join(codesRoot(input.agentRoot), "pending");
|
|
101
|
+
fs.mkdirSync(pendingDir, { recursive: true, mode: 0o700 });
|
|
102
|
+
fs.writeFileSync(path.join(pendingDir, `${digest(codeBody)}.json`), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
103
|
+
(0, runtime_1.emitNervesEvent)({
|
|
104
|
+
component: "friends",
|
|
105
|
+
event: "friends.link_code_issued",
|
|
106
|
+
message: "issued an identity link code",
|
|
107
|
+
meta: { friendId: input.friend.id, channel: input.channel ?? "any", expiresAt: record.expiresAt, issuedBy: input.issuedBy },
|
|
108
|
+
});
|
|
109
|
+
return { code: formatLinkCode(codeBody), friendId: record.friendId, friendName: record.friendName, ...(record.channel ? { channel: record.channel } : {}), expiresAt: record.expiresAt };
|
|
110
|
+
}
|
|
111
|
+
const REJECTION_REPLIES = {
|
|
112
|
+
unknown_or_used: "That link code isn't valid. Ask for a new one.",
|
|
113
|
+
expired: "That link code has expired. Ask for a new one.",
|
|
114
|
+
group_chat: "Link codes only work in a 1:1 chat with me. Ask for a new code and send it to me directly.",
|
|
115
|
+
wrong_channel: "That link code was issued for a different channel. Ask for a new one.",
|
|
116
|
+
already_linked: "This account is already linked to you.",
|
|
117
|
+
claimant_trusted: "This account is already known to me as someone I trust, so a code can't relink it. The owner can merge the records instead.",
|
|
118
|
+
friend_missing: "The person this code was issued for no longer exists. Ask for a new code.",
|
|
119
|
+
};
|
|
120
|
+
function reject(reason, meta) {
|
|
121
|
+
(0, runtime_1.emitNervesEvent)({
|
|
122
|
+
level: "warn",
|
|
123
|
+
component: "friends",
|
|
124
|
+
event: "friends.link_code_rejected",
|
|
125
|
+
message: "rejected an identity link code",
|
|
126
|
+
meta: { reason, ...meta },
|
|
127
|
+
});
|
|
128
|
+
return { status: "rejected", reason, reply: REJECTION_REPLIES[reason] };
|
|
129
|
+
}
|
|
130
|
+
/** Atomically take the code out of `pending/`. Null when no pending code has this body. */
|
|
131
|
+
function consumeCode(agentRoot, codeBody, nowIso) {
|
|
132
|
+
const root = codesRoot(agentRoot);
|
|
133
|
+
const name = `${digest(codeBody)}.json`;
|
|
134
|
+
const usedDir = path.join(root, "used");
|
|
135
|
+
fs.mkdirSync(usedDir, { recursive: true, mode: 0o700 });
|
|
136
|
+
const usedPath = path.join(usedDir, name);
|
|
137
|
+
try {
|
|
138
|
+
fs.renameSync(path.join(root, "pending", name), usedPath);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
/* v8 ignore next -- only ENOENT is expected; other filesystem failures propagate @preserve */
|
|
142
|
+
if (error.code !== "ENOENT")
|
|
143
|
+
throw error;
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
const record = JSON.parse(fs.readFileSync(usedPath, "utf-8"));
|
|
147
|
+
fs.writeFileSync(usedPath, `${JSON.stringify({ ...record, consumedAt: nowIso }, null, 2)}\n`, { mode: 0o600 });
|
|
148
|
+
return { record, usedPath };
|
|
149
|
+
}
|
|
150
|
+
function recordOutcome(usedPath, outcome) {
|
|
151
|
+
const current = JSON.parse(fs.readFileSync(usedPath, "utf-8"));
|
|
152
|
+
fs.writeFileSync(usedPath, `${JSON.stringify({ ...current, ...outcome }, null, 2)}\n`, { mode: 0o600 });
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Redeem a link code sent by `claimant`. Returns null when `text` is not a link code, so
|
|
156
|
+
* callers can run this on every inbound message and fall through to a normal turn.
|
|
157
|
+
*/
|
|
158
|
+
async function redeemLinkCode(input) {
|
|
159
|
+
const codeBody = parseLinkCode(input.text);
|
|
160
|
+
if (!codeBody)
|
|
161
|
+
return null;
|
|
162
|
+
const now = (input.now ?? (() => new Date()))();
|
|
163
|
+
const nowIso = now.toISOString();
|
|
164
|
+
const baseMeta = { claimantId: input.claimant.id, channel: input.channel };
|
|
165
|
+
const consumed = consumeCode(input.agentRoot, codeBody, nowIso);
|
|
166
|
+
if (!consumed)
|
|
167
|
+
return reject("unknown_or_used", baseMeta);
|
|
168
|
+
const { record, usedPath } = consumed;
|
|
169
|
+
const meta = { ...baseMeta, friendId: record.friendId };
|
|
170
|
+
const refuse = (reason) => {
|
|
171
|
+
recordOutcome(usedPath, { outcome: "rejected", reason, claimantId: input.claimant.id, channel: input.channel });
|
|
172
|
+
return reject(reason, meta);
|
|
173
|
+
};
|
|
174
|
+
if (Date.parse(record.expiresAt) <= now.getTime())
|
|
175
|
+
return refuse("expired");
|
|
176
|
+
if (input.isGroupChat)
|
|
177
|
+
return refuse("group_chat");
|
|
178
|
+
if (record.channel && record.channel !== input.channel)
|
|
179
|
+
return refuse("wrong_channel");
|
|
180
|
+
if (input.claimant.id === record.friendId)
|
|
181
|
+
return refuse("already_linked");
|
|
182
|
+
// A code can only bring in an identity the agent does not already trust; trusted
|
|
183
|
+
// records are merged deliberately by the owner, not by whoever holds a code.
|
|
184
|
+
if (input.claimant.trustLevel !== "stranger" && input.claimant.trustLevel !== "acquaintance")
|
|
185
|
+
return refuse("claimant_trusted");
|
|
186
|
+
const merged = await (0, friend_merge_1.mergeFriends)({
|
|
187
|
+
store: input.store,
|
|
188
|
+
friendsDir: input.friendsDir,
|
|
189
|
+
targetId: record.friendId,
|
|
190
|
+
sourceIds: [input.claimant.id],
|
|
191
|
+
actor: `link-code:${record.issuedBy}`,
|
|
192
|
+
reason: `identity link code redeemed on ${input.channel}`,
|
|
193
|
+
now: () => now,
|
|
194
|
+
});
|
|
195
|
+
if (!merged.ok)
|
|
196
|
+
return refuse("friend_missing");
|
|
197
|
+
recordOutcome(usedPath, { outcome: "linked", claimantId: input.claimant.id, channel: input.channel, linkedExternalIds: merged.addedExternalIds.map((externalId) => externalId.provider) });
|
|
198
|
+
(0, runtime_1.emitNervesEvent)({
|
|
199
|
+
component: "friends",
|
|
200
|
+
event: "friends.link_code_redeemed",
|
|
201
|
+
message: "linked a new identity through a link code",
|
|
202
|
+
meta: { ...meta, trustLevel: merged.target.trustLevel, linkedExternalIds: merged.addedExternalIds.map((externalId) => externalId.provider) },
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
status: "linked",
|
|
206
|
+
friend: merged.target,
|
|
207
|
+
reply: `Linked. I'll recognize this ${input.channel} account as ${merged.target.name} from now on.`,
|
|
208
|
+
};
|
|
209
|
+
}
|