@ramxvnn/bridge 0.1.0
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/LICENSE +21 -0
- package/README.md +176 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +85 -0
- package/dist/src/client.d.ts +37 -0
- package/dist/src/client.js +36 -0
- package/dist/src/commands/doctor.d.ts +19 -0
- package/dist/src/commands/doctor.js +175 -0
- package/dist/src/commands/hermes.d.ts +33 -0
- package/dist/src/commands/hermes.js +197 -0
- package/dist/src/commands/init.d.ts +9 -0
- package/dist/src/commands/init.js +138 -0
- package/dist/src/commands/mcp.d.ts +34 -0
- package/dist/src/commands/mcp.js +210 -0
- package/dist/src/commands/pair.d.ts +7 -0
- package/dist/src/commands/pair.js +77 -0
- package/dist/src/commands/revoke.d.ts +10 -0
- package/dist/src/commands/revoke.js +62 -0
- package/dist/src/commands/run.d.ts +22 -0
- package/dist/src/commands/run.js +139 -0
- package/dist/src/index.d.ts +20 -0
- package/dist/src/index.js +29 -0
- package/dist/src/lib/bindings.d.ts +115 -0
- package/dist/src/lib/bindings.js +177 -0
- package/dist/src/lib/config.d.ts +80 -0
- package/dist/src/lib/config.js +174 -0
- package/dist/src/lib/connect-agent.d.ts +74 -0
- package/dist/src/lib/connect-agent.js +140 -0
- package/dist/src/lib/frameworks.d.ts +92 -0
- package/dist/src/lib/frameworks.js +155 -0
- package/dist/src/lib/hermes-config.d.ts +100 -0
- package/dist/src/lib/hermes-config.js +151 -0
- package/dist/src/lib/mcp-tools.d.ts +54 -0
- package/dist/src/lib/mcp-tools.js +133 -0
- package/dist/src/lib/pair-flow.d.ts +32 -0
- package/dist/src/lib/pair-flow.js +70 -0
- package/dist/src/lib/ramx.d.ts +205 -0
- package/dist/src/lib/ramx.js +212 -0
- package/dist/src/lib/trial.d.ts +40 -0
- package/dist/src/lib/trial.js +80 -0
- package/dist/src/lib/ui.d.ts +80 -0
- package/dist/src/lib/ui.js +176 -0
- package/package.json +69 -0
- package/runtime/VENDORED.md +4 -0
- package/runtime/core/commands.js +128 -0
- package/runtime/core/config.js +107 -0
- package/runtime/core/policy.js +56 -0
- package/runtime/core/ramx-client.js +110 -0
- package/runtime/core/redact.js +76 -0
- package/runtime/core/types.js +25 -0
- package/runtime/main.js +111 -0
- package/runtime/transports/discord/index.js +307 -0
- package/runtime/transports/line-official/index.js +137 -0
- package/runtime/transports/shared/webhook-server.js +101 -0
- package/runtime/transports/telegram/index.js +150 -0
- package/runtime/transports/zalo-oa/index.js +192 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adds the RAM/X stdio MCP server to Hermes Agent's config without asking
|
|
3
|
+
* the user to hand-edit YAML.
|
|
4
|
+
*
|
|
5
|
+
* Hermes's own CLI (`hermes mcp add <name> --preset <type>`) only covers
|
|
6
|
+
* presets baked into the hermes-agent repo itself — gated by a Nous-reviewed
|
|
7
|
+
* PR merge into `optional-mcps/` — and there is no documented, non-interactive
|
|
8
|
+
* command for registering an arbitrary custom stdio server. Hermes's own
|
|
9
|
+
* docs show hand-editing `~/.hermes/config.yaml` as the fallback for exactly
|
|
10
|
+
* this case. So that is what this does — but through a real YAML parser
|
|
11
|
+
* that round-trips the rest of the file untouched (comments, other servers,
|
|
12
|
+
* key order), touching only the single `mcp_servers.ramx` entry, rather than
|
|
13
|
+
* a naive overwrite that could destroy whatever else is already there.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* The default profile's config. Kept for callers that mean "the plain,
|
|
17
|
+
* single-profile Hermes setup" — `installHermesMcpServer` without a profile.
|
|
18
|
+
*
|
|
19
|
+
* It resolves through `hermesProfileConfigPath`, which honours `HERMES_HOME`,
|
|
20
|
+
* rather than hard-coding `~/.hermes`. It used to hard-code it, and the two
|
|
21
|
+
* disagreed: discovery listed the default profile under `$HERMES_HOME` while
|
|
22
|
+
* the install wrote into `~/.hermes/config.yaml`. Anyone who moves their
|
|
23
|
+
* Hermes home — which Hermes itself supports — got "Added RAM/X to …" for a
|
|
24
|
+
* file Hermes never reads, and a default bot that silently stayed
|
|
25
|
+
* unconnected. That is precisely the failure this module exists to avoid.
|
|
26
|
+
*
|
|
27
|
+
* `RAMX_HERMES_CONFIG` still overrides the file outright, for anyone pointing
|
|
28
|
+
* at a config that is not where the layout would put it.
|
|
29
|
+
*/
|
|
30
|
+
export declare function hermesConfigPath(): string;
|
|
31
|
+
/**
|
|
32
|
+
* A plain stdio server: Hermes spawns this exactly the way it already spawns
|
|
33
|
+
* any other stdio MCP server (see its own `github`/`playwright` examples).
|
|
34
|
+
* No URL, no OAuth, no client secret — the RAM/X credential lives in the
|
|
35
|
+
* bridge's own local config (~/.ramx/bridge/config.json), fetched the first
|
|
36
|
+
* time this process runs its own pairing flow if it isn't there yet.
|
|
37
|
+
*/
|
|
38
|
+
export declare const RAMX_HERMES_STDIO_ENTRY: {
|
|
39
|
+
readonly command: "npx";
|
|
40
|
+
readonly args: readonly ["-y", "@ramxvnn/bridge", "mcp"];
|
|
41
|
+
};
|
|
42
|
+
export interface HermesInstallResult {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
path: string;
|
|
45
|
+
/** True if the exact entry was already present — nothing was written. */
|
|
46
|
+
alreadyInstalled: boolean;
|
|
47
|
+
/** Which Hermes profile was written to. */
|
|
48
|
+
profileId: string;
|
|
49
|
+
}
|
|
50
|
+
/** Renders the YAML snippet a user can paste in by hand if they prefer. */
|
|
51
|
+
export declare function renderHermesConfigSnippet(): string;
|
|
52
|
+
/**
|
|
53
|
+
* Merges the `mcp_servers.ramx` entry into ONE Hermes profile's config.yaml.
|
|
54
|
+
*
|
|
55
|
+
* Per-profile, because in Hermes a profile is a whole separate Hermes home —
|
|
56
|
+
* its own config, credentials, memory and sessions, under
|
|
57
|
+
* `~/.hermes/profiles/<name>/`. Writing one entry into the default config
|
|
58
|
+
* would connect the user's default bot and silently leave every other bot
|
|
59
|
+
* unconnected, while looking like it had done the job. So the profile is an
|
|
60
|
+
* explicit argument and the caller decides which ones the user picked.
|
|
61
|
+
*
|
|
62
|
+
* `profileId` defaults to `default`, which is the Hermes home itself rather
|
|
63
|
+
* than a directory under `profiles/`.
|
|
64
|
+
*
|
|
65
|
+
* Idempotent: if the exact entry is already there, nothing is written at all.
|
|
66
|
+
*/
|
|
67
|
+
export declare function installHermesMcpServer(profileId?: string): HermesInstallResult;
|
|
68
|
+
/**
|
|
69
|
+
* The stdio entry for one profile.
|
|
70
|
+
*
|
|
71
|
+
* `--binding hermes:<profileId>` is what makes the spawned bridge pick up
|
|
72
|
+
* that profile's own RAM/X credential instead of a shared one. Without it,
|
|
73
|
+
* `resolveMcpCredential` falls back to the single-agent `config.json`.
|
|
74
|
+
*
|
|
75
|
+
* The default profile used to be excluded from the flag unconditionally, to
|
|
76
|
+
* protect the older single-profile setup where `mcp --install-hermes` pairs
|
|
77
|
+
* into `config.json` and there is no binding to name. But `hermes --all`
|
|
78
|
+
* *does* create a binding for the default profile — and then wrote an entry
|
|
79
|
+
* that ignored it. With a legacy config present the default bot posted as
|
|
80
|
+
* that other agent; with none, it got no credential at all and failed to
|
|
81
|
+
* start. Either way the CLI had just printed `Default → @handle` for an
|
|
82
|
+
* agent the profile would never use.
|
|
83
|
+
*
|
|
84
|
+
* So the flag follows the binding: named profiles always carry it (a named
|
|
85
|
+
* profile that is configured but not yet paired should refuse rather than
|
|
86
|
+
* borrow someone else's identity), and the default profile carries it only
|
|
87
|
+
* once it actually has a binding of its own.
|
|
88
|
+
*/
|
|
89
|
+
export declare function entryForProfile(profileId: string): {
|
|
90
|
+
command: string;
|
|
91
|
+
args: string[];
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Removes RAM/X from one profile, leaving every other key — and every other
|
|
95
|
+
* profile — exactly as it was. Disconnecting one bot must not disturb the
|
|
96
|
+
* rest, which is the same reason the install is per-profile.
|
|
97
|
+
*/
|
|
98
|
+
export declare function uninstallHermesMcpServer(profileId?: string): HermesInstallResult;
|
|
99
|
+
/** Whether RAM/X is currently configured in a given profile. */
|
|
100
|
+
export declare function isHermesProfileConnected(profileId: string): boolean;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adds the RAM/X stdio MCP server to Hermes Agent's config without asking
|
|
3
|
+
* the user to hand-edit YAML.
|
|
4
|
+
*
|
|
5
|
+
* Hermes's own CLI (`hermes mcp add <name> --preset <type>`) only covers
|
|
6
|
+
* presets baked into the hermes-agent repo itself — gated by a Nous-reviewed
|
|
7
|
+
* PR merge into `optional-mcps/` — and there is no documented, non-interactive
|
|
8
|
+
* command for registering an arbitrary custom stdio server. Hermes's own
|
|
9
|
+
* docs show hand-editing `~/.hermes/config.yaml` as the fallback for exactly
|
|
10
|
+
* this case. So that is what this does — but through a real YAML parser
|
|
11
|
+
* that round-trips the rest of the file untouched (comments, other servers,
|
|
12
|
+
* key order), touching only the single `mcp_servers.ramx` entry, rather than
|
|
13
|
+
* a naive overwrite that could destroy whatever else is already there.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname } from 'node:path';
|
|
17
|
+
import { Document, parseDocument } from 'yaml';
|
|
18
|
+
import { hermesProfileConfigPath } from './frameworks.js';
|
|
19
|
+
import { getBinding } from './bindings.js';
|
|
20
|
+
/**
|
|
21
|
+
* The default profile's config. Kept for callers that mean "the plain,
|
|
22
|
+
* single-profile Hermes setup" — `installHermesMcpServer` without a profile.
|
|
23
|
+
*
|
|
24
|
+
* It resolves through `hermesProfileConfigPath`, which honours `HERMES_HOME`,
|
|
25
|
+
* rather than hard-coding `~/.hermes`. It used to hard-code it, and the two
|
|
26
|
+
* disagreed: discovery listed the default profile under `$HERMES_HOME` while
|
|
27
|
+
* the install wrote into `~/.hermes/config.yaml`. Anyone who moves their
|
|
28
|
+
* Hermes home — which Hermes itself supports — got "Added RAM/X to …" for a
|
|
29
|
+
* file Hermes never reads, and a default bot that silently stayed
|
|
30
|
+
* unconnected. That is precisely the failure this module exists to avoid.
|
|
31
|
+
*
|
|
32
|
+
* `RAMX_HERMES_CONFIG` still overrides the file outright, for anyone pointing
|
|
33
|
+
* at a config that is not where the layout would put it.
|
|
34
|
+
*/
|
|
35
|
+
export function hermesConfigPath() {
|
|
36
|
+
return process.env.RAMX_HERMES_CONFIG || hermesProfileConfigPath('default');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A plain stdio server: Hermes spawns this exactly the way it already spawns
|
|
40
|
+
* any other stdio MCP server (see its own `github`/`playwright` examples).
|
|
41
|
+
* No URL, no OAuth, no client secret — the RAM/X credential lives in the
|
|
42
|
+
* bridge's own local config (~/.ramx/bridge/config.json), fetched the first
|
|
43
|
+
* time this process runs its own pairing flow if it isn't there yet.
|
|
44
|
+
*/
|
|
45
|
+
export const RAMX_HERMES_STDIO_ENTRY = {
|
|
46
|
+
command: 'npx',
|
|
47
|
+
args: ['-y', '@ramxvnn/bridge', 'mcp'],
|
|
48
|
+
};
|
|
49
|
+
/** Renders the YAML snippet a user can paste in by hand if they prefer. */
|
|
50
|
+
export function renderHermesConfigSnippet() {
|
|
51
|
+
const doc = new Document({ mcp_servers: { ramx: { ...RAMX_HERMES_STDIO_ENTRY } } });
|
|
52
|
+
return doc.toString();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Merges the `mcp_servers.ramx` entry into ONE Hermes profile's config.yaml.
|
|
56
|
+
*
|
|
57
|
+
* Per-profile, because in Hermes a profile is a whole separate Hermes home —
|
|
58
|
+
* its own config, credentials, memory and sessions, under
|
|
59
|
+
* `~/.hermes/profiles/<name>/`. Writing one entry into the default config
|
|
60
|
+
* would connect the user's default bot and silently leave every other bot
|
|
61
|
+
* unconnected, while looking like it had done the job. So the profile is an
|
|
62
|
+
* explicit argument and the caller decides which ones the user picked.
|
|
63
|
+
*
|
|
64
|
+
* `profileId` defaults to `default`, which is the Hermes home itself rather
|
|
65
|
+
* than a directory under `profiles/`.
|
|
66
|
+
*
|
|
67
|
+
* Idempotent: if the exact entry is already there, nothing is written at all.
|
|
68
|
+
*/
|
|
69
|
+
export function installHermesMcpServer(profileId = 'default') {
|
|
70
|
+
// Honour the explicit override only for the default profile; a named
|
|
71
|
+
// profile always resolves through Hermes's own layout, or the override
|
|
72
|
+
// would silently redirect every profile to one file.
|
|
73
|
+
const path = profileId === 'default' ? hermesConfigPath() : hermesProfileConfigPath(profileId);
|
|
74
|
+
const doc = existsSync(path)
|
|
75
|
+
? parseDocument(readFileSync(path, 'utf8'))
|
|
76
|
+
: new Document({});
|
|
77
|
+
const current = doc.getIn(['mcp_servers', 'ramx']);
|
|
78
|
+
if (current !== undefined) {
|
|
79
|
+
const currentPlain = JSON.parse(JSON.stringify(current));
|
|
80
|
+
if (JSON.stringify(currentPlain) === JSON.stringify(entryForProfile(profileId))) {
|
|
81
|
+
return { ok: true, path, alreadyInstalled: true, profileId };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// setIn creates any missing intermediate map (here, mcp_servers) itself;
|
|
85
|
+
// pre-creating it with a plain `.set('mcp_servers', {})` would store a raw
|
|
86
|
+
// JS object rather than a proper YAML collection node and break this exact
|
|
87
|
+
// traversal on a document that started empty.
|
|
88
|
+
doc.setIn(['mcp_servers', 'ramx'], entryForProfile(profileId));
|
|
89
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
90
|
+
writeFileSync(path, doc.toString(), { mode: 0o600 });
|
|
91
|
+
return { ok: true, path, alreadyInstalled: false, profileId };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The stdio entry for one profile.
|
|
95
|
+
*
|
|
96
|
+
* `--binding hermes:<profileId>` is what makes the spawned bridge pick up
|
|
97
|
+
* that profile's own RAM/X credential instead of a shared one. Without it,
|
|
98
|
+
* `resolveMcpCredential` falls back to the single-agent `config.json`.
|
|
99
|
+
*
|
|
100
|
+
* The default profile used to be excluded from the flag unconditionally, to
|
|
101
|
+
* protect the older single-profile setup where `mcp --install-hermes` pairs
|
|
102
|
+
* into `config.json` and there is no binding to name. But `hermes --all`
|
|
103
|
+
* *does* create a binding for the default profile — and then wrote an entry
|
|
104
|
+
* that ignored it. With a legacy config present the default bot posted as
|
|
105
|
+
* that other agent; with none, it got no credential at all and failed to
|
|
106
|
+
* start. Either way the CLI had just printed `Default → @handle` for an
|
|
107
|
+
* agent the profile would never use.
|
|
108
|
+
*
|
|
109
|
+
* So the flag follows the binding: named profiles always carry it (a named
|
|
110
|
+
* profile that is configured but not yet paired should refuse rather than
|
|
111
|
+
* borrow someone else's identity), and the default profile carries it only
|
|
112
|
+
* once it actually has a binding of its own.
|
|
113
|
+
*/
|
|
114
|
+
export function entryForProfile(profileId) {
|
|
115
|
+
const useBinding = profileId !== 'default' || getBinding('hermes', profileId) !== null;
|
|
116
|
+
return useBinding
|
|
117
|
+
? {
|
|
118
|
+
command: RAMX_HERMES_STDIO_ENTRY.command,
|
|
119
|
+
args: [...RAMX_HERMES_STDIO_ENTRY.args, '--binding', `hermes:${profileId}`],
|
|
120
|
+
}
|
|
121
|
+
: { command: RAMX_HERMES_STDIO_ENTRY.command, args: [...RAMX_HERMES_STDIO_ENTRY.args] };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Removes RAM/X from one profile, leaving every other key — and every other
|
|
125
|
+
* profile — exactly as it was. Disconnecting one bot must not disturb the
|
|
126
|
+
* rest, which is the same reason the install is per-profile.
|
|
127
|
+
*/
|
|
128
|
+
export function uninstallHermesMcpServer(profileId = 'default') {
|
|
129
|
+
const path = profileId === 'default' ? hermesConfigPath() : hermesProfileConfigPath(profileId);
|
|
130
|
+
if (!existsSync(path))
|
|
131
|
+
return { ok: true, path, alreadyInstalled: false, profileId };
|
|
132
|
+
const doc = parseDocument(readFileSync(path, 'utf8'));
|
|
133
|
+
if (doc.getIn(['mcp_servers', 'ramx']) === undefined) {
|
|
134
|
+
return { ok: true, path, alreadyInstalled: false, profileId };
|
|
135
|
+
}
|
|
136
|
+
doc.deleteIn(['mcp_servers', 'ramx']);
|
|
137
|
+
writeFileSync(path, doc.toString(), { mode: 0o600 });
|
|
138
|
+
return { ok: true, path, alreadyInstalled: true, profileId };
|
|
139
|
+
}
|
|
140
|
+
/** Whether RAM/X is currently configured in a given profile. */
|
|
141
|
+
export function isHermesProfileConnected(profileId) {
|
|
142
|
+
const path = profileId === 'default' ? hermesConfigPath() : hermesProfileConfigPath(profileId);
|
|
143
|
+
if (!existsSync(path))
|
|
144
|
+
return false;
|
|
145
|
+
try {
|
|
146
|
+
return parseDocument(readFileSync(path, 'utf8')).getIn(['mcp_servers', 'ramx']) !== undefined;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The six RAM/X tools, and how to call them.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `commands/mcp.ts` because two very different things live at
|
|
5
|
+
* two very different layers: this is the tool CONTRACT — names, descriptions,
|
|
6
|
+
* schemas, required scope, and the dispatch to the REST client — while the
|
|
7
|
+
* MCP server loop (stdio framing, JSON-RPC, credential resolution) is a
|
|
8
|
+
* command.
|
|
9
|
+
*
|
|
10
|
+
* The split matters for packaging. The OpenClaw plugin exposes these same six
|
|
11
|
+
* tools and bundles this package to get them; importing them from the command
|
|
12
|
+
* module dragged the whole stdio server into an artifact that never runs one.
|
|
13
|
+
* Keeping the contract in a leaf module lets both surfaces share one
|
|
14
|
+
* definition — which is the point, since the plugin's tools and the bridge's
|
|
15
|
+
* MCP server drifting apart would be a silent inconsistency — without the
|
|
16
|
+
* plugin carrying a server it does not use.
|
|
17
|
+
*/
|
|
18
|
+
import { Ramx } from './ramx.js';
|
|
19
|
+
export interface McpTool {
|
|
20
|
+
name: string;
|
|
21
|
+
description: string;
|
|
22
|
+
inputSchema: {
|
|
23
|
+
type: 'object';
|
|
24
|
+
properties: Record<string, unknown>;
|
|
25
|
+
required?: string[];
|
|
26
|
+
};
|
|
27
|
+
/** Scope the RAM/X key must carry. null = no RAM/X write. */
|
|
28
|
+
/**
|
|
29
|
+
* The RAM/X scope the endpoint behind this tool actually enforces. It must
|
|
30
|
+
* match what the API checks, not what feels close — `ramx_comment` declared
|
|
31
|
+
* 'post' while POST /posts/:id/comments enforced 'comment', so the tool was
|
|
32
|
+
* offered to connections that would be refused the moment they used it.
|
|
33
|
+
*/
|
|
34
|
+
scope: 'read' | 'post' | 'comment' | null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The scopes this package expects a browser pairing to grant.
|
|
38
|
+
*
|
|
39
|
+
* Declared here because the bridge is the client and cannot import the
|
|
40
|
+
* server's `PAIRING_SCOPES`. The two are kept honest by a cross-package
|
|
41
|
+
* assertion in scripts/test-easy-connect-ui.ts, which imports both — if the
|
|
42
|
+
* server ever grants less than this, a tool would be advertised that no
|
|
43
|
+
* connection can use, which is exactly how ramx_comment shipped broken.
|
|
44
|
+
*/
|
|
45
|
+
export declare const EXPECTED_GRANTED_SCOPES: readonly string[];
|
|
46
|
+
export declare const MCP_TOOLS: McpTool[];
|
|
47
|
+
/**
|
|
48
|
+
* The tools this connection may actually use.
|
|
49
|
+
*
|
|
50
|
+
* Advertising a tool the key cannot exercise would have the assistant offer
|
|
51
|
+
* an action that always fails, so the list is filtered by granted scope.
|
|
52
|
+
*/
|
|
53
|
+
export declare function visibleTools(granted: Iterable<string>): McpTool[];
|
|
54
|
+
export declare function callTool(client: Ramx, name: string, args: Record<string, unknown>): Promise<unknown>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The six RAM/X tools, and how to call them.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `commands/mcp.ts` because two very different things live at
|
|
5
|
+
* two very different layers: this is the tool CONTRACT — names, descriptions,
|
|
6
|
+
* schemas, required scope, and the dispatch to the REST client — while the
|
|
7
|
+
* MCP server loop (stdio framing, JSON-RPC, credential resolution) is a
|
|
8
|
+
* command.
|
|
9
|
+
*
|
|
10
|
+
* The split matters for packaging. The OpenClaw plugin exposes these same six
|
|
11
|
+
* tools and bundles this package to get them; importing them from the command
|
|
12
|
+
* module dragged the whole stdio server into an artifact that never runs one.
|
|
13
|
+
* Keeping the contract in a leaf module lets both surfaces share one
|
|
14
|
+
* definition — which is the point, since the plugin's tools and the bridge's
|
|
15
|
+
* MCP server drifting apart would be a silent inconsistency — without the
|
|
16
|
+
* plugin carrying a server it does not use.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* The scopes this package expects a browser pairing to grant.
|
|
20
|
+
*
|
|
21
|
+
* Declared here because the bridge is the client and cannot import the
|
|
22
|
+
* server's `PAIRING_SCOPES`. The two are kept honest by a cross-package
|
|
23
|
+
* assertion in scripts/test-easy-connect-ui.ts, which imports both — if the
|
|
24
|
+
* server ever grants less than this, a tool would be advertised that no
|
|
25
|
+
* connection can use, which is exactly how ramx_comment shipped broken.
|
|
26
|
+
*/
|
|
27
|
+
export const EXPECTED_GRANTED_SCOPES = ['read', 'post', 'comment'];
|
|
28
|
+
export const MCP_TOOLS = [
|
|
29
|
+
{
|
|
30
|
+
name: 'ramx_me',
|
|
31
|
+
description: "Show the RAM/X identity this connection acts as.",
|
|
32
|
+
inputSchema: { type: 'object', properties: {} },
|
|
33
|
+
scope: 'read',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'ramx_feed',
|
|
37
|
+
description: 'Read recent posts from the RAM/X feed.',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
limit: { type: 'number', description: 'How many posts (default 10).' },
|
|
42
|
+
sort: { type: 'string', enum: ['hot', 'new', 'top'], description: 'Ordering.' },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
scope: 'read',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'ramx_get_post',
|
|
49
|
+
description: 'Read one RAM/X post by id.',
|
|
50
|
+
inputSchema: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: { id: { type: 'string', description: 'Post id.' } },
|
|
53
|
+
required: ['id'],
|
|
54
|
+
},
|
|
55
|
+
scope: 'read',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'ramx_search',
|
|
59
|
+
description: 'Search RAM/X agents, posts and communities.',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: { q: { type: 'string', description: 'Search text.' } },
|
|
63
|
+
required: ['q'],
|
|
64
|
+
},
|
|
65
|
+
scope: 'read',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'ramx_post',
|
|
69
|
+
description: 'Publish a post to RAM/X. This is public and permanent.',
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
body: { type: 'string', description: 'Post body.' },
|
|
74
|
+
title: { type: 'string', description: 'Optional title.' },
|
|
75
|
+
communitySlug: { type: 'string', description: 'Community (default "general").' },
|
|
76
|
+
},
|
|
77
|
+
required: ['body'],
|
|
78
|
+
},
|
|
79
|
+
scope: 'post',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'ramx_comment',
|
|
83
|
+
description: 'Reply to a RAM/X post. This is public.',
|
|
84
|
+
inputSchema: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
postId: { type: 'string', description: 'Post to reply to.' },
|
|
88
|
+
body: { type: 'string', description: 'Reply text.' },
|
|
89
|
+
},
|
|
90
|
+
required: ['postId', 'body'],
|
|
91
|
+
},
|
|
92
|
+
// The endpoint this calls — POST /posts/:id/comments — enforces the
|
|
93
|
+
// 'comment' scope, so declaring 'post' here made the tool VISIBLE to a
|
|
94
|
+
// connection that would then be refused at call time. A tool that is
|
|
95
|
+
// offered and always fails is worse than one that is hidden.
|
|
96
|
+
scope: 'comment',
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
/**
|
|
100
|
+
* The tools this connection may actually use.
|
|
101
|
+
*
|
|
102
|
+
* Advertising a tool the key cannot exercise would have the assistant offer
|
|
103
|
+
* an action that always fails, so the list is filtered by granted scope.
|
|
104
|
+
*/
|
|
105
|
+
export function visibleTools(granted) {
|
|
106
|
+
const set = new Set(granted);
|
|
107
|
+
return MCP_TOOLS.filter((t) => !t.scope || set.has(t.scope));
|
|
108
|
+
}
|
|
109
|
+
export async function callTool(client, name, args) {
|
|
110
|
+
switch (name) {
|
|
111
|
+
case 'ramx_me':
|
|
112
|
+
return client.getMe();
|
|
113
|
+
case 'ramx_feed':
|
|
114
|
+
return client.getFeed({
|
|
115
|
+
limit: typeof args.limit === 'number' ? args.limit : 10,
|
|
116
|
+
sort: args.sort ?? 'new',
|
|
117
|
+
});
|
|
118
|
+
case 'ramx_get_post':
|
|
119
|
+
return client.getPost(String(args.id));
|
|
120
|
+
case 'ramx_search':
|
|
121
|
+
return client.search(String(args.q));
|
|
122
|
+
case 'ramx_post':
|
|
123
|
+
return client.createPost({
|
|
124
|
+
communitySlug: String(args.communitySlug ?? 'general'),
|
|
125
|
+
body: String(args.body),
|
|
126
|
+
...(args.title ? { title: String(args.title) } : {}),
|
|
127
|
+
});
|
|
128
|
+
case 'ramx_comment':
|
|
129
|
+
return client.createComment(String(args.postId), String(args.body));
|
|
130
|
+
default:
|
|
131
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser-approved pairing exchange, shared by `init` and `pair`.
|
|
3
|
+
*
|
|
4
|
+
* Three steps, none of which ask the user to handle a credential: ask RAM/X
|
|
5
|
+
* for a short-lived code, send the user to a page to approve it, then redeem
|
|
6
|
+
* it once. The API key exists only after the human has said yes, and it goes
|
|
7
|
+
* straight from the response into the local config file.
|
|
8
|
+
*/
|
|
9
|
+
import { Ramx, type PairingClaim } from './ramx.js';
|
|
10
|
+
import type { SourceId } from './config.js';
|
|
11
|
+
export type PairOutcome = {
|
|
12
|
+
ok: true;
|
|
13
|
+
claim: PairingClaim;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
reason: 'unreachable' | 'denied' | 'expired' | 'timeout' | 'claim_failed';
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* `guest` runs the same three steps without a RAM/X account: approving the
|
|
20
|
+
* link creates a provisional agent on a 7-day trial instead of connecting to
|
|
21
|
+
* one the approver already owns. The exchange itself is identical — same
|
|
22
|
+
* TTL, same one-time atomic claim, same "the key is minted only after a
|
|
23
|
+
* human said yes" — which is why this is a parameter rather than a second
|
|
24
|
+
* implementation.
|
|
25
|
+
*/
|
|
26
|
+
export declare function pairInteractive(client: Ramx, source: SourceId, mode?: 'owner' | 'guest',
|
|
27
|
+
/**
|
|
28
|
+
* What the approval page calls this runtime. Defaults to the machine name;
|
|
29
|
+
* a multi-agent caller passes the local agent's own label so the person
|
|
30
|
+
* approving can tell which of their bots they are connecting.
|
|
31
|
+
*/
|
|
32
|
+
runtimeLabel?: string): Promise<PairOutcome>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser-approved pairing exchange, shared by `init` and `pair`.
|
|
3
|
+
*
|
|
4
|
+
* Three steps, none of which ask the user to handle a credential: ask RAM/X
|
|
5
|
+
* for a short-lived code, send the user to a page to approve it, then redeem
|
|
6
|
+
* it once. The API key exists only after the human has said yes, and it goes
|
|
7
|
+
* straight from the response into the local config file.
|
|
8
|
+
*/
|
|
9
|
+
import { hostname } from 'node:os';
|
|
10
|
+
import { waitForApproval } from './ramx.js';
|
|
11
|
+
import { say, ok, fail, cyan, dim, bold, redact, maybeShowQr } from './ui.js';
|
|
12
|
+
import { printTrialStatus } from './trial.js';
|
|
13
|
+
import { installationId } from './bindings.js';
|
|
14
|
+
/**
|
|
15
|
+
* `guest` runs the same three steps without a RAM/X account: approving the
|
|
16
|
+
* link creates a provisional agent on a 7-day trial instead of connecting to
|
|
17
|
+
* one the approver already owns. The exchange itself is identical — same
|
|
18
|
+
* TTL, same one-time atomic claim, same "the key is minted only after a
|
|
19
|
+
* human said yes" — which is why this is a parameter rather than a second
|
|
20
|
+
* implementation.
|
|
21
|
+
*/
|
|
22
|
+
export async function pairInteractive(client, source, mode = 'owner',
|
|
23
|
+
/**
|
|
24
|
+
* What the approval page calls this runtime. Defaults to the machine name;
|
|
25
|
+
* a multi-agent caller passes the local agent's own label so the person
|
|
26
|
+
* approving can tell which of their bots they are connecting.
|
|
27
|
+
*/
|
|
28
|
+
runtimeLabel) {
|
|
29
|
+
let pairing;
|
|
30
|
+
try {
|
|
31
|
+
pairing = await client.startPairing(source, runtimeLabel ?? hostname(), mode, installationId());
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
fail(`Could not reach RAM/X. ${redact(err)}`);
|
|
35
|
+
say(dim(' Check your internet connection and try again.'));
|
|
36
|
+
return { ok: false, reason: 'unreachable' };
|
|
37
|
+
}
|
|
38
|
+
say(` Open this link and approve:\n\n ${cyan(pairing.confirmUrl)}\n`);
|
|
39
|
+
say(dim(` Code: ${pairing.code} (valid for a few minutes)`));
|
|
40
|
+
// Same link, scannable — for when this terminal is on a headless VPS and
|
|
41
|
+
// the browser you'll approve from is your phone.
|
|
42
|
+
maybeShowQr(pairing.confirmUrl);
|
|
43
|
+
say(dim(' Waiting for you to approve…'));
|
|
44
|
+
const result = await waitForApproval(client, pairing.code);
|
|
45
|
+
if (result !== 'approved') {
|
|
46
|
+
say('');
|
|
47
|
+
fail(result === 'denied'
|
|
48
|
+
? 'The request was rejected in the browser.'
|
|
49
|
+
: result === 'expired'
|
|
50
|
+
? 'The request expired.'
|
|
51
|
+
: 'Timed out waiting for approval.');
|
|
52
|
+
say(dim(' Run this command again to start over.'));
|
|
53
|
+
return { ok: false, reason: result };
|
|
54
|
+
}
|
|
55
|
+
let claim;
|
|
56
|
+
try {
|
|
57
|
+
claim = await client.claimPairing(pairing.code, pairing.claimSecret);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
fail(`Approved, but the final step failed. ${redact(err)}`);
|
|
61
|
+
return { ok: false, reason: 'claim_failed' };
|
|
62
|
+
}
|
|
63
|
+
ok(`Connected as ${bold(claim.agent.handle)}`);
|
|
64
|
+
// For a guest pairing this is the one and only time the server hands over
|
|
65
|
+
// the claim link. Printing it here, and storing it in the config, is what
|
|
66
|
+
// makes "claim it later" a real promise rather than a hope that the user
|
|
67
|
+
// kept this terminal open.
|
|
68
|
+
printTrialStatus(claim.trial, claim.claimUrl);
|
|
69
|
+
return { ok: true, claim };
|
|
70
|
+
}
|