Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ release gates. Stable promotion and publication require approval of the exact te
- Restore the agent directory refresh to 03:00 local time after a spring-forward clock change
instead of retaining the transition day’s normalized 04:00 hour.

- Automatically continue the original agent thread after approving a server for a hidden variable; no manual “continue” message is needed.

- Clarify that the network tool's policy is advisory in `/sudo` direct-host threads, with no
OS-level egress cutoff. Ordinary proxy-mode container enforcement is unchanged.

Expand Down
2 changes: 2 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# ChannelGate — Features

- **Continue after variable server approval in Slack.** Approving a hidden variable’s server automatically queues one continuation in the original thread as the original requester. It waits behind active work, honors queued Stop and current access, records usage and deduplicates repeated clicks. Queued wakeups survive restart; interrupted continuations are reported without replaying unknown actions. Denial and stale approvals do not continue.

The documentation handbook includes its branded favicon in standalone builds and website exports.
The build validator checks that every handbook page references the shipped SVG asset.

Expand Down
15 changes: 15 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# ChannelGate — Test Plan

## Automatic continuation after variable server approval

Automated: `node --test test/secret-host-continuation.test.js test/egress-secret-approval.test.js test/durable-approvals.test.js test/approval-links.test.js`.
Live SECRET-HOST-CONTINUE-01: run separately in Claude and Codex worker fixtures with network ON,
Lean OFF, an approved requester and a disposable hidden HTTPS token for an operator-controlled
public endpoint with no approved hosts. Ask to check the endpoint without printing the token.
After secret-refused, click Allow on server without sending continue. Require one automatic retry
and result in the original thread/session as the original requester, including a different admin
approving an organization token. Repeat with a personal owner approval, while the source turn is
active, after it ends, and with a pending card across restart. Require serialized runs and usage
for every continuation. Duplicate clicks, denial, stale/rotated tokens, unauthorized approval,
revoked requester access and Stop while queued must cause no additional execution. Queued wakeups
survive restart; an interrupted running wakeup produces a notice without replaying unknown effects.
Never expose token values. Live engine acceptance remains separate from automated regression.

## Network tool description and direct-host policy

