switchroom 0.13.53 → 0.13.55

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.
@@ -265,6 +265,7 @@ import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
265
265
 
266
266
  import { createIpcServer, type IpcClient, type IpcServer } from './ipc-server.js'
267
267
  import { handleRequestDriveApproval } from './drive-write-approval.js'
268
+ import { handleRequestMs365Approval } from './ms365-write-approval.js'
268
269
  import { buildDiffPreviewCard } from './diff-preview-card.js'
269
270
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
270
271
  import { createInboundSpool } from './inbound-spool.js'
@@ -2999,8 +3000,18 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
2999
3000
  return
3000
3001
  }
3001
3002
 
3003
+ // PR4b emitter sweep: supergroup-mode agents route operator-event
3004
+ // cards (boot crash, restart-watchdog, model-unavailable, etc.) into
3005
+ // the alerts/admin alias topic instead of chat-root. The
3006
+ // 'compact-watchdog' kind covers all system-initiated notifications
3007
+ // — alerts alias → default_topic_id fallback (see router contract).
3008
+ // Fleet-shared / DM agents see `undefined` → no `message_thread_id`
3009
+ // is added to the broadcast opts → behavior unchanged.
3010
+ const opEventTopic = resolveAgentOutboundTopic({ kind: 'compact-watchdog' })
3011
+
3002
3012
  process.stderr.write(
3003
- `telegram gateway: operator-event posting agent=${agent} kind=${kind} to ${access.allowFrom.length} chat(s)\n`,
3013
+ `telegram gateway: operator-event posting agent=${agent} kind=${kind} to ${access.allowFrom.length} chat(s)` +
3014
+ (opEventTopic != null ? ` topic=${opEventTopic}` : '') + '\n',
3004
3015
  )
