@orboto/mcp 0.210.1 → 0.211.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,7 +123,7 @@ Omit `ORBOTO_API_KEY` to use OAuth login instead. `codex mcp add` writes the sam
123
123
  | `ORBOTO_MCP_PORT` | no | Listen port when transport is `http`. Default `3100`. |
124
124
  | `ORBOTO_MCP_CLIENT` | no | User-agent suffix that lands in the API audit log. Useful for filtering audit rows by client. |
125
125
  | `ORBOTO_MCP_CHANNEL` | no | `1` (default) makes the stdio proxy a Claude Code channel: it follows the session's inbox stream and pushes each message as a `notifications/claude/channel` event that wakes the session (`claude --dangerously-load-development-channels server:orboto`, ORB-2140). `0` disables it. |
126
- | `ORBOTO_MCP_CHANNEL_DIGEST_MINUTES` | no | Digest window for `info` / `complete` inbox mail on the channel. Default `15`; `0` delivers every message at once. Requests, errors, questions and mail older than five minutes always go out immediately. |
126
+ | `ORBOTO_MCP_CHANNEL_DIGEST_MINUTES` | no | Digest window for account-addressed or broadcast `info` / `complete` inbox mail on the channel. Default `15`; `0` delivers every message at once. Requests, errors, questions, mail addressed to this session, replies in a thread this session sent into within 24 hours and mail older than five minutes always go out immediately. |
127
127
 
128
128
  ## Version compatibility
129
129
 
@@ -7,10 +7,10 @@
7
7
  export const CONNECT_SCRIPT_HEADING = 'If you are an AI agent helping a person set this up';