Run `node --test test/channel-policy-audit.test.js test/network-policy.test.js
Expand Down
28 changes: 26 additions & 2 deletions src/gateway/secret-host-approvals.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,24 @@ import { engineHostsFor } from "./egress/engine-hosts.js";
import { hostMatches, normalizeHost } from "./egress/rules.js";
import { isValidRuleHost } from "./egress/catalog-rules.js";
import { liveTurnIn } from "./egress/liveness.js";
import { getDb, fromJson } from "../db/index.js";

export const SECRET_HOST_ACTION = "secret_host";

// Stop/clear must retire accepted wakeups even before the inbox can dispatch them (offline or
// capacity-bound). Running handlers are also aborted through the ordinary per-thread queue.
export function cancelSecretHostContinuations(channelId, threadKey = null) {
const db = getDb();
const rows = db.prepare("SELECT event_id, data FROM inbound_events WHERE namespace = 'secret-host-continuation' AND conversation_id = ? AND status IN ('queued', 'running', 'interrupted', 'notice_failed')").all(channelId);
const threads = new Set();
for (const row of rows) {
const action = fromJson(row.data, {});
if (threadKey && action.threadKey !== threadKey) continue;
db.prepare("UPDATE inbound_events SET status = 'done', data = '{}' WHERE namespace = 'secret-host-continuation' AND event_id = ?").run(row.event_id);
if (action.threadKey) threads.add(action.threadKey);
}
return [...threads];
}
// The approval tier a secret's scope carries (mirrored by gateway-server.js secretCardTier).
export function secretHostTier(scope) {
return scope === "organization" ? "admin" : scope === "personal" ? "owner" : "";
Expand Down Expand Up @@ -123,7 +139,7 @@ export async function requestSecretHostApprovals(refusals, { channelId, slug, ho
}

// The durable executor (server.js setDurableApprovalExecutor) for a SECRET_HOST_ACTION row.
export async function executeSecretHostApproval(record) {
export async function executeSecretHostApproval(record, { enqueueContinuation } = {}) {
const action = record?.action;
try {
if (record?.status !== "executing" || action?.kind !== SECRET_HOST_ACTION || action.channelId !== record.channelId || action.slug !== record.slug
Expand Down Expand Up @@ -151,7 +167,15 @@ export async function executeSecretHostApproval(record) {
if (!entry) throw new Error(`${action.secretName} no longer exists.`);
if ((Number(entry.setAt) || 0) !== action.entrySetAt) throw new Error(`${action.secretName} was changed after this approval was requested. Retry the request to get a fresh card.`);
await patchSecretEntry({ scope: action.scope, slug: action.slug, userId: action.ownerId, name: action.secretName, addApprovedHost: host });
return { ok: true, completed: true, label: "secret server approval", message: `${action.secretName} may now be used on ${host}. Retry the request.` };
let continuation = "Retry the request.";
if (enqueueContinuation && action.authorId && action.threadKey) {
try {
if (enqueueContinuation(record)) continuation = "The agent will continue automatically in this thread.";
} catch {
continuation = "The server was approved, but automatic continuation could not be queued. Say continue to retry.";
}
}
return { ok: true, completed: true, label: "secret server approval", message: `${action.secretName} may now be used on ${host}. ${continuation}` };
} catch (error) {
return { ok: false, error: error.message };
}
Expand Down
64 changes: 64 additions & 0 deletions src/gateway/secret-host-continuation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Approval wakeups are durable, deduplicated by approval ID, and serialized with human turns.
import { createDurableInbox } from "../platforms/durable-inbox.js";
import { automationTarget, postNotice } from "../platforms/notify.js";
import { getChannelEntry, getChannelMeta, isAdminPrincipal, isApproved } from "../config/store.js";
import { isAuthorized } from "./modes.js";
import { runMessage } from "./run.js";
import { runQueue } from "../slack/message-lifecycle.js";
import { deliverResult } from "../slack/deliver.js";
import { createUsageBank } from "./usage.js";
import { platformOfConversation } from "../platforms/ids.js";

export function createSecretHostContinuation({ slack, runner = runMessage, deliver = deliverResult, bank = (payload) => createUsageBank()(payload), target = (id) => automationTarget(slack, id) } = {}) {
let timer;
const inbox = createDurableInbox({
namespace: "secret-host-continuation",
isReady: (channelId) => Boolean(target(channelId)),
async handle(action) {
const key = `${action.slug}::${action.threadKey}`;
const handle = { aborted: false, controller: new AbortController(), authorId: action.authorId, background: true };
await runQueue.acquire(key, handle);
try {
if (handle.aborted) return;
// Approval grants a destination, never the approver's identity or runtime privileges.
const entry = await getChannelEntry(action.channelId);
const meta = await getChannelMeta(action.slug);
if (!entry || entry.slug !== action.slug || !meta || !isAuthorized(meta, action.authorId, Boolean(entry.isDM), {
isAdminUser: await isAdminPrincipal(action.authorId), isApprovedUser: await isApproved(action.authorId),
})) return;
if (handle.aborted) return;
const result = await runner({
channelId: action.channelId, authorId: action.authorId, threadKey: action.threadKey,
origin: "continuation", signal: handle.controller.signal,
text: `[automatic continuation] Server approval was granted for variable ${action.secretName} on ${action.host}. `
+ "Continue the user's pending task and retry the request that was refused with secret-refused. "
+ "Check prior results before repeating any completed actions. Never display the variable's value.",
});
await bank({ channelId: action.channelId, slug: action.slug, authorId: action.authorId, engine: result.engine, taskKind: "background", result });
if (handle.aborted) return;
await deliver(target(action.channelId), { channel: action.channelId, threadKey: action.threadKey, result, footer: true });
} finally { runQueue.release(key, handle); }
},
async interrupted(action) {
const sent = await postNotice(target(action.channelId), { conversationId: action.channelId, threadKey: action.threadKey,
text: "⚠️ The automatic continuation after server approval was interrupted. Check the previous result before continuing; it was not run again." });
if (!sent) throw new Error("Continuation interruption notice could not be delivered");
},
});
return {
start() {
inbox.start();
timer ||= setInterval(() => inbox.wake(), 1000);
timer.unref?.();
},
stop() { clearInterval(timer); timer = null; inbox.stop(); },
enqueue(record) {
const action = record.action;
if (!record.id || !action?.authorId || !action.threadKey) return false;
// Other platforms own different session queues; do not bypass their coordination.
if (platformOfConversation(action.channelId) !== "slack") return false;
inbox.accept({ id: record.id, conversationId: action.channelId, payload: action });
return true;
},
};
}
11 changes: 7 additions & 4 deletions src/platforms/durable-inbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
const CLEANUP_INTERVAL_MS = 60_000;
const CLEANUP_BATCH = 500;

export function createDurableInbox({ namespace, handle, interrupted = async () => {}, log = console, now = Date.now, concurrencyGroup = "default", conversationKey = null, queuedNamespace = null } = {}) {
export function createDurableInbox({ namespace, handle, interrupted = async () => {}, log = console, now = Date.now, concurrencyGroup = "default", conversationKey = null, queuedNamespace = null, isReady = null } = {}) {
if (!namespace || typeof handle !== "function") throw new TypeError("Inbox requires a namespace and handler");
const owner = randomUUID();
// Ordinary work shares four slots. Explicit control intake can use a separate bounded group
Expand Down Expand Up @@ -50,10 +50,12 @@ export function createDurableInbox({ namespace, handle, interrupted = async () =
function pump() {
if (stopped) return;
if (active.size >= MAX_ACTIVE) return;
for (const row of readyRows(MAX_ACTIVE - active.size)) {
for (const row of readyRows(isReady ? MAX_PENDING : MAX_ACTIVE - active.size)) {
if (active.size >= MAX_ACTIVE) break;
const key = `${namespace}:${row.event_id}`;
const conversation = `${namespace}:${row.conversation_id}`;
if (active.has(key) || activeConversations.has(conversation)) continue;
if (isReady && !isReady(row.conversation_id)) continue;
const wasInterrupted = row.status === "interrupted";
const claimed = db.prepare("UPDATE inbound_events SET status = 'running', owner = ? WHERE namespace = ? AND event_id = ? AND status = ?")
.run(owner, namespace, row.event_id, row.status);
Expand All @@ -62,11 +64,11 @@ export function createDurableInbox({ namespace, handle, interrupted = async () =
active.add(key);
activeConversations.add(conversation);
Promise.resolve().then(() => wasInterrupted ? interrupted(fromJson(saved.data, {})) : handle(fromJson(saved.data, {})))
.then(() => db.prepare("UPDATE inbound_events SET status = 'done', data = '{}' WHERE namespace = ? AND event_id = ? AND owner = ?").run(namespace, row.event_id, owner))
.then(() => db.prepare("UPDATE inbound_events SET status = 'done', data = '{}' WHERE namespace = ? AND event_id = ? AND owner = ? AND status = 'running'").run(namespace, row.event_id, owner))
.catch((error) => {
// A handler may fail AFTER side effects. Keep an explicit interruption record, never
// silently turn it back into queued execution. A failed notice waits for reconnect.
db.prepare("UPDATE inbound_events SET status = ? WHERE namespace = ? AND event_id = ? AND owner = ?")
db.prepare("UPDATE inbound_events SET status = ? WHERE namespace = ? AND event_id = ? AND owner = ? AND status = 'running'")
.run(wasInterrupted ? "notice_failed" : "interrupted", namespace, row.event_id, owner);
log.error?.(`[inbox] ${namespace} event interrupted: ${error?.message || error}`);
})
Expand All @@ -79,6 +81,7 @@ export function createDurableInbox({ namespace, handle, interrupted = async () =
}

return {
wake: () => queueMicrotask(pump),
accept({ id, conversationId, payload }) {
if (stopped) throw new Error("Inbound transport is stopped; event was not accepted");
if (!id || !conversationId) throw new Error("Inbound event requires stable event and conversation identifiers");
Expand Down
5 changes: 4 additions & 1 deletion src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { BackgroundJobs, setActiveBackgroundJobs } from "./gateway/background.js
import { requestApproval, setDurableApprovalExecutor } from "./slack/approvals.js";
import { executeInstructionApproval, INSTRUCTION_ACTION } from "./gateway/instruction-approvals.js";
import { executeSecretHostApproval, SECRET_HOST_ACTION, setSecretHostApprovalRequester } from "./gateway/secret-host-approvals.js";
import { createSecretHostContinuation } from "./gateway/secret-host-continuation.js";
import { CONTROL_PLANE_ACTION, executeControlPlaneApproval, setControlPlaneInvoker } from "./gateway/control-plane-approvals.js";
import { createDirectDaemonIpc, createGatewayMcpServer, ctxFromClaims } from "./mcp/gateway-server.js";
import { startMcpSocketServer, stopMcpSocketServer, mcpSocketStatus } from "./mcp/socket-server.js";
Expand Down Expand Up @@ -339,10 +340,12 @@ async function main() {
// job rows have been re-tracked, or the first API/approval-triggered job would rewrite bg_jobs
// from a still-empty map and wipe every unrecovered row.
backgroundJobs.armRecovery();
const secretHostContinuation = createSecretHostContinuation({ slack });
secretHostContinuation.start();
setDurableApprovalExecutor((record) => record.action?.kind === INSTRUCTION_ACTION
? executeInstructionApproval(record)
: record.action?.kind === SECRET_HOST_ACTION
? executeSecretHostApproval(record)
? executeSecretHostApproval(record, { enqueueContinuation: (saved) => secretHostContinuation.enqueue(saved) })
: record.action?.kind === CONTROL_PLANE_ACTION
? executeControlPlaneApproval(record)
: backgroundJobs.startApproved(record));
Expand Down
5 changes: 3 additions & 2 deletions src/slack/message-pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import { codeChannelReplyTarget, isCodeChannelMeta, syncCodeChannelMeta } from "
import { refreshCodeChannelDiff } from "./code-channels.js";
import { busyThreadChoiceBlocks, busyThreadChoices, deliverBusyThreadChoiceLinks, steerActiveRun, BUSY_THREAD_CHOICE_KIND } from "./busy-thread-choice.js";
import { engineSwitchChoices, engineSwitchChoiceBlocks, engineSwitchChoiceText } from "./engine-switch-choice.js";
import { cancelSecretHostContinuations } from "../gateway/secret-host-approvals.js";

// In-flight runs by "<slug>::<threadKey>". The queue serializes turns per thread — a second
// message in the same thread waits FIFO behind the running one instead of racing it in the same
Expand Down Expand Up @@ -105,7 +106,7 @@ export function abortRunsInChannel(channelId, slug, byUser, threadKey = null) {
// durably, so an old button cannot resurrect that message after the active turn was cancelled.
const pendingChoices = clearPendingRunChoices({ channelId, threadKey });
const pendingQuestions = cancelPendingQuestions({ channelId, threadKey });
const stoppedRuns = [];
const stoppedRuns = cancelSecretHostContinuations(channelId, threadKey);
const stoppedTurns = [];

// Stop every matching engine and terminalize its durable work before the first awaited Slack
Expand All @@ -132,7 +133,7 @@ export function abortRunsInChannel(channelId, slug, byUser, threadKey = null) {
}
res.active?.controller?.abort(); // kills a cold (keep-alive off) child mid-run — Claude or Codex
abortPooled(key); // terminates a warm (Claude) session
stoppedRuns.push(key.slice(prefix.length));
if (!stoppedRuns.includes(key.slice(prefix.length))) stoppedRuns.push(key.slice(prefix.length));
for (const [state, handles] of [["active", [res.active]], ["queued", res.queued || []]]) {
for (const handle of handles) {
if (!handle || handle.stopAccounted) continue;
Expand Down
12 changes: 12 additions & 0 deletions test/egress-secret-approval.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,18 @@ test("executing an approval records the server on the secret itself — for an a
// A card for an OLDER version of the secret (deleted and re-created, or re-set) blesses nothing.
const stale = await approvals.executeSecretHostApproval({ ...record("U_APPR_ADMIN"), action: { ...action, entrySetAt: 1 } });
assert.match(stale.error, /changed after this approval was requested/);
let wakeups = 0;
const queue = (saved) => { wakeups++; assert.equal(saved.action.authorId, "U_APPR_MEMBER"); return true; };
const withSource = { ...action, authorId: "U_APPR_MEMBER", threadKey: "1790000000.000100" };
const denied = await approvals.executeSecretHostApproval({ ...record("U_APPR_MEMBER"), action: withSource }, { enqueueContinuation: queue });
assert.equal(denied.ok, false);
assert.equal(wakeups, 0);
const resumed = await approvals.executeSecretHostApproval({ ...record("U_APPR_ADMIN"), action: withSource }, { enqueueContinuation: queue });
assert.equal(wakeups, 1);
assert.match(resumed.message, /continue automatically/);
const queueFailed = await approvals.executeSecretHostApproval({ ...record("U_APPR_ADMIN"), action: withSource }, { enqueueContinuation: () => { throw new Error("full"); } });
assert.equal(queueFailed.ok, true, "the approved host remains saved even when wakeup fails");
assert.match(queueFailed.message, /could not be queued/);
const ok = await approvals.executeSecretHostApproval(record("U_APPR_ADMIN"));
assert.equal(ok.ok, true, ok.error);
assert.deepEqual(scoped.getOrgEnv().PAY_API_TOKEN.approvedHosts, ["api.pay.example"]);
Expand Down
Loading
Loading