3005
3016
  for (const chat_id of access.allowFrom) {
3006
3017
  // grammy's Other<...> opts type is generated and stricter than our
@@ -3008,8 +3019,12 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
3008
3019
  const opts = {
3009
3020
  parse_mode: 'HTML' as const,
3010
3021
  ...(renderedKeyboard ? { reply_markup: renderedKeyboard } : {}),
3022
+ ...(opEventTopic != null ? { message_thread_id: opEventTopic } : {}),
3011
3023
  }
3012
- // allow-raw-bot-api: operator-event broadcast loop; opts has no message_thread_id
3024
+ // Comment-only context for the reader; the lint marker on the
3025
+ // very next line is what unlocks the raw bot.api call.
3026
+ // Opts now includes message_thread_id when supergroup mode is on.
3027
+ // allow-raw-bot-api: operator-event broadcast loop; topic-aware opts
3013
3028
  void bot.api.sendMessage(chat_id, renderedText, opts as never).catch(e => {
3014
3029
  process.stderr.write(
3015
3030
  `telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}\n`,
@@ -4009,13 +4024,36 @@ const ipcServer: IpcServer = createIpcServer({
4009
4024
  .row()
4010
4025
  .text(`🔁 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`)
4011
4026
  }
4027
+ // PR4b emitter sweep — supergroup-mode permission card routing.
4028
+ // Per CPO #3 the design is "turn-initiated requests follow the
4029
+ // conversation topic; background requests go to admin alias."
4030
+ // Permission requests come from the bridge mid-tool-use, so they
4031
+ // are always turn-initiated in practice — the currently active
4032
+ // turn's sessionThreadId is the originating topic. Fall back to
4033
+ // admin alias when no active turn (cron / background path).
4034
+ // Fleet-shared / DM agents see `undefined` → no
4035
+ // `message_thread_id` is added → behavior unchanged.
4036
+ // currentTurn is the singleton "claude is currently on this turn"
4037
+ // pointer — per Framing 1 / PR3b scope-discovery, claude
4038
+ // serializes so there's exactly one (or zero) active turn at any
4039
+ // moment. When set, the permission request is in-flight for that
4040
+ // turn and follows the originating topic.
4041
+ const activeTurn = currentTurn
4042
+ const permTopic = resolveAgentOutboundTopic({
4043
+ kind: 'permission',
4044
+ turnInitiated: activeTurn != null,
4045
+ originThreadId: activeTurn?.sessionThreadId,
4046
+ })
4012
4047
  for (const chat_id of access.allowFrom) {
4013
4048
  // parse_mode=HTML pairs with formatPermissionCardBody (#1790)
4014
4049
  // so the <b>/<i> tags render as formatting.
4015
- // allow-raw-bot-api: permission-request keyboard fan-out; reply_markup + parse_mode only, no thread_id
4050
+ // PR4b emitter sweep opts now optionally carries
4051
+ // message_thread_id when supergroup mode is on.
4052
+ // allow-raw-bot-api: permission-request keyboard fan-out; topic-aware opts
4016
4053
  void bot.api.sendMessage(chat_id, text, {
4017
4054
  parse_mode: 'HTML',
4018
4055
  reply_markup: keyboard,
4056
+ ...(permTopic != null ? { message_thread_id: permTopic } : {}),
4019
4057
  }).catch(e => {
4020
4058
  process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
4021
4059
  })
@@ -4138,7 +4176,18 @@ const ipcServer: IpcServer = createIpcServer({
4138
4176
  // routing surface (see how /folders posts) — this picks the
4139
4177
  // DM path which is the common case; group-routing follow-up
4140
4178
  // can extend this.
4141
- return { chatId: operator }
4179
+ // PR4b emitter sweep — supergroup-mode adds an explicit
4180
+ // topic. Drive approval cards follow the originating turn
4181
+ // (operator-initiated tool call), admin alias fallback.
4182
+ const activeTurn = currentTurn
4183
+ const driveTopic = resolveAgentOutboundTopic({
4184
+ kind: 'hostd-approval',
4185
+ originThreadId: activeTurn?.sessionThreadId,
4186
+ })
4187
+ return {
4188
+ chatId: operator,
4189
+ ...(driveTopic != null ? { threadId: driveTopic } : {}),
4190
+ }
4142
4191
  },
4143
4192
  registerApproval: async (args) => {
4144
4193
  const r = await kernelApprovalRequest({
@@ -4186,6 +4235,76 @@ const ipcServer: IpcServer = createIpcServer({
4186
4235
  })
4187
4236
  },
4188
4237
 
4238
+ /**
4239
+ * RFC #1873 §8 — MS-365 write approval card. Mirrors
4240
+ * onRequestDriveApproval but with the weak-metadata v1 preview shape
4241
+ * (no diff-preview — just plain text card).
4242
+ */
4243
+ async onRequestMs365Approval(client: IpcClient, msg) {
4244
+ await handleRequestMs365Approval(client, msg, {
4245
+ agentName: getMyAgentName(),
4246
+ loadAllowFrom: () => loadAccess().allowFrom,
4247
+ loadTargetChat: () => {
4248
+ const access = loadAccess()
4249
+ const operator = access.allowFrom[0]
4250
+ if (operator === undefined) return null
4251
+ // PR4b emitter sweep — MS365 write approval cards route into
4252
+ // the originating turn's topic in supergroup mode, admin
4253
+ // alias fallback for background cases. Same shape as hostd /
4254
+ // drive approvals below.
4255
+ const activeTurn = currentTurn
4256
+ const ms365Topic = resolveAgentOutboundTopic({
4257
+ kind: 'hostd-approval',
4258
+ originThreadId: activeTurn?.sessionThreadId,
4259
+ })
4260
+ return {
4261
+ chatId: operator,
4262
+ ...(ms365Topic != null ? { threadId: ms365Topic } : {}),
4263
+ }
4264
+ },
4265
+ registerApproval: async (args) => {
4266
+ const r = await kernelApprovalRequest({
4267
+ agent_unit: args.agent_unit,
4268
+ scope: args.scope,
4269
+ action: args.action,
4270
+ approver_set: args.approver_set,
4271
+ why: args.why,
4272
+ ttl_ms: args.ttl_ms,
4273
+ })
4274
+ if (r === null || r.state === 'rate_limited') return null
4275
+ return {
4276
+ request_id: r.request_id,
4277
+ expires_at_ms: r.expires_at,
4278
+ }
4279
+ },
4280
+ postCard: async (args) => {
4281
+ try {
4282
+ const sent = await robustApiCall(
4283
+ () =>
4284
+ bot.api.sendMessage(args.chatId, args.text, {
4285
+ ...(args.threadId !== undefined
4286
+ ? { message_thread_id: args.threadId }
4287
+ : {}),
4288
+ reply_markup: args.replyMarkup as never,
4289
+ }),
4290
+ {
4291
+ chat_id: String(args.chatId),
4292
+ verb: 'ms365-approval-card',
4293
+ ...(args.threadId !== undefined ? { threadId: args.threadId } : {}),
4294
+ },
4295
+ )
4296
+ return { messageId: (sent as { message_id: number }).message_id }
4297
+ } catch (err) {
4298
+ process.stderr.write(
4299
+ `telegram gateway: ms365-approval postCard failed: ${(err as Error).message}\n`,
4300
+ )
4301
+ return null
4302
+ }
4303
+ },
4304
+ log: (m) => process.stderr.write(`telegram gateway: ms365-approval — ${m}\n`),
4305
+ })
4306
+ },
4307
+
4189
4308
  /**
4190
4309
  * #1623 — hostd-initiated config-edit approval card. hostd posts a
4191
4310
  * `request_config_approval` message; we render the card via
@@ -4206,7 +4325,21 @@ const ipcServer: IpcServer = createIpcServer({
4206
4325
  const access = loadAccess()
4207
4326
  const operator = access.allowFrom[0]
4208
4327
  if (operator === undefined) return null
4209
- return { chatId: operator }
4328
+ // PR4b emitter sweep — hostd config-approval cards are
4329
+ // operator-initiated (someone typed /update apply or tapped
4330
+ // a button). Follow the originating turn when there is one;
4331
+ // admin alias fallback in supergroup mode otherwise. Helper
4332
+ // returns undefined for non-supergroup agents → behavior
4333
+ // unchanged.
4334
+ const activeTurn = currentTurn
4335
+ const cfgTopic = resolveAgentOutboundTopic({
4336
+ kind: 'hostd-approval',
4337
+ originThreadId: activeTurn?.sessionThreadId,
4338
+ })
4339
+ return {
4340
+ chatId: operator,
4341
+ ...(cfgTopic != null ? { threadId: cfgTopic } : {}),
4342
+ }
4210
4343
  },
4211
4344
  buildKeyboard: (requestId) =>
4212
4345
  new InlineKeyboard()
@@ -9156,10 +9289,26 @@ type SwitchroomReplyMarkup =
9156
9289
  async function switchroomReply(
9157
9290
  ctx: Context,
9158
9291
  text: string,
9159
- options: { html?: boolean; reply_markup?: SwitchroomReplyMarkup } = {},
9292
+ options: {
9293
+ html?: boolean
9294
+ reply_markup?: SwitchroomReplyMarkup
9295
+ // PR5 — supergroup-mode slash-command smart split (CPO #4).
9296
+ // 'query' (default semantics) follows the originating topic;
9297
+ // 'mutation' and 'heavy' route to admin alias in supergroup mode;
9298
+ // omitted/undefined preserves legacy in-place reply behavior.
9299
+ classification?: 'query' | 'mutation' | 'heavy'
9300
+ } = {},
9160
9301
  ): Promise<void> {
9161
9302
  const chatId = String(ctx.chat!.id)
9162
- const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
9303
+ const baseThreadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
9304
+ // PR5 — when caller flagged the reply as mutation/heavy AND we're
9305
+ // in supergroup mode, override the in-place thread with the
9306
+ // admin-alias topic. For 'query' (or non-supergroup), routedOpts
9307
+ // is `{}` and baseThreadId wins → behavior unchanged.
9308
+ const routedOpts = options.classification
9309
+ ? slashCommandReplyOpts(ctx, options.classification)
9310
+ : {}
9311
+ const threadId = routedOpts.message_thread_id ?? baseThreadId
9163
9312
  await ctx.reply(text, {
9164
9313
  ...(threadId != null ? { message_thread_id: threadId } : {}),
9165
9314
  ...(options.html ? { parse_mode: 'HTML' as const, link_preview_options: { is_disabled: true } } : {}),
@@ -9469,6 +9618,53 @@ function resolveSystemdRunPath(): string | null {
9469
9618
  return _systemdRunPath
9470
9619
  }
9471
9620
 
9621
+ /**
9622
+ * PR5 — supergroup-mode slash-command smart split (CPO #4 design).
9623
+ *
9624
+ * Classifies a slash-command response by event class and returns the
9625
+ * `message_thread_id` an outbound reply should target in supergroup
9626
+ * mode, or `undefined` when the agent isn't in supergroup-owned mode
9627
+ * (caller falls through to grammY's default `ctx.reply` which
9628
+ * routes to the originating topic).
9629
+ *
9630
+ * Three classes:
9631
+ * - `query` (`/help`, `/status`, etc.) — follows the originating
9632
+ * topic, identical to default `ctx.reply` behaviour.
9633
+ * - `mutation` (`/restart`, `/update apply`) — admin alias.
9634
+ * - `heavy` (`/logs`, `/audit`, `/upgradestatus`, `/memory <q>`)
9635
+ * — admin alias (operational separation per CPO #4).
9636
+ *
9637
+ * Callers spread the returned topic onto their send opts:
9638
+ *
9639
+ * await ctx.reply(text, {
9640
+ * ...opts,
9641
+ * ...slashCommandReplyOpts(ctx, 'heavy'),
9642
+ * })
9643
+ *
9644
+ * Returns `{}` when no override is needed (non-supergroup, or
9645
+ * helper resolved to the originating topic). Returns
9646
+ * `{ message_thread_id: N }` otherwise.
9647
+ */
9648
+ function slashCommandReplyOpts(
9649
+ ctx: Context,
9650
+ classification: 'query' | 'mutation' | 'heavy',
9651
+ ): { message_thread_id?: number } {
9652
+ const originThreadId = ctx.message?.message_thread_id
9653
+ const event =
9654
+ classification === 'query'
9655
+ ? ({ kind: 'command-query', originThreadId } as const)
9656
+ : classification === 'mutation'
9657
+ ? ({ kind: 'command-mutation' } as const)
9658
+ : ({ kind: 'command-heavy' } as const)
9659
+ const target = resolveAgentOutboundTopic(event)
9660
+ if (target == null) return {}
9661
+ // Avoid the spurious explicit thread_id when it would equal the
9662
+ // implicit-reply destination — keeps the wire identical to default
9663
+ // ctx.reply for the query class in supergroup mode.
9664
+ if (target === originThreadId) return {}
9665
+ return { message_thread_id: target }
9666
+ }
9667
+
9472
9668
  /**
9473
9669
  * Detect whether `docker` is callable from this process — required by
9474
9670
  * `switchroom update`'s pull-images and recreate-containers steps.
@@ -9920,18 +10116,27 @@ async function dispatchShortVerbViaHostd(
9920
10116
  )
9921
10117
  }
9922
10118
 
9923
- async function runSwitchroomCommand(ctx: Context, args: string[], label: string): Promise<void> {
10119
+ async function runSwitchroomCommand(
10120
+ ctx: Context,
10121
+ args: string[],
10122
+ label: string,
10123
+ // PR5 — supergroup-mode classification threaded through to
10124
+ // switchroomReply. Default 'query' so existing callers (most
10125
+ // commands) preserve in-place reply behavior. /logs /audit
10126
+ // /upgradestatus /memory pass 'heavy' to route to admin alias.
10127
+ classification: 'query' | 'mutation' | 'heavy' = 'query',
10128
+ ): Promise<void> {
9924
10129
  try {
9925
10130
  const output = stripAnsi(switchroomExec(args))
9926
10131
  const formatted = formatSwitchroomOutput(output)
9927
- if (formatted) { await switchroomReply(ctx, preBlock(formatted), { html: true }) }
9928
- else { await switchroomReply(ctx, `${label}: done (no output)`) }
10132
+ if (formatted) { await switchroomReply(ctx, preBlock(formatted), { html: true, classification }) }
10133
+ else { await switchroomReply(ctx, `${label}: done (no output)`, { classification }) }
9929
10134
  } catch (err: unknown) {
9930
10135
  const error = err as { status?: number; stderr?: string; message?: string }
9931
- if (error.message?.includes('ENOENT')) { await switchroomReply(ctx, 'switchroom CLI not found.', { html: true }); return }
9932
- if (error.message?.includes('ETIMEDOUT') || error.message?.includes('timed out')) { await switchroomReply(ctx, `${label}: timed out`); return }
10136
+ if (error.message?.includes('ENOENT')) { await switchroomReply(ctx, 'switchroom CLI not found.', { html: true, classification }); return }
10137
+ if (error.message?.includes('ETIMEDOUT') || error.message?.includes('timed out')) { await switchroomReply(ctx, `${label}: timed out`, { classification }); return }
9933
10138
  const detail = stripAnsi(error.stderr?.trim() || error.message || 'unknown error')
9934
- await switchroomReply(ctx, `<b>${escapeHtmlForTg(label)} failed:</b>\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true })
10139
+ await switchroomReply(ctx, `<b>${escapeHtmlForTg(label)} failed:</b>\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true, classification })
9935
10140
  }
9936
10141
  }
9937
10142
 
@@ -10896,7 +11101,9 @@ bot.command('update', async ctx => {
10896
11101
  // /upgrade-status. The /upgrade alias just below redirects.)
10897
11102
  bot.command('upgradestatus', async ctx => {
10898
11103
  if (!isAuthorizedSender(ctx)) return
10899
- await runSwitchroomCommand(ctx, ['update', '--status'], 'update --status')
11104
+ // PR5 heavy-output: route to admin alias in supergroup mode
11105
+ // (CPO #4). Fleet-shared / DM agents fall through to in-place reply.
11106
+ await runSwitchroomCommand(ctx, ['update', '--status'], 'update --status', 'heavy')
10900
11107
  })
10901
11108
  // Alias with hyphen — Grammy doesn't allow hyphens in command names
10902
11109
  // (Telegram's slash-command grammar excludes them) but operators are
@@ -10984,7 +11191,8 @@ bot.command('audit', async ctx => {
10984
11191
  )
10985
11192
  return
10986
11193
  }
10987
- await runSwitchroomCommand(ctx, argv, `hostd audit${argv.length > 2 ? ' …' : ''}`)
11194
+ // PR5 heavy-output admin alias in supergroup mode (CPO #4).
11195
+ await runSwitchroomCommand(ctx, argv, `hostd audit${argv.length > 2 ? ' …' : ''}`, 'heavy')
10988
11196
  })
10989
11197
 
10990
11198
  // ─── /approve, /deny, /pending ────────────────────────────────────────────
@@ -13766,14 +13974,16 @@ bot.command('logs', async ctx => {
13766
13974
  try { assertSafeAgentName(name) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
13767
13975
  const lines = linesArg ? parseInt(linesArg, 10) : 20
13768
13976
  const lineCount = isNaN(lines) || lines < 1 ? 20 : Math.min(lines, 200)
13769
- await runSwitchroomCommand(ctx, ['agent', 'logs', name, '--lines', String(lineCount)], `logs ${name}`)
13977
+ // PR5 heavy-output admin alias in supergroup mode (CPO #4).
13978
+ await runSwitchroomCommand(ctx, ['agent', 'logs', name, '--lines', String(lineCount)], `logs ${name}`, 'heavy')
13770
13979
  })
13771
13980
 
13772
13981
  bot.command('memory', async ctx => {
13773
13982
  if (!isAuthorizedSender(ctx)) return
13774
13983
  const query = ctx.match?.trim()
13775
13984
  if (!query) { await switchroomReply(ctx, 'Usage: /memory <search query>'); return }
13776
- await runSwitchroomCommand(ctx, ['memory', 'search', query], 'memory search')
13985
+ // PR5 heavy-output admin alias in supergroup mode (CPO #4).
13986
+ await runSwitchroomCommand(ctx, ['memory', 'search', query], 'memory search', 'heavy')
13777
13987
  })
13778
13988
 
13779
13989
  bot.command('issues', async ctx => {
@@ -127,6 +127,7 @@ export type GatewayToClient =
127
127
  | ToolCallResult
128
128
  | ScheduleRestartResult
129
129
  | DriveApprovalPostedEvent
130
+ | Ms365ApprovalPostedEvent
130
131
  | ConfigApprovalResolvedEvent;
131
132
 
132
133
  // === Bridge (Client) -> Gateway messages ===
@@ -306,6 +307,41 @@ export interface RequestDriveApprovalMessage {
306
307
  ttlMs?: number;
307
308
  }
308
309
 
310
+ /**
311
+ * RFC #1873 §8 — Microsoft 365 write approval (PR 4).
312
+ *
313
+ * Sent by the `ms-365-write-pretool` PreToolUse hook when softeria
314
+ * tries a gated write tool (OneDrive upload, calendar/mail mutations).
315
+ * Same shape as `request_drive_approval` but carries the weak-metadata
316
+ * v1 preview shape (file path / id / size delta / deep link / agent
317
+ * rationale) rather than Google's full DiffPreviewInput. Structural-
318
+ * diff preview is RFC §8 v1.5.
319
+ */
320
+ export interface RequestMs365ApprovalMessage {
321
+ type: "request_ms365_approval";
322
+ correlationId: string;
323
+ agentName: string;
324
+ /**
325
+ * Weak-metadata payload — see Ms365WritePreview in
326
+ * `telegram-plugin/gateway/ms365-write-approval.ts`. Opaque on the
327
+ * wire; gateway validates via the handler.
328
+ */
329
+ preview: Record<string, unknown>;
330
+ ttlMs?: number;
331
+ }
332
+
333
+ /**
334
+ * Gateway → hook response after card is posted (or fails).
335
+ */
336
+ export interface Ms365ApprovalPostedEvent {
337
+ type: "ms365_approval_posted";
338
+ correlationId: string;
339
+ ok: boolean;
340
+ requestId?: string;
341
+ expiresAtMs?: number;
342
+ reason?: string;
343
+ }
344
+
309
345
  /**
310
346
  * hostd config-edit approval — sent by hostd to the caller agent's
311
347
  * gateway to render an approval card with the full unified diff in
@@ -367,5 +403,6 @@ export type ClientToGateway =
367
403
  | UpdatePlaceholderMessage
368
404
  | InjectInboundMessage
369
405
  | RequestDriveApprovalMessage
406
+ | RequestMs365ApprovalMessage
370
407
  | RequestConfigApprovalMessage
371
408
  | RequestConfigFinalizeMessage;
@@ -11,6 +11,7 @@ import type {
11
11
  RequestConfigApprovalMessage,
12
12
  RequestConfigFinalizeMessage,
13
13
  RequestDriveApprovalMessage,
14
+ RequestMs365ApprovalMessage,
14
15
  ScheduleRestartMessage,
15
16
  SessionEventForward,
16
17
  ToolCallMessage,
@@ -55,6 +56,15 @@ export interface IpcServerOptions {
55
56
  client: IpcClient,
56
57
  msg: RequestDriveApprovalMessage,
57
58
  ) => Promise<void>;
59
+ /**
60
+ * RFC #1873 §8 — Microsoft 365 write approval (PR 4). Same shape as
61
+ * onRequestDriveApproval but for softeria write tools. Optional;
62
+ * gateways without M365 integration ignore.
63
+ */
64
+ onRequestMs365Approval?: (
65
+ client: IpcClient,
66
+ msg: RequestMs365ApprovalMessage,
67
+ ) => Promise<void>;
58
68
  /**
59
69
  * #1623 — hostd-initiated config-edit approval card. Handler posts
60
70
  * a Telegram card with [✅ Approve] [🚫 Deny] buttons, tracks the
@@ -273,6 +283,22 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
273
283
  || (m.ttlMs as number) < 0)) return false;
274
284
  return true;
275
285
  }
286
+ case "request_ms365_approval": {
287
+ // RFC #1873 §8 PR 4. Same wire-shape gate as Drive — gateway
288
+ // routes on the outer fields; the inner `preview` is opaque
289
+ // and re-validated by `validateMs365Preview()` downstream.
290
+ if (typeof m.correlationId !== "string"
291
+ || (m.correlationId as string).length === 0
292
+ || (m.correlationId as string).length > 64) return false;
293
+ if (typeof m.agentName !== "string"
294
+ || !AGENT_NAME_RE.test(m.agentName as string)) return false;
295
+ if (typeof m.preview !== "object" || m.preview === null) return false;
296
+ if (m.ttlMs !== undefined
297
+ && (typeof m.ttlMs !== "number"
298
+ || !Number.isFinite(m.ttlMs)
299
+ || (m.ttlMs as number) < 0)) return false;
300
+ return true;
301
+ }
276
302
  default:
277
303
  return false;
278
304
  }
@@ -292,6 +318,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
292
318
  onPtyPartial,
293
319
  onInjectInbound,
294
320
  onRequestDriveApproval,
321
+ onRequestMs365Approval,
295
322
  onRequestConfigApproval,
296
323
  onRequestConfigFinalize,
297
324
  log = () => {},
@@ -436,6 +463,38 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
436
463
  }
437
464
  }
438
465
  break;
466
+ case "request_ms365_approval":
467
+ if (onRequestMs365Approval) {
468
+ onRequestMs365Approval(client, msg as RequestMs365ApprovalMessage).catch(
469
+ (err) => {
470
+ log(
471
+ `request_ms365_approval handler threw (client=${client.id}): ${(err as Error).message}`,
472
+ );
473
+ try {
474
+ client.send({
475
+ type: "ms365_approval_posted",
476
+ correlationId: (msg as RequestMs365ApprovalMessage).correlationId,
477
+ ok: false,
478
+ reason: `gateway handler error: ${(err as Error).message}`,
479
+ });
480
+ } catch {
481
+ /* best effort */
482
+ }
483
+ },
484
+ );
485
+ } else {
486
+ try {
487
+ client.send({
488
+ type: "ms365_approval_posted",
489
+ correlationId: (msg as RequestMs365ApprovalMessage).correlationId,
490
+ ok: false,
491
+ reason: "gateway not configured for MS-365 write approval",
492
+ });
493
+ } catch {
494
+ /* best effort */
495
+ }
496
+ }
497
+ break;
439
498
  case "request_config_approval":
440
499
  if (onRequestConfigApproval) {
441
500
  onRequestConfigApproval(