8
8
  export const CONNECT_SCRIPT_STEPS = [
9
9
  'Work one step per message: say in one sentence what the step does and why, run it, show the result, and only then move on.',
10
- 'Run every step you can run yourself - `orboto connect --yes` checks Node and Claude Code, writes the `orboto` server entry for this directory and prints the exact start command with the standing arguments.',
10
+ 'Run every step you can run yourself - `orboto connect --yes --project <KEY>` wires this directory (profile pointer, project, the `orboto` server entry, one identity, the completion hook), probes the wake channel and ends with the checklist; exit code 0 means every line passed, and a failing line names its fix.',
11
11
  'Hand the person only the steps that need a human: the browser login, the two `/plugin` commands that install the orboto plugin (a CLI cannot type into a running session), quitting and restarting the session with that command, confirming the servers from `.mcp.json` and, in the development form, the local-development prompt, and, on a Team or Enterprise plan, the one Owner switch that allows channels and lists the plugin.',
12
12
  'Give every command the person runs verbatim in its own code block, never paraphrased and never merged into prose.',
13
- 'Verify before you continue: `orboto whoami` for the identity, `orboto mcp status` for the entry of this directory, `orboto connect --verify` for the channel.',
13
+ 'Verify before you continue: every line of the `orboto connect` checklist passes, `orboto doctor` prints the same checklist later, `orboto connect --verify` probes the running session; declare or change the scope with `orboto session-start` in the shell, never through the claude.ai connector.',
14
14
  'Never paste a reference page into the chat - link it and keep the message to the step you are on.',
15
15
  'Never ask for an API key, a password or a token in the chat; `orboto login` writes the credential itself.',
16
16
  'When a check fails, name the cause from the troubleshooting table, fix it, and repeat the check before the next step.',
@@ -2,6 +2,8 @@ import { RESTART_REQUESTED_NOTICE, writeRestartRequest } from './session-restart
2
2
  import { confirmScope, rememberedScope } from './session-scope-memory.js';
3
3
  export { RESTART_REQUESTED_NOTICE };
4
4
  export const CHANNEL_METHOD = 'notifications/claude/channel';
5
+ /** ORB-2263 - the server notice an unscoped channel session gets; the session sees it once, with the exact CLI call. */
6
+ export const DECLARE_SCOPE_NOTICE = 'declare_scope';
5
7
  export const CHANNEL_CAPABILITY = 'claude/channel';
6
8
  export const DEFAULT_DIGEST_MINUTES = 15;
7
9
  const IMMEDIATE_AFTER_MS = 5 * 60_000;
@@ -19,7 +21,7 @@ export const CHANNEL_INSTRUCTIONS = 'Inbox channel: messages for THIS session ar
19
21
  + 'Never answer the channel itself and never ack what you did not handle - dismiss it instead with orboto_messages { dismiss: { ids, reason } }: '
20
22
  + 'not_mine for another session\'s mail (the sender is told, and the message never reaches this session again), obsolete or duplicate with a note when the request is already done. '
21
23
  + 'A session that declared no scope is woken by mail addressed to it and by broadcasts only, and gets one notice event saying so on connect: '
22
- + 'declare the scope with orboto_session_start { scope: { role, projectKeys } } to be woken by the account\'s project mail again - '
24
+ + 'declare the scope through the CLI in the shell, `orboto session-start --role worker --scope-projects <KEY>`, to be woken by the account\'s project mail again - never through the claude.ai connector, which is another session - '
23
25
  + 'the rest of the account\'s inbox stays readable with orboto_messages the whole time. '
24
26
  + 'Declare it once: the session keeps its id and scope across reconnects and restarts of this terminal.';
25
27
  const START_FLAG = '--dangerously-load-development-channels server:orboto';
@@ -65,12 +67,18 @@ function waitedMinutes(createdAt, now) {
65
67
  return 0;
66
68
  return Math.max(0, Math.floor((now - t) / 60_000));
67
69
  }
68
- /** A message goes out at once when it asks for something or already waited; the rest is digest material. */
70
+ /** ORB-2265 - a message the stream flags as addressed to this session or answering a thread it sent into is never digest material. */
71
+ export function isForThisSession(m) {
72
+ return m.addressed === true || m.inSentThread === true;
73
+ }
74
+ /** A message goes out at once when it asks for something, is addressed to this session, answers its thread or already waited; account and broadcast info is digest material. */
69
75
  export function isImmediate(m, now, digestMinutes) {
70
76
  if (digestMinutes <= 0)
71
77
  return true;
72
78
  if (m.kind === 'request' || m.kind === 'error')
73
79
  return true;
80
+ if (isForThisSession(m))
81
+ return true;
74
82
  if (waitedMinutes(m.createdAt, now) * 60_000 >= IMMEDIATE_AFTER_MS)
75
83
  return true;
76
84
  return ASK.test(`${m.subject} ${firstLine(m.payload)}`);
@@ -96,6 +104,11 @@ export function renderEvent(m, now) {
96
104
  },
97
105
  };
98
106
  }
