switchroom 0.13.9 → 0.13.11
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/dist/cli/switchroom.js +38 -14
- package/dist/host-control/main.js +222 -7
- package/examples/switchroom.yaml +25 -7
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +23 -4
- package/telegram-plugin/dist/gateway/gateway.js +540 -147
- package/telegram-plugin/dist/server.js +23 -4
- package/telegram-plugin/gateway/config-approval-handler.test.ts +246 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +284 -0
- package/telegram-plugin/gateway/gateway.ts +218 -25
- package/telegram-plugin/gateway/ipc-protocol.ts +72 -2
- package/telegram-plugin/gateway/ipc-server.ts +101 -0
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +185 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +69 -0
- package/telegram-plugin/model-unavailable.ts +11 -1
- package/telegram-plugin/operator-events.fixtures.json +14 -24
- package/telegram-plugin/operator-events.ts +11 -2
- package/telegram-plugin/session-tail.ts +71 -4
- package/telegram-plugin/subagent-watcher.ts +39 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +15 -2
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +53 -2
- package/telegram-plugin/tests/operator-events.test.ts +14 -7
- package/telegram-plugin/tests/subagent-handback-decision.test.ts +112 -0
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +105 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +61 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +67 -1
- package/telegram-plugin/uat/scenarios/jtbd-subagent-handback-dm.test.ts +95 -0
- package/profiles/default/CLAUDE.md +0 -193
|
@@ -281,6 +281,7 @@ import {
|
|
|
281
281
|
buildVaultSaveFailedInbound,
|
|
282
282
|
buildVaultSaveDiscardedInbound,
|
|
283
283
|
} from './vault-grant-inbound-builders.js'
|
|
284
|
+
import { decideSubagentHandback } from './subagent-handback-inbound-builder.js'
|
|
284
285
|
import { createPollHealthCheck, type PollHealthCheckHandle } from './poll-health.js'
|
|
285
286
|
import type {
|
|
286
287
|
ToolCallMessage,
|
|
@@ -2711,8 +2712,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
2711
2712
|
// Card text branches on the AND. wouldFireFleetAutoFallback is a
|
|
2712
2713
|
// pure read of the dedup state; calling fireFleetAutoFallback only
|
|
2713
2714
|
// when both are true keeps the card honest.
|
|
2714
|
-
|
|
2715
|
-
|
|
2715
|
+
// Only a genuine quota / usage-limit hit is addressable by fleet
|
|
2716
|
+
// auto-fallback (swap to an account that still has runway). An
|
|
2717
|
+
// `overload` is transient Anthropic SERVER-side capacity pressure —
|
|
2718
|
+
// every account is equally affected, so failing over does nothing;
|
|
2719
|
+
// it just produces a self-cancelling "probed healthy / Stale event?"
|
|
2720
|
+
// loop on every 529. Overload is handled by Claude Code's own
|
|
2721
|
+
// internal retry, not by switching accounts.
|
|
2722
|
+
const isAutoKind = modelUnavailable.kind === 'quota_exhausted'
|
|
2716
2723
|
const willActuallyFire = isAutoKind && wouldFireFleetAutoFallback()
|
|
2717
2724
|
process.stderr.write(
|
|
2718
2725
|
`telegram gateway: operator-event suppressing-raw-stderr-for-model-unavailable agent=${agent} kind=${kind} detected=${modelUnavailable.kind} autoKind=${isAutoKind} willFire=${willActuallyFire}\n`,
|
|
@@ -3438,11 +3445,15 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3438
3445
|
},
|
|
3439
3446
|
|
|
3440
3447
|
onClientDisconnected(client: IpcClient) {
|
|
3441
|
-
|
|
3442
|
-
//
|
|
3443
|
-
//
|
|
3444
|
-
//
|
|
3448
|
+
// ONLY log "bridge disconnected" + emit bridgeDown for the REAL
|
|
3449
|
+
// bridge sidecar (matching the bridgeUp gate above). Anonymous IPC
|
|
3450
|
+
// clients — e.g. recall.py's one-shot legacy update_placeholder
|
|
3451
|
+
// handshake — connect and disconnect constantly with agentName=null;
|
|
3452
|
+
// logging those as "bridge disconnected — agent=null" reads as a
|
|
3453
|
+
// bridge flap in the supervisor log when nothing flapped. The
|
|
3454
|
+
// anonymous disconnect is still traced by disconnect-flush.ts.
|
|
3445
3455
|
if (client.agentName != null) {
|
|
3456
|
+
process.stderr.write(`telegram gateway: bridge disconnected — agent=${client.agentName}\n`)
|
|
3446
3457
|
shadowEmit({ kind: 'bridgeDown', at: Date.now() })
|
|
3447
3458
|
}
|
|
3448
3459
|
|
|
@@ -3768,6 +3779,102 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3768
3779
|
})
|
|
3769
3780
|
},
|
|
3770
3781
|
|
|
3782
|
+
/**
|
|
3783
|
+
* #1623 — hostd-initiated config-edit approval card. hostd posts a
|
|
3784
|
+
* `request_config_approval` message; we render the card via
|
|
3785
|
+
* `handleRequestConfigApproval`, awaiting the operator tap (or
|
|
3786
|
+
* timeout) before sending `config_approval_resolved` back. After
|
|
3787
|
+
* hostd's apply attempt completes it posts
|
|
3788
|
+
* `request_config_finalize` to flip the card to the terminal
|
|
3789
|
+
* outcome.
|
|
3790
|
+
*/
|
|
3791
|
+
async onRequestConfigApproval(client: IpcClient, msg) {
|
|
3792
|
+
const { handleRequestConfigApproval } = await import(
|
|
3793
|
+
'./config-approval-handler.js'
|
|
3794
|
+
)
|
|
3795
|
+
const { InlineKeyboard } = await import('grammy')
|
|
3796
|
+
await handleRequestConfigApproval(client, msg, {
|
|
3797
|
+
agentName: getMyAgentName(),
|
|
3798
|
+
loadTargetChat: () => {
|
|
3799
|
+
const access = loadAccess()
|
|
3800
|
+
const operator = access.allowFrom[0]
|
|
3801
|
+
if (operator === undefined) return null
|
|
3802
|
+
return { chatId: operator }
|
|
3803
|
+
},
|
|
3804
|
+
buildKeyboard: (requestId) =>
|
|
3805
|
+
new InlineKeyboard()
|
|
3806
|
+
.text('✅ Approve', `cfg:${requestId}:approve`)
|
|
3807
|
+
.text('🚫 Deny', `cfg:${requestId}:deny`),
|
|
3808
|
+
postCard: async (args) => {
|
|
3809
|
+
try {
|
|
3810
|
+
const sent = await robustApiCall(
|
|
3811
|
+
() =>
|
|
3812
|
+
bot.api.sendMessage(args.chatId, args.text, {
|
|
3813
|
+
parse_mode: 'HTML',
|
|
3814
|
+
...(args.threadId !== undefined
|
|
3815
|
+
? { message_thread_id: args.threadId }
|
|
3816
|
+
: {}),
|
|
3817
|
+
reply_markup: args.replyMarkup as never,
|
|
3818
|
+
}),
|
|
3819
|
+
{
|
|
3820
|
+
chat_id: String(args.chatId),
|
|
3821
|
+
verb: 'config-approval-card',
|
|
3822
|
+
...(args.threadId !== undefined ? { threadId: args.threadId } : {}),
|
|
3823
|
+
},
|
|
3824
|
+
)
|
|
3825
|
+
return { messageId: (sent as { message_id: number }).message_id }
|
|
3826
|
+
} catch (err) {
|
|
3827
|
+
process.stderr.write(
|
|
3828
|
+
`telegram gateway: config-approval postCard failed: ${(err as Error).message}\n`,
|
|
3829
|
+
)
|
|
3830
|
+
return null
|
|
3831
|
+
}
|
|
3832
|
+
},
|
|
3833
|
+
editCard: async (args) => {
|
|
3834
|
+
try {
|
|
3835
|
+
await robustApiCall(
|
|
3836
|
+
() =>
|
|
3837
|
+
bot.api.editMessageText(args.chatId, args.messageId, args.text, {
|
|
3838
|
+
parse_mode: 'HTML',
|
|
3839
|
+
}),
|
|
3840
|
+
{ chat_id: String(args.chatId), verb: 'config-approval-edit' },
|
|
3841
|
+
)
|
|
3842
|
+
} catch (err) {
|
|
3843
|
+
process.stderr.write(
|
|
3844
|
+
`telegram gateway: config-approval editCard failed: ${(err as Error).message}\n`,
|
|
3845
|
+
)
|
|
3846
|
+
}
|
|
3847
|
+
},
|
|
3848
|
+
log: (m) =>
|
|
3849
|
+
process.stderr.write(`telegram gateway: config-approval — ${m}\n`),
|
|
3850
|
+
})
|
|
3851
|
+
},
|
|
3852
|
+
|
|
3853
|
+
async onRequestConfigFinalize(client: IpcClient, msg) {
|
|
3854
|
+
const { handleRequestConfigFinalize } = await import(
|
|
3855
|
+
'./config-approval-handler.js'
|
|
3856
|
+
)
|
|
3857
|
+
await handleRequestConfigFinalize(client, msg, {
|
|
3858
|
+
editCard: async (args) => {
|
|
3859
|
+
try {
|
|
3860
|
+
await robustApiCall(
|
|
3861
|
+
() =>
|
|
3862
|
+
bot.api.editMessageText(args.chatId, args.messageId, args.text, {
|
|
3863
|
+
parse_mode: 'HTML',
|
|
3864
|
+
}),
|
|
3865
|
+
{ chat_id: String(args.chatId), verb: 'config-approval-finalize' },
|
|
3866
|
+
)
|
|
3867
|
+
} catch (err) {
|
|
3868
|
+
process.stderr.write(
|
|
3869
|
+
`telegram gateway: config-finalize editCard failed: ${(err as Error).message}\n`,
|
|
3870
|
+
)
|
|
3871
|
+
}
|
|
3872
|
+
},
|
|
3873
|
+
log: (m) =>
|
|
3874
|
+
process.stderr.write(`telegram gateway: config-finalize — ${m}\n`),
|
|
3875
|
+
})
|
|
3876
|
+
},
|
|
3877
|
+
|
|
3771
3878
|
onInjectInbound(_client: IpcClient, msg: InjectInboundMessage) {
|
|
3772
3879
|
const promptKey = typeof msg.inbound.meta?.prompt_key === 'string'
|
|
3773
3880
|
? msg.inbound.meta.prompt_key
|
|
@@ -12716,6 +12823,55 @@ bot.on('callback_query:data', async ctx => {
|
|
|
12716
12823
|
return
|
|
12717
12824
|
}
|
|
12718
12825
|
|
|
12826
|
+
// #1623: cfg:<requestId>:<approve|deny> — hostd config-edit
|
|
12827
|
+
// approval card. Resolves the pending approval (gateway-side
|
|
12828
|
+
// in-memory map), sends `config_approval_resolved` back to hostd
|
|
12829
|
+
// over the original IPC connection, and edits the card to the
|
|
12830
|
+
// interim "Applying…" / "Denied" state. Same operator-allowlist
|
|
12831
|
+
// gate as every other callback family.
|
|
12832
|
+
if (data.startsWith('cfg:')) {
|
|
12833
|
+
const access = loadAccess()
|
|
12834
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
12835
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
12836
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' })
|
|
12837
|
+
return
|
|
12838
|
+
}
|
|
12839
|
+
const { parseConfigApprovalCallback, resolvePendingConfigApproval } =
|
|
12840
|
+
await import('./config-approval-handler.js')
|
|
12841
|
+
const parsed = parseConfigApprovalCallback(data)
|
|
12842
|
+
if (parsed === null) {
|
|
12843
|
+
await ctx.answerCallbackQuery({ text: 'Malformed callback.' })
|
|
12844
|
+
return
|
|
12845
|
+
}
|
|
12846
|
+
const resolved = await resolvePendingConfigApproval(
|
|
12847
|
+
parsed.requestId,
|
|
12848
|
+
parsed.choice,
|
|
12849
|
+
{
|
|
12850
|
+
editCard: async (args) => {
|
|
12851
|
+
try {
|
|
12852
|
+
await robustApiCall(() =>
|
|
12853
|
+
bot.api.editMessageText(args.chatId, args.messageId, args.text, {
|
|
12854
|
+
parse_mode: 'HTML',
|
|
12855
|
+
}),
|
|
12856
|
+
)
|
|
12857
|
+
} catch {
|
|
12858
|
+
/* best effort */
|
|
12859
|
+
}
|
|
12860
|
+
},
|
|
12861
|
+
log: (m) =>
|
|
12862
|
+
process.stderr.write(`telegram gateway: config-approval cb — ${m}\n`),
|
|
12863
|
+
},
|
|
12864
|
+
)
|
|
12865
|
+
await ctx.answerCallbackQuery({
|
|
12866
|
+
text: resolved
|
|
12867
|
+
? parsed.choice === 'approve'
|
|
12868
|
+
? 'Approving…'
|
|
12869
|
+
: 'Denied'
|
|
12870
|
+
: 'Already resolved.',
|
|
12871
|
+
})
|
|
12872
|
+
return
|
|
12873
|
+
}
|
|
12874
|
+
|
|
12719
12875
|
// RFC E §4.1: drvpick:<verb>:<agent>[:<...>] — folder-picker card taps.
|
|
12720
12876
|
// open / enter / back / refresh re-render the card in place;
|
|
12721
12877
|
// grant writes an allow_always kernel decision at
|
|
@@ -14897,25 +15053,40 @@ void (async () => {
|
|
|
14897
15053
|
)
|
|
14898
15054
|
}
|
|
14899
15055
|
},
|
|
14900
|
-
//
|
|
14901
|
-
//
|
|
14902
|
-
// the
|
|
14903
|
-
//
|
|
14904
|
-
|
|
14905
|
-
|
|
14906
|
-
|
|
15056
|
+
// conversational-pacing beat 4 — the handback. A foreground
|
|
15057
|
+
// sub-agent hands its result straight back as the Task tool
|
|
15058
|
+
// result, inside the parent's own turn; the model sees it
|
|
15059
|
+
// in-context. A *background* sub-agent does not — it
|
|
15060
|
+
// finishes decoupled from any turn boundary, with the parent
|
|
15061
|
+
// typically idle and no turn to receive the result. Without
|
|
15062
|
+
// a nudge the user never hears back until they send the next
|
|
15063
|
+
// message themselves. So: when a background sub-agent
|
|
15064
|
+
// terminates, wake the agent with a `subagent_handback`
|
|
15065
|
+
// inbound carrying the worker's result text, and let it
|
|
15066
|
+
// synthesise a user-facing handback in its own voice.
|
|
15067
|
+
//
|
|
15068
|
+
// Gated to background completions: foreground sub-agents
|
|
15069
|
+
// need nothing here, and 'orphan' is a stale historical-at-
|
|
15070
|
+
// boot row, not a fresh completion the user is waiting on.
|
|
15071
|
+
onFinish: ({ agentId, outcome, description, resultText }) => {
|
|
15072
|
+
// IO: resolve the fleet chat id and the background flag.
|
|
15073
|
+
// The DECISION (gating + inbound build) is delegated to
|
|
15074
|
+
// the pure `decideSubagentHandback` so it is unit-tested
|
|
15075
|
+
// independent of the gateway — see
|
|
15076
|
+
// `subagent-handback-decision.test.ts`.
|
|
15077
|
+
let fleetChatId = ''
|
|
14907
15078
|
let isBackground = false
|
|
14908
15079
|
try {
|
|
14909
15080
|
const fleets = progressDriver?.peekAllFleets() ?? []
|
|
14910
15081
|
for (const f of fleets) {
|
|
14911
15082
|
if (f.fleet.has(agentId)) {
|
|
14912
|
-
|
|
14913
|
-
chatId = f.chatId ?? ''
|
|
15083
|
+
fleetChatId = f.chatId ?? ''
|
|
14914
15084
|
break
|
|
14915
15085
|
}
|
|
14916
15086
|
}
|
|
14917
15087
|
} catch {
|
|
14918
|
-
// peek failures are non-fatal —
|
|
15088
|
+
// peek failures are non-fatal — fall through to the
|
|
15089
|
+
// owner-chat fallback inside decideSubagentHandback.
|
|
14919
15090
|
}
|
|
14920
15091
|
if (turnsDb != null) {
|
|
14921
15092
|
try {
|
|
@@ -14925,15 +15096,37 @@ void (async () => {
|
|
|
14925
15096
|
if (row != null) isBackground = row.background === 1
|
|
14926
15097
|
} catch { /* best-effort */ }
|
|
14927
15098
|
}
|
|
14928
|
-
|
|
14929
|
-
|
|
14930
|
-
|
|
14931
|
-
|
|
14932
|
-
isBackground
|
|
14933
|
-
|
|
14934
|
-
|
|
14935
|
-
|
|
14936
|
-
|
|
15099
|
+
|
|
15100
|
+
const decision = decideSubagentHandback({
|
|
15101
|
+
handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
|
|
15102
|
+
outcome,
|
|
15103
|
+
isBackground,
|
|
15104
|
+
fleetChatId,
|
|
15105
|
+
// Owner-chat fallback: if the progress-driver fleet
|
|
15106
|
+
// entry was already cleaned up, route to the owner
|
|
15107
|
+
// chat. Every switchroom fleet agent is DM-shaped, so
|
|
15108
|
+
// allowFrom[0] is the conversation that dispatched.
|
|
15109
|
+
ownerChatId: loadAccess().allowFrom[0] ?? '',
|
|
15110
|
+
taskDescription: description,
|
|
15111
|
+
resultText,
|
|
15112
|
+
})
|
|
15113
|
+
if (!decision.deliver) {
|
|
15114
|
+
if (decision.reason === 'no-chat') {
|
|
15115
|
+
process.stderr.write(
|
|
15116
|
+
`telegram gateway: subagent-handback ${agentId} — no chat to deliver to; skipped\n`,
|
|
15117
|
+
)
|
|
15118
|
+
}
|
|
15119
|
+
return
|
|
15120
|
+
}
|
|
15121
|
+
|
|
15122
|
+
// Deliver via pendingInboundBuffer + the idle-drain tick.
|
|
15123
|
+
// The drain only releases at an idle prompt (no active
|
|
15124
|
+
// turn), so the handback always lands as a clean fresh
|
|
15125
|
+
// turn and never races a turn-in-flight composer (#1556).
|
|
15126
|
+
pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', decision.inbound)
|
|
15127
|
+
process.stderr.write(
|
|
15128
|
+
`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}\n`,
|
|
15129
|
+
)
|
|
14937
15130
|
},
|
|
14938
15131
|
})
|
|
14939
15132
|
process.stderr.write('telegram gateway: subagent-watcher active\n')
|
|
@@ -93,13 +93,32 @@ export interface DriveApprovalPostedEvent {
|
|
|
93
93
|
reason?: string;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* hostd config-edit approval — sent by the gateway back to hostd
|
|
98
|
+
* after the operator taps Approve/Deny on a `request_config_approval`
|
|
99
|
+
* card (or the 10-minute timeout elapses).
|
|
100
|
+
*
|
|
101
|
+
* The gateway is the source of truth for the verdict; hostd treats
|
|
102
|
+
* this as a one-shot reply per `requestId`. Subsequent taps on the
|
|
103
|
+
* same card are ignored at the callback dispatcher (#1623, RFC §3.4).
|
|
104
|
+
*/
|
|
105
|
+
export interface ConfigApprovalResolvedEvent {
|
|
106
|
+
type: "config_approval_resolved";
|
|
107
|
+
/** Echoes the requestId from the originating request_config_approval. */
|
|
108
|
+
requestId: string;
|
|
109
|
+
verdict: "approve" | "deny" | "timeout";
|
|
110
|
+
/** Diagnostic detail when present (currently unused; reserved). */
|
|
111
|
+
reason?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
export type GatewayToClient =
|
|
97
115
|
| InboundMessage
|
|
98
116
|
| PermissionEvent
|
|
99
117
|
| StatusEvent
|
|
100
118
|
| ToolCallResult
|
|
101
119
|
| ScheduleRestartResult
|
|
102
|
-
| DriveApprovalPostedEvent
|
|
120
|
+
| DriveApprovalPostedEvent
|
|
121
|
+
| ConfigApprovalResolvedEvent;
|
|
103
122
|
|
|
104
123
|
// === Bridge (Client) -> Gateway messages ===
|
|
105
124
|
|
|
@@ -278,6 +297,55 @@ export interface RequestDriveApprovalMessage {
|
|
|
278
297
|
ttlMs?: number;
|
|
279
298
|
}
|
|
280
299
|
|
|
300
|
+
/**
|
|
301
|
+
* hostd config-edit approval — sent by hostd to the caller agent's
|
|
302
|
+
* gateway to render an approval card with the full unified diff in
|
|
303
|
+
* the operator's primary chat. The gateway:
|
|
304
|
+
*
|
|
305
|
+
* 1. Posts a Telegram card with [✅ Approve] [🚫 Deny] buttons
|
|
306
|
+
* using callback_data `cfg:<requestId>:approve` / `:deny`.
|
|
307
|
+
* 2. Tracks the pending request in-memory (no SQLite).
|
|
308
|
+
* 3. On button tap (or 10-minute timeout) sends a single
|
|
309
|
+
* `config_approval_resolved` event back over the same
|
|
310
|
+
* connection.
|
|
311
|
+
* 4. After hostd reports the apply outcome via
|
|
312
|
+
* `request_config_finalize`, edits the card body to the final
|
|
313
|
+
* state ("✅ applied" / "⚠️ reconcile failed; rolled back" /
|
|
314
|
+
* "🚫 denied" / "⏱ expired").
|
|
315
|
+
*
|
|
316
|
+
* Trust model: same as request_drive_approval — the gateway socket
|
|
317
|
+
* lives inside the agent container, only that-UID processes can
|
|
318
|
+
* connect. hostd reaches it via the per-agent state-dir bind mount
|
|
319
|
+
* (`<state-dir>/gateway.sock`).
|
|
320
|
+
*/
|
|
321
|
+
export interface RequestConfigApprovalMessage {
|
|
322
|
+
type: "request_config_approval";
|
|
323
|
+
/** Hostd-generated stable id (8-hex). Echoed in resolved/finalize. */
|
|
324
|
+
requestId: string;
|
|
325
|
+
/** Name of the admin agent that called config_propose_edit. */
|
|
326
|
+
agentName: string;
|
|
327
|
+
/** Operator-visible justification (≤500 chars). */
|
|
328
|
+
reason: string;
|
|
329
|
+
/** Full unified diff to render in a code block on the card. */
|
|
330
|
+
unifiedDiff: string;
|
|
331
|
+
/** Card timeout in milliseconds (gateway-enforced). */
|
|
332
|
+
timeoutMs: number;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Sent by hostd after the apply attempt completes (success OR
|
|
337
|
+
* rollback) so the gateway can edit the approval card body to a
|
|
338
|
+
* terminal state. Idempotent: if the card was already edited (e.g.
|
|
339
|
+
* by the timeout path), the second edit is a best-effort no-op.
|
|
340
|
+
*/
|
|
341
|
+
export interface RequestConfigFinalizeMessage {
|
|
342
|
+
type: "request_config_finalize";
|
|
343
|
+
requestId: string;
|
|
344
|
+
outcome: "applied" | "reconcile_failed_rolled_back";
|
|
345
|
+
/** Optional short diagnostic appended to the card body. */
|
|
346
|
+
detail?: string;
|
|
347
|
+
}
|
|
348
|
+
|
|
281
349
|
export type ClientToGateway =
|
|
282
350
|
| RegisterMessage
|
|
283
351
|
| ToolCallMessage
|
|
@@ -289,4 +357,6 @@ export type ClientToGateway =
|
|
|
289
357
|
| PtyPartialForward
|
|
290
358
|
| UpdatePlaceholderMessage
|
|
291
359
|
| InjectInboundMessage
|
|
292
|
-
| RequestDriveApprovalMessage
|
|
360
|
+
| RequestDriveApprovalMessage
|
|
361
|
+
| RequestConfigApprovalMessage
|
|
362
|
+
| RequestConfigFinalizeMessage;
|
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
PermissionRequestForward,
|
|
9
9
|
PtyPartialForward,
|
|
10
10
|
RegisterMessage,
|
|
11
|
+
RequestConfigApprovalMessage,
|
|
12
|
+
RequestConfigFinalizeMessage,
|
|
11
13
|
RequestDriveApprovalMessage,
|
|
12
14
|
ScheduleRestartMessage,
|
|
13
15
|
SessionEventForward,
|
|
@@ -53,6 +55,26 @@ export interface IpcServerOptions {
|
|
|
53
55
|
client: IpcClient,
|
|
54
56
|
msg: RequestDriveApprovalMessage,
|
|
55
57
|
) => Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* #1623 — hostd-initiated config-edit approval card. Handler posts
|
|
60
|
+
* a Telegram card with [✅ Approve] [🚫 Deny] buttons, tracks the
|
|
61
|
+
* pending request in-memory, and sends `config_approval_resolved`
|
|
62
|
+
* back over the same connection when the operator taps or the
|
|
63
|
+
* timeout fires. Optional: gateways without the hostd integration
|
|
64
|
+
* configured ignore these messages.
|
|
65
|
+
*/
|
|
66
|
+
onRequestConfigApproval?: (
|
|
67
|
+
client: IpcClient,
|
|
68
|
+
msg: RequestConfigApprovalMessage,
|
|
69
|
+
) => Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* #1623 — hostd-initiated terminal card edit after apply completed
|
|
72
|
+
* (success OR rolled-back). Best-effort; no reply expected.
|
|
73
|
+
*/
|
|
74
|
+
onRequestConfigFinalize?: (
|
|
75
|
+
client: IpcClient,
|
|
76
|
+
msg: RequestConfigFinalizeMessage,
|
|
77
|
+
) => Promise<void>;
|
|
56
78
|
log?: (msg: string) => void;
|
|
57
79
|
/**
|
|
58
80
|
* How long (in ms) to wait without a heartbeat before force-closing the
|
|
@@ -205,6 +227,35 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
|
|
|
205
227
|
&& typeof inb.meta === "object"
|
|
206
228
|
&& inb.meta !== null;
|
|
207
229
|
}
|
|
230
|
+
case "request_config_approval": {
|
|
231
|
+
// #1623 — hostd-initiated config-edit approval card. Wire shape
|
|
232
|
+
// only; the handler module validates the diff content.
|
|
233
|
+
if (typeof m.requestId !== "string"
|
|
234
|
+
|| (m.requestId as string).length === 0
|
|
235
|
+
|| (m.requestId as string).length > 64) return false;
|
|
236
|
+
if (typeof m.agentName !== "string"
|
|
237
|
+
|| !AGENT_NAME_RE.test(m.agentName as string)) return false;
|
|
238
|
+
if (typeof m.reason !== "string"
|
|
239
|
+
|| (m.reason as string).length === 0
|
|
240
|
+
|| (m.reason as string).length > 500) return false;
|
|
241
|
+
if (typeof m.unifiedDiff !== "string"
|
|
242
|
+
|| (m.unifiedDiff as string).length === 0) return false;
|
|
243
|
+
if (typeof m.timeoutMs !== "number"
|
|
244
|
+
|| !Number.isFinite(m.timeoutMs)
|
|
245
|
+
|| (m.timeoutMs as number) <= 0) return false;
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
case "request_config_finalize": {
|
|
249
|
+
if (typeof m.requestId !== "string"
|
|
250
|
+
|| (m.requestId as string).length === 0
|
|
251
|
+
|| (m.requestId as string).length > 64) return false;
|
|
252
|
+
if (m.outcome !== "applied"
|
|
253
|
+
&& m.outcome !== "reconcile_failed_rolled_back") return false;
|
|
254
|
+
if (m.detail !== undefined
|
|
255
|
+
&& (typeof m.detail !== "string"
|
|
256
|
+
|| (m.detail as string).length > 500)) return false;
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
208
259
|
case "request_drive_approval": {
|
|
209
260
|
// RFC E §4.2 Cut 2. Validate the wire-shaped fields the
|
|
210
261
|
// gateway will route on; the inner `preview` is treated as
|
|
@@ -241,6 +292,8 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
241
292
|
onPtyPartial,
|
|
242
293
|
onInjectInbound,
|
|
243
294
|
onRequestDriveApproval,
|
|
295
|
+
onRequestConfigApproval,
|
|
296
|
+
onRequestConfigFinalize,
|
|
244
297
|
log = () => {},
|
|
245
298
|
heartbeatTimeoutMs = 30_000,
|
|
246
299
|
} = options;
|
|
@@ -383,6 +436,54 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
|
|
|
383
436
|
}
|
|
384
437
|
}
|
|
385
438
|
break;
|
|
439
|
+
case "request_config_approval":
|
|
440
|
+
if (onRequestConfigApproval) {
|
|
441
|
+
onRequestConfigApproval(
|
|
442
|
+
client,
|
|
443
|
+
msg as RequestConfigApprovalMessage,
|
|
444
|
+
).catch((err) => {
|
|
445
|
+
log(
|
|
446
|
+
`request_config_approval handler threw (client=${client.id}): ${(err as Error).message}`,
|
|
447
|
+
);
|
|
448
|
+
try {
|
|
449
|
+
client.send({
|
|
450
|
+
type: "config_approval_resolved",
|
|
451
|
+
requestId: (msg as RequestConfigApprovalMessage).requestId,
|
|
452
|
+
verdict: "deny",
|
|
453
|
+
reason: `gateway handler error: ${(err as Error).message}`,
|
|
454
|
+
});
|
|
455
|
+
} catch {
|
|
456
|
+
/* best effort */
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
} else {
|
|
460
|
+
// Fail closed — hostd treats this as deny so the apply path
|
|
461
|
+
// never runs without an operator-attested approval card.
|
|
462
|
+
try {
|
|
463
|
+
client.send({
|
|
464
|
+
type: "config_approval_resolved",
|
|
465
|
+
requestId: (msg as RequestConfigApprovalMessage).requestId,
|
|
466
|
+
verdict: "deny",
|
|
467
|
+
reason: "gateway not configured for config-edit approval",
|
|
468
|
+
});
|
|
469
|
+
} catch {
|
|
470
|
+
/* best effort */
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
break;
|
|
474
|
+
case "request_config_finalize":
|
|
475
|
+
if (onRequestConfigFinalize) {
|
|
476
|
+
onRequestConfigFinalize(
|
|
477
|
+
client,
|
|
478
|
+
msg as RequestConfigFinalizeMessage,
|
|
479
|
+
).catch((err) => {
|
|
480
|
+
log(
|
|
481
|
+
`request_config_finalize handler threw (client=${client.id}): ${(err as Error).message}`,
|
|
482
|
+
);
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
// No reply expected.
|
|
486
|
+
break;
|
|
386
487
|
case "update_placeholder":
|
|
387
488
|
// Legacy recall.py IPC — placeholder UX was removed in #553 PR 5.
|
|
388
489
|
// Soft-accepted so recall.py keeps working without modifying
|