107
+ /** ORB-2263 - the scope hint names the project of this checkout in its CLI call when the environment carries one. */
108
+ export function scopeHintWithKey(content, rawKey) {
109
+ const key = rawKey?.trim().toUpperCase();
110
+ return key && /^[A-Z][A-Z0-9_]{0,63}$/.test(key) ? content.replaceAll('<KEY>', key) : content;
111
+ }
99
112
  export function renderDigest(batch, now) {
100
113
  const lines = [`Inbox digest: ${batch.length} info/complete message(s) since the last event. Ack the ones you handled with orboto_messages { ackIds }.`];
101
114
  for (const m of batch) {
@@ -119,6 +132,7 @@ export class InboxChannel {
119
132
  seen = new Set();
120
133
  seenOrder = [];
121
134
  batch = [];
135
+ scopeHintShown = false;
122
136
  /** Counters an operator can read from the log; tests read them directly. */
123
137
  stats = { delivered: 0, digested: 0, duplicates: 0, reconnects: 0, notices: 0 };
124
138
  /** ORB-2209 - what the server said on the last connect. */
@@ -172,10 +186,18 @@ export class InboxChannel {
172
186
  this.digestTimer = setTimeout(() => { this.digestTimer = null; void this.flushDigest(); }, this.digestMinutes * 60_000);
173
187
  }
174
188
  }
175
- /** ORB-2151 - a server notice (the scope hint) is emitted as-is and never becomes the replay anchor. */
189
+ /** ORB-2151 - a server notice is emitted and never becomes the replay anchor; the scope hint only once per process (ORB-2263). */
176
190
  async deliverNotice(notice, content) {
177
191
  if (!content)
178
192
  return;
193
+ if (notice === DECLARE_SCOPE_NOTICE) {
194
+ if (this.scopeHintShown)
195
+ return;
196
+ this.scopeHintShown = true;
197
+ this.stats.notices += 1;
198
+ await this.emit({ content: scopeHintWithKey(content, this.opts.projectKey ?? process.env.ORBOTO_PROJECT_KEY), meta: { kind: 'notice', notice } });
199
+ return;
200
+ }
179
201
  this.stats.notices += 1;
180
202
  if (notice === RESTART_REQUESTED_NOTICE) {
181
203
  await this.emit({ content: this.takeRestartRequest(content), meta: { kind: 'notice', notice } });
@@ -5,7 +5,7 @@
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
6
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
7
  import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
8
- import { CHANNEL_CAPABILITY, CHANNEL_INSTRUCTIONS, CHANNEL_METHOD, CLI_OUTDATED_NOTICE, InboxChannel, RESTART_REQUESTED_NOTICE, cliOutdatedNotice, digestMinutesFromEnv, isImmediate, renderEvent } from './inbox-channel.js';
8
+ import { CHANNEL_CAPABILITY, CHANNEL_INSTRUCTIONS, CHANNEL_METHOD, CLI_OUTDATED_NOTICE, InboxChannel, RESTART_REQUESTED_NOTICE, cliOutdatedNotice, digestMinutesFromEnv, isImmediate, renderEvent, scopeHintWithKey } from './inbox-channel.js';
9
9
  import { buildOrbotoMcpServer } from './server.js';
10
10
  import { _forgetDeclaredScopes, rememberDeclaredScope, rememberedScope } from './session-scope-memory.js';
11
11
  function mockMcp() {
@@ -49,6 +49,14 @@ describe('renderEvent / isImmediate', () => {
49
49
  expect(isImmediate(msg({ kind: 'info', payload: { message: 'old' }, createdAt: '2026-09-18 09:50:00+00' }), NOW, 15)).toBe(true);
50
50
  expect(isImmediate(msg({ kind: 'info', payload: { message: 'pushed abc' } }), NOW, 0)).toBe(true);
51
51
  });
52
+ it('ORB-2265 - info flagged addressed or in a sent thread wakes at once; account and broadcast info stays digest material', () => {
53
+ const info = (over = {}) => msg({ kind: 'info', payload: { message: 'z440-2 is off' }, ...over });
54
+ expect(isImmediate(info({ addressed: true }), NOW, 15)).toBe(true);
55
+ expect(isImmediate(info({ kind: 'complete', payload: null, addressed: true }), NOW, 15)).toBe(true);
56
+ expect(isImmediate(info({ threadId: 't1', inSentThread: true }), NOW, 15)).toBe(true);
57
+ expect(isImmediate(info(), NOW, 15)).toBe(false);
58
+ expect(isImmediate(info({ threadId: 't1' }), NOW, 15)).toBe(false);
59
+ });
52
60
  it('reads the digest window from the environment with a safe default', () => {
53
61
  expect(digestMinutesFromEnv(undefined)).toBe(15);
54
62
  expect(digestMinutesFromEnv('0')).toBe(0);
@@ -58,6 +66,25 @@ describe('renderEvent / isImmediate', () => {
58
66
  });
59
67
  });
60
68
  describe('InboxChannel', () => {
69
+ it('ORB-2265 - a connected channel wakes at once for info addressed to its session and for a reply flagged as in its sent thread, and digests the account info', async () => {
70
+ const { mcp, notification } = mockMcp();
71
+ const mine = 'bbbbbbbb-0000-4000-8000-000000000000';
72
+ const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-proc', mcp, log: () => { }, digestMinutes: 15 });
73
+ channel.applySession({ id: mine, scope: null, resumed: false, scopeRestored: false, reconnects: 0, lastReconnectAt: null });
74
+ const noSession = { email: 'claude@orboto.io', sessionId: null, label: 'claude@orboto.io' };
75
+ const base = { kind: 'info', payload: { message: 'z440-2 is off' }, createdAt: '2026-09-18 09:59:50+00' };
76
+ await channel.deliver(msg({ ...base, id: 'addr', addressed: true }));
77
+ await channel.deliver(msg({ ...base, id: 'thread', threadId: 't1', inSentThread: true, to: noSession }));
78
+ await channel.deliver(msg({ ...base, id: 'account', to: noSession }));
79
+ await channel.deliver(msg({ ...base, id: 'stale-thread', threadId: 't0', to: noSession }));
80
+ expect(notification).toHaveBeenCalledTimes(2);
81
+ expect(notification.mock.calls.map((c) => c[0].params.meta.id)).toEqual(['addr', 'thread']);
82
+ expect(channel.stats).toMatchObject({ delivered: 2, digested: 2 });
83
+ await vi.advanceTimersByTimeAsync(15 * 60_000);
84
+ expect(notification).toHaveBeenCalledTimes(3);
85
+ expect(notification.mock.calls[2][0].params.meta).toMatchObject({ kind: 'digest', count: '2' });
86
+ channel.close();
87
+ });
61
88
  it('turns each streamed message into one channel notification and batches info into a digest', async () => {
62
89
  const { mcp, notification } = mockMcp();
63
90
  const fetchFn = vi.fn().mockResolvedValueOnce(sse([
@@ -162,6 +189,27 @@ describe('InboxChannel', () => {
162
189
  expect(String(fetchFn.mock.calls[1][0])).toContain('since=m1');
163
190
  channel.close();
164
191
  });
192
+ it('ORB-2263 - the scope hint reaches the session once per process, with the checkout project in the CLI call', async () => {
193
+ const { mcp, notification } = mockMcp();
194
+ const hint = { notice: 'declare_scope', sessionId: 'cccccccc-0000-4000-8000-000000000000', content: 'declare your scope through the CLI in the shell: orboto session-start --role worker --scope-projects <KEY> - never through the claude.ai connector' };
195
+ const fetchFn = vi.fn()
196
+ .mockResolvedValueOnce(sse([hint]))
197
+ .mockResolvedValueOnce(sse([hint]))
198
+ .mockImplementation(() => new Promise(() => { }));
199
+ const channel = new InboxChannel({ baseUrl: 'https://x.test', apiKey: 'orb_k', instanceToken: 'mcp-once', mcp, fetchFn, log: () => { }, digestMinutes: 0, projectKey: 'acme' });
200
+ channel.start();
201
+ await vi.waitFor(() => expect(fetchFn).toHaveBeenCalledTimes(1));
202
+ await vi.advanceTimersByTimeAsync(5_000);
203
+ await vi.waitFor(() => expect(fetchFn.mock.calls.length).toBeGreaterThanOrEqual(3));
204
+ expect(notification).toHaveBeenCalledTimes(1);
205
+ const sent = notification.mock.calls[0][0];
206
+ expect(sent.params.content).toContain('orboto session-start --role worker --scope-projects ACME');
207
+ expect(sent.params.content).not.toContain('<KEY>');
208
+ expect(channel.stats.notices).toBe(1);
209
+ expect(scopeHintWithKey('x <KEY>', undefined)).toBe('x <KEY>');
210
+ expect(scopeHintWithKey('x <KEY>', 'bad key!')).toBe('x <KEY>');
211
+ channel.close();
212
+ });
165
213
  it('ORB-2181 - a restart_requested notice becomes the supervisor request file and says so', async () => {
166
214
  const { mcp, notification } = mockMcp();
167
215
  const writeRestart = vi.fn().mockReturnValue({ written: true, sessionId: 'sess-1', path: '/home/a/.orboto/claude/sess-1/restart.json', detail: 'ok' });
package/dist/index.js CHANGED
@@ -78,7 +78,10 @@ async function main() {
78
78
  beat();
79
79
  const beats = setInterval(beat, 60_000);
80
80
  beats.unref?.();
81
- stdio.onclose = () => { clearInterval(beats); inbox.close(); };
81
+ const cleanup = () => { clearInterval(beats); inbox.close(); };
82
+ stdio.onclose = cleanup;
83
+ const { endProbeSessionOnStdinEnd } = await import('./probe-session.js');
84
+ endProbeSessionOnStdinEnd({ env: process.env, stdin: process.stdin, client: preflightClient, instanceToken, cleanup, exit: (code) => process.exit(code) });
82
85
  }
83
86
  return;
84
87
  }
@@ -0,0 +1,20 @@
1
+ /** Must stay equal to ProbeSessionEnv in cli/internal/cmd/connect_probe.go (drift test). */
2
+ export const PROBE_SESSION_ENV = 'ORBOTO_PROBE_SESSION';
3
+ /** Answers false outside a probe; inside one, ends the session for good and exits once stdin ends. */
4
+ export function endProbeSessionOnStdinEnd(opts) {
5
+ if (opts.env[PROBE_SESSION_ENV] !== '1')
6
+ return false;
7
+ let ended = false;
8
+ const end = () => {
9
+ if (ended)
10
+ return;
11
+ ended = true;
12
+ opts.cleanup();
13
+ void opts.client.post('/v1/agent/end-session', {}, { instanceToken: opts.instanceToken })
14
+ .catch(() => undefined)
15
+ .finally(() => opts.exit(0));
16
+ };
17
+ opts.stdin.once('end', end);
18
+ opts.stdin.once('close', end);
19
+ return true;
20
+ }
@@ -0,0 +1,36 @@
1
+ /** ORB-2263 - the probe proxy ends its own session on stdin end, a real session never does. */
2
+ import { EventEmitter } from 'node:events';
3
+ import { readFileSync } from 'node:fs';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { endProbeSessionOnStdinEnd, PROBE_SESSION_ENV } from './probe-session.js';
6
+ function harness(env) {
7
+ const stdin = new EventEmitter();
8
+ const calls = [];
9
+ const client = { post: async (path, body, opts) => { calls.push({ path, body, instanceToken: opts?.instanceToken }); return undefined; } };
10
+ const cleanup = vi.fn();
11
+ const exit = vi.fn();
12
+ const armed = endProbeSessionOnStdinEnd({ env, stdin, client, instanceToken: 'agent-0123456789ab', cleanup, exit });
13
+ return { stdin, calls, cleanup, exit, armed };
14
+ }
15
+ describe('ORB-2263 - probe session end', () => {
16
+ it('names the variable the CLI sets for the probe child', () => {
17
+ const go = readFileSync(new URL('../../../cli/internal/cmd/connect_probe.go', import.meta.url), 'utf8');
18
+ expect(go).toContain(`const ProbeSessionEnv = "${PROBE_SESSION_ENV}"`);
19
+ });
20
+ it('ends the session for good and exits when the probe stdin ends', async () => {
21
+ const h = harness({ [PROBE_SESSION_ENV]: '1' });
22
+ expect(h.armed).toBe(true);
23
+ h.stdin.emit('end');
24
+ h.stdin.emit('close');
25
+ await vi.waitFor(() => expect(h.exit).toHaveBeenCalledWith(0));
26
+ expect(h.cleanup).toHaveBeenCalledTimes(1);
27
+ expect(h.calls).toEqual([{ path: '/v1/agent/end-session', body: {}, instanceToken: 'agent-0123456789ab' }]);
28
+ });
29
+ it('leaves a real session alone so a resume keeps its row', () => {
30
+ const h = harness({});
31
+ expect(h.armed).toBe(false);
32
+ h.stdin.emit('end');
33
+ expect(h.calls).toEqual([]);
34
+ expect(h.exit).not.toHaveBeenCalled();
35
+ });
36
+ });
@@ -82,9 +82,11 @@ export function makeAgentPresenceHandler(client) {
82
82
  };
83
83
  };
84
84
  }
85
+ /** ORB-2263 - mirror of AgentNotifyRoutedToSchema in @orboto/shared-schema (drift test in agent-coordination.test.ts). */
86
+ export const NOTIFY_ROUTED_TO = ['session', 'scope', 'account'];
85
87
  export const agentNotifyToolConfig = {
86
88
  title: 'Notify another agent / user',
87
- description: 'Message a user (bot or human) by email; lands in their inbox (orboto_messages), for humans in-app. kind: request|error = work inside the scope the recipient holds (request needs project or toSessionRef + outcome); info = information; complete = result (needs outcome). payload: free-form JSON; threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it (ORB-2136).',
89
+ description: 'Message a user (bot or human) by email; lands in their inbox (orboto_messages), for humans in-app. kind: request|error = work inside the scope the recipient holds (request needs project or toSessionRef + outcome); info = information; complete = result (needs outcome). payload: free-form JSON; threadId links a reply to the message it answers. toSessionRef (instance short id, session ref or id) reaches ONE session of the account; project scopes an account message to sessions declared on it (ORB-2136). The answer names routedTo (session, scope or account); routingNote "no session declared KEY" means nothing wakes - escalate instead of assuming delivery (ORB-2263).',
88
90
  inputSchema: z.object({
89
91
  targetEmail: z.string().email(),
90
92
  kind: z.enum(['info', 'request', 'complete', 'error']).default('info'),
@@ -92,14 +94,16 @@ export const agentNotifyToolConfig = {
92
94
  outcome: z.string().min(10).max(1000).optional(),
93
95
  payload: z.record(z.string(), z.unknown()).optional(),
94
96
  threadId: z.string().uuid().optional(),
95
- project: z.string().min(1).max(64).optional().describe('Project key or UUID: scope the message to the recipient session working that project.'),
96
- senderRef: z.string().min(1).max(128).optional().describe('Sender-session ref for self-echo exclusion; defaults to this MCP session.'),
97
+ project: z.string().min(1).max(64).optional().describe('Project key or UUID.'),
98
+ senderRef: z.string().min(1).max(128).optional().describe('Sender-session ref; default: this MCP session.'),
97
99
  toSessionRef: z.string().min(1).max(200).optional(),
98
100
  }).shape,
99
101
  outputSchema: z.object({
100
102
  ok: z.literal(true),
101
103
  messageId: z.string().uuid(),
102
104
  toSessionId: z.string().uuid().nullable(),
105
+ routedTo: z.enum(NOTIFY_ROUTED_TO),
106
+ routingNote: z.string().optional(),
103
107
  }).shape,
104
108
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
105
109
  };
@@ -146,9 +150,11 @@ export function makeAgentNotifyHandler(client) {
146
150
  const senderRef = mcpInstanceToken(args.senderRef, extra);
147
151
  const res = await client.post('/v1/agent/notify', { ...args, senderRef }, { instanceToken: mcpInstanceToken(undefined, extra) });
148
152
  const target = res.toSessionId ? `${args.targetEmail} session ${res.toSessionId.slice(0, 8)}` : args.targetEmail;
153
+ const routedTo = res.routedTo ?? (res.toSessionId ? 'session' : 'account');
154
+ const note = res.routingNote ? ` - ${res.routingNote}` : '';
149
155
  return {
150
- content: [{ type: 'text', text: `notified ${target} (message ${res.messageId} - delivered live if connected, waits in their inbox otherwise)` }],
151
- structuredContent: { ok: true, messageId: res.messageId, toSessionId: res.toSessionId ?? null },
156
+ content: [{ type: 'text', text: `notified ${target} (message ${res.messageId}, routed to ${routedTo}${note})` }],
157
+ structuredContent: { ok: true, messageId: res.messageId, toSessionId: res.toSessionId ?? null, routedTo, ...(res.routingNote ? { routingNote: res.routingNote } : {}) },
152
158
  };
153
159
  };
154
160
  }
@@ -7,8 +7,9 @@
7
7
  * agent-sessions.test.ts.
8
8
  */
9
9
  import { afterEach, describe, expect, it, vi } from 'vitest';
10
+ import { readFileSync } from 'node:fs';
10
11
  import { OrbotoClient } from '../orboto-client.js';
11
- import { makeAgentHeartbeatHandler, makeAgentPresenceHandler, makeAgentNotifyHandler, } from './agent-coordination.js';
12
+ import { NOTIFY_ROUTED_TO, agentNotifyToolConfig, makeAgentHeartbeatHandler, makeAgentPresenceHandler, makeAgentNotifyHandler, } from './agent-coordination.js';
12
13
  import { makeAgentMessagesHandler } from './agent-messages.js';
13
14
  afterEach(() => { vi.restoreAllMocks(); });
14
15
  function stub(responses) {
@@ -180,6 +181,27 @@ describe('orboto_agent_notify', () => {
180
181
  expect(capturedBody[1].senderRef).toBe('runner:custom');
181
182
  });
182
183
  });
184
+ describe('orboto_agent_notify routing (ORB-2263)', () => {
185
+ it('mirrors the routedTo values of the shared schema', () => {
186
+ const shared = readFileSync(new URL('../../../../packages/shared-schema/src/agent-message-kinds.ts', import.meta.url), 'utf8');
187
+ const match = shared.match(/AgentNotifyRoutedToSchema = z\.enum\(\[([^\]]+)\]\)/);
188
+ expect(match).not.toBeNull();
189
+ const values = match[1].split(',').map((v) => v.trim().replace(/'/g, ''));
190
+ expect(values).toEqual([...NOTIFY_ROUTED_TO]);
191
+ expect(agentNotifyToolConfig.description).toContain('routedTo');
192
+ expect(agentNotifyToolConfig.description).toContain('no session declared KEY');
193
+ });
194
+ it('answers routedTo account with the routing note so the sender escalates', async () => {
195
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
196
+ ok: true, status: 200, statusText: 'OK',
197
+ json: async () => ({ ok: true, messageId: '00000000-0000-4000-8000-000000000000', toSessionId: null, routedTo: 'account', routingNote: 'no session declared HIVE - the message waits in the account inbox and wakes no session; escalate or address a session instead of assuming delivery' }),
198
+ text: async () => '',
199
+ }));
200
+ const result = await makeAgentNotifyHandler(client)({ targetEmail: 'bot@example.org', kind: 'request', subject: 'build HIVE-720', outcome: 'HIVE-720 is built and in review', project: 'HIVE' });
201
+ expect(result.structuredContent).toMatchObject({ routedTo: 'account', routingNote: expect.stringContaining('no session declared HIVE') });
202
+ expect(result.content[0].text).toContain('routed to account - no session declared HIVE');
203
+ });
204
+ });
183
205
  describe('orboto_messages (ORB-1742 self-echo exclusion)', () => {
184
206
  it('fetches with excludeRef = the session ref by default; includeOwnSends drops the filter', async () => {
185
207
  const urls = [];
@@ -4,7 +4,7 @@
4
4
  import { z } from 'zod';
5
5
  export const secretScanToolConfig = {
6
6
  title: 'Secret scan',
7
- description: 'Admin sweep for stored secret-shaped values (ORB-2229); see skill reference admin.md "Secret scan".',
7
+ description: 'Admin sweep for stored secret-shaped values (ORB-2229); see skill reference admin.md "Secret scan". Each offer prints a findingId (the credential kind it would become); the move into the vault is not a tool: send that id to POST /admin/content/secret-scan/move through orboto_api_call, or run `orboto admin secret-scan move <findingId>` in the CLI.',
8
8
  inputSchema: z.object({
9
9
  redact: z.boolean().optional().describe('Replace found values, purge history (default false).'),
10
10
  limit: z.number().int().min(1).max(1000).optional().describe('Max hits (default 200).'),
@@ -31,6 +31,9 @@ export function makeSecretScanHandler(client) {
31
31
  for (const f of hit.findings) {
32
32
  lines.push(` ${f.class} in ${hit.field} at line ${f.line}, column ${f.column} (${f.preview}, ${f.length} chars)`);
33
33
  }
34
+ for (const m of hit.moves ?? []) {
35
+ lines.push(` move as ${m.kind}: findingId ${m.findingId} (post /admin/content/secret-scan/move with a key)`);
36
+ }
34
37
  }
35
38
  lines.push(report.redacted > 0
36
39
  ? `Redacted ${report.redacted} rows; rotate every credential listed above.`
@@ -46,7 +49,7 @@ export function makeSecretScanHandler(client) {
46
49
  hits: report.hits.map((h) => ({
47
50
  entity: h.entity, id: h.id, label: h.label, field: h.field,
48
51
  projectKey: h.projectKey, spaceVisibility: h.spaceVisibility, webUrl: h.webUrl,
49
- classes: h.findings.map((f) => f.class), redacted: h.redacted,
52
+ classes: h.findings.map((f) => f.class), redacted: h.redacted, moves: h.moves ?? [],
50
53
  })),
51
54
  },
52
55
  };
@@ -0,0 +1,30 @@
1
+ /** ORB-2229, ORB-2261 - orboto_secret_scan prints each hit masked and the finding id of every move offer. */
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import { OrbotoClient } from '../orboto-client.js';
4
+ import { makeSecretScanHandler } from './secret-scan.js';
5
+ afterEach(() => { vi.restoreAllMocks(); });
6
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.org', apiKey: 'orb_x' });
7
+ describe('orboto_secret_scan', () => {
8
+ it('lists the move offers with their finding ids and never a value', async () => {
9
+ const calls = [];
10
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
11
+ calls.push({ url: url.toString(), body: init?.body ? JSON.parse(init.body) : undefined });
12
+ return {
13
+ ok: true, status: 200, statusText: 'OK', text: async () => '',
14
+ json: async () => ({
15
+ scannedAt: '2026-09-29T10:00:00.000Z', scanned: { doc: 1 }, redacted: 0, nextCursor: null, truncated: false,
16
+ hits: [{
17
+ entity: 'doc', id: 'd1', label: 'ACME-D38', field: 'content', projectKey: 'ACME', spaceVisibility: 'workspace', webUrl: null,
18
+ findings: [{ class: 'labelled_value', line: 3, column: 11, preview: 'Hk***', length: 12 }], redacted: false,
19
+ moves: [{ findingId: 'WyJkb2MiXQ', kind: 'login', findingIndexes: [0] }],
20
+ }],
21
+ }),
22
+ };
23
+ });
24
+ const res = await makeSecretScanHandler(client)({});
25
+ expect(calls[0].url).toContain('/admin/content/secret-scan');
26
+ const text = res.content[0].text;
27
+ expect(text).toContain('move as login: findingId WyJkb2MiXQ');
28
+ expect(res.structuredContent).toMatchObject({ hits: [{ moves: [{ findingId: 'WyJkb2MiXQ', kind: 'login' }] }] });
29
+ });
30
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.210.1",
3
+ "version": "0.211.1",
4
4
  "description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
5
5
  "license": "MIT",
6
6
  "type": "module",