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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ product overview.

## Unreleased

- Fix a history-transfer deadlock when `/sudo` crosses a container whose mounts need recreation.
Transfer preparation now excludes its own lease, reports waits, handles Stop, and times out
into transcript recovery instead of hanging indefinitely.
- Google Chat now reports a fatal Pub/Sub receiver error as a failed connection, stops intake,
and gives an actionable Subscriber-role hint for permission errors. Reconnect restores status
without allowing an old connection's late error to overwrite it.

- Channel Runtime now shows only engine and model controls for the shared login. Dedicated Codex
sign-in uses a method dropdown: choosing ChatGPT starts the device flow and clicking its code
copies it; the API key field appears only for that method. The shared sign-in moved to Settings.
Expand Down
11 changes: 11 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# ChannelGate — Features

## Runtime history transfer and Google Chat failure recovery

- Switching a thread between its container and `/sudo` host carries native history without
treating the transfer's own lease as an active occupant that blocks container recreation.
Preparation reports waits, responds to Stop, and falls back to transcript recovery after a
bounded preparation timeout. An explicit Stop never starts a replacement engine turn.
- Fatal Google Chat receiver errors stop intake and mark the connection as failed; health and
connector availability no longer report a stopped receiver as connected. Pub/Sub permission
failures name the Subscriber role and reconnect action. Reconnecting clears the failure, and
callbacks from older connections cannot overwrite the replacement connection's state.

## Channel-specific Codex authentication

- Runtime settings ask which Codex login a channel uses. The shared choice shows the channel's
Expand Down
35 changes: 35 additions & 0 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
# ChannelGate — Test Plan

## Runtime history transfer and Google Chat failure recovery (2026-10-02)

- [x] Automated: `test/session-carry.test.js`, `test/container-carry.test.js`,
`test/container-lifecycle.test.js`, and `test/runtime-integration-run.test.js` cover caller-lease
forwarding through container→host transfer, stale mount recreation, lease release, Stop during
preparation, timeout fallback, and cancellation without starting a healed turn.
- [x] Automated: `test/platform-googlechat.test.js` and `test/platform-transport-manager.test.js`
cover a real puller receiving a simulated 403 after startup, stopped inbox dispatch, failed
connection status and absent live connector, reconnect recovery, early failure during startup,
and stale failure callbacks after reconnect/disconnect. This transport behavior is engine-independent.
- [x] Live container fixture proof: `CG_LIVE_CONTAINER=1 node --test
test/session-carry.live.test.js` passed with real Podman and the runtime image. Disposable
Claude and Codex native-layout fixture files roundtripped host→container→host; changing the
clean-workspace bind source recreated the container while its carry lease was held. Bytes
survived both rebuilds, leases returned to zero, and the disposable container/volume were
removed. This verifies file movement and lifecycle, not real-engine conversation acceptance.
- [ ] Live Claude and Codex acceptance (separate runs): use a disposable private admin test
channel with the authorized admin test actor, pin the engine, and send “Remember carry-check-42
for the next message.” After its reply, change a fixture workspace mount without destroying its
persistent HOME; enable `/sudo` in the same thread and ask “What marker did I give you?”. Pass:
container recreation and native history transfer finish, the answer contains the marker, and
no indefinite “waiting for active runs” occurs. Repeat host→container after `/sudo off`.
With another fixture occupant holding a lease, repeat and press Stop during the preparation
wait. Pass: cancellation releases the transfer lease, starts no engine, and the next turn runs.
Copy commands remain bounded by their existing 120-second CLI timeout; cancellation during an
already executing copy is checked on completion, whereas readiness waits cancel immediately.
- [ ] Live Google Chat acceptance (engine-independent): use a dedicated test subscription and
service account without Subscriber, connect, then poll settings/health. Pass: status becomes
error with a Subscriber/reconnect remedy and no live connector remains. Grant Subscriber on
that subscription, reconnect, and send one test message; pass: exactly one reply. A separate
healthy Slack fixture must continue answering throughout.
- [ ] Private QA catalog: mirror the preceding two engine cases and the engine-independent
Google case to the requesting maintainer's selected personal Airtable connection. Pending
explicit connection-use authorization; automated results are not a claim of live acceptance.

## Channel-specific Codex authentication (2026-09-29)

- [x] Automated: Chromium confirms the shared-login channel view has no gateway status/sign-in
Expand Down
5 changes: 4 additions & 1 deletion src/gateway/run.js
Original file line number Diff line number Diff line change
Expand Up @@ -979,7 +979,10 @@ export async function runMessage({ channelId, authorId, workspaceId = "", text,
let carried = null;
try {
carried = await carrySession({
engine, sessionId, cwd, storedRuntime: sessionRuntime, target, slug: entry.slug, threadKey,
engine, sessionId, cwd, storedRuntime: sessionRuntime, target, slug: entry.slug, threadKey, signal,
announce: (text) => {
try { onEvent?.({ kind: "notice", scope: "gateway", text }); } catch { /* best-effort progress */ }
},
});
} finally {
clearTimeout(carryTimer);
Expand Down
13 changes: 11 additions & 2 deletions src/gateway/session-carry.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export function buildCarryEntries({ engine, cwd, sessionId, fromDir, toDir }) {
* @param {string} [options.threadKey] for the log line
* @param {Function} [options.resolveFor] resolveRuntime, injectable for tests
* @param {Function} [options.log] console.log, injectable for tests
* @param {AbortSignal} [options.signal] cancellation of the turn, including container preparation
* @param {Function} [options.announce] progress while the source or destination container is prepared
* @returns {Promise<{direction: string, files: number}|null>} null when nothing was carried
*/
export async function carrySession({
Expand All @@ -71,6 +73,8 @@ export async function carrySession({
threadKey = "",
resolveFor = resolveRuntime,
log = console.log,
signal = null,
announce = null,
} = {}) {
const where = `${slug}/${threadKey}`;
const to = String(target?.backend || "host");
Expand All @@ -79,6 +83,7 @@ export async function carrySession({
if (!sessionId || !target || from === to) return null;

try {
signal?.throwIfAborted();
if (!engineSessionState(engine)) {
log(`[gateway] ${where}: ${engine} does not declare where it keeps a session, so its history stays on ${from} — the resume falls back to the existing heal`);
return null;
Expand Down Expand Up @@ -106,16 +111,20 @@ export async function carrySession({
let result;
try {
result = to === "host"
? await mover.runtime.copyOut(mover, entries)
: await mover.runtime.copyIn(mover, entries);
? await mover.runtime.copyOut(mover, entries, { lease, signal, announce })
: await mover.runtime.copyIn(mover, entries, { lease, signal, announce });
} finally {
lease.release();
}
signal?.throwIfAborted();
const files = Number(result?.copied) || 0;
if (!files) return null;
log(`[gateway] ${where}: carried ${engine} session ${sessionId} ${direction} (${files} file${files === 1 ? "" : "s"})`);
return { direction, files };
} catch (error) {
// A user Stop must end the turn, never fall through to a fresh engine attempt.
signal?.throwIfAborted();
if (error?.name === "AbortError") throw error;
log(`[gateway] ${where}: session carry-over failed (${error?.message || error}) — the resume falls back to the existing heal`);
return null;
}
Expand Down
5 changes: 3 additions & 2 deletions src/platforms/boot.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { startTeams, botIdFor } from "./msteams/transport.js";
// The connector is built BEFORE the transport starts, on purpose: a Pub/Sub pull can deliver its
// first event inside start(), and an ingest that does not exist yet would drop that message. Wiring
// it up front means the very first message is answerable.
async function startGoogleChatTransport(config, log) {
async function startGoogleChatTransport(config, log, { onFatal } = {}) {
const capabilities = requirePlatform("googlechat").capabilities;
const auth = createGoogleAuth({ serviceAccount: config.serviceAccountJson });
const api = createChatApi({ auth });
Expand All @@ -31,6 +31,7 @@ async function startGoogleChatTransport(config, log) {
...config,
capabilities,
onMessage: ingest,
onFatal,
log,
deps: { auth, api, connector },
});
Expand All @@ -49,7 +50,7 @@ async function startTeamsTransport(config, log) {
}

export function createPlatformTransports({ log = console } = {}) {
const googlechat = createTransportManager({ platform: "googlechat", log, start: (config) => startGoogleChatTransport(config, log) });
const googlechat = createTransportManager({ platform: "googlechat", log, start: (config, lifecycle) => startGoogleChatTransport(config, log, lifecycle) });
const msteams = createTransportManager({ platform: "msteams", log, start: (config) => startTeamsTransport(config, log) });
registerTransport("googlechat", googlechat);
registerTransport("msteams", msteams);
Expand Down
7 changes: 6 additions & 1 deletion src/platforms/googlechat/pubsub.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,12 @@ export function createPubSubPuller({
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(`Pub/Sub ${path} failed (${res.status}): ${text.slice(0, 300)}`);
let detail = text.slice(0, 300);
try { detail = String(JSON.parse(text).error?.message || detail).slice(0, 300); } catch { /* non-JSON response */ }
const remedy = res.status === 403
? ` Check that the configured service account has Pub/Sub Subscriber on ${subscription}, then reconnect Google Chat.`
: "";
const err = new Error(`Pub/Sub ${path} failed (${res.status}): ${detail}${remedy}`);
err.status = res.status;
// 401/403/404 are configuration facts, not weather: a revoked key, a service account without
// roles/pubsub.subscriber, or a subscription that does not exist. Backing off and retrying
Expand Down
9 changes: 7 additions & 2 deletions src/platforms/googlechat/transport.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export async function startGoogleChat({
capabilities,
onMessage,
onBotUserId = null,
onFatal = null,
log = console,
deps = {},
} = {}) {
Expand Down Expand Up @@ -62,8 +63,12 @@ export async function startGoogleChat({
return inbox.accept({ id, conversationId, payload: { event, botId: selfId } });
}

const puller = deps.puller || createPubSubPuller({ auth, subscription, onEvent, log,
onFatal: (err) => log.error?.(`[googlechat] transport stopped: ${err?.message || err}`) });
const puller = deps.puller || createPubSubPuller({ auth, subscription, onEvent, log, fetchImpl: deps.fetchImpl,
onFatal: (err) => {
inbox.stop();
log.error?.(`[googlechat] transport stopped: ${err?.message || err}`);
onFatal?.(err);
} });
inbox.start();
try { puller.start(); } catch (error) { inbox.stop(); throw error; }

Expand Down
15 changes: 12 additions & 3 deletions src/platforms/manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,22 @@ export function createTransportManager({ platform, start, log = console } = {})
error = null;
await stopTransport(previous);
try {
const started = await start(config);
// Fatal errors can arrive during start() or after it resolves. Bind them to
// this generation so a late failure from an old connection cannot poison its replacement.
let failed = false;
const onFatal = (err) => {
if (gen !== generation) return;
failed = true;
status = "error";
error = err?.message || String(err);
};
const started = await start(config, { onFatal });
if (gen !== generation) {
await stopTransport(started);
return snapshot();
}
current = started;
status = "connected";
if (!failed) status = "connected";
} catch (err) {
log.error?.(`[${platform}] connect failed: ${err?.message || err}`);
if (gen === generation) {
Expand All @@ -90,7 +99,7 @@ export function createTransportManager({ platform, start, log = console } = {})
connect,
disconnect,
snapshot,
getConnector: () => current?.connector ?? null,
getConnector: () => status === "connected" ? current?.connector ?? null : null,
// The live transport, for the few things that need more than a connector (the Teams webhook
// handler the Express route mounts, the Chat puller's health).
getTransport: () => current,
Expand Down
33 changes: 25 additions & 8 deletions src/runtimes/container/carry.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,29 @@ export function createContainerCarry({ exec, lifecycle, log = () => {} } = {}) {
// A carry may run against a container the idle reaper has stopped — that is rather the point:
// the history lives in a volume that outlives the container. Bringing it up is the backend's own
// job (the caller holds a lease around the whole carry so the reaper cannot stop it again
// mid-copy). `retry` keeps the out-of-band-removal self-heal every other exec has.
async function runScript(target, script) {
await lifecycle.ensureUp(target, {});
return exec.runExec(target, [target.container.name, "/bin/sh", "-c", script], {
// mid-copy). Forward that lease so a mount rebuild never waits on its own caller.
async function runScript(target, script, { lease = null, signal = null, announce = null } = {}) {
signal?.throwIfAborted();
// A busy old container must not strand history transfer indefinitely. Timeout is a
// recoverable carry failure; an explicit Stop remains cancellation of the whole turn.
const timeout = AbortSignal.timeout(CARRY_TIMEOUT_MS);
try {
await lifecycle.ensureUp(target, { lease, announce, signal: signal ? AbortSignal.any([signal, timeout]) : timeout });
} catch (error) {
signal?.throwIfAborted();
// Lifecycle labels all cancelled waits AbortError; preserve a timeout as a carry failure.
if (timeout.aborted) throw timeout.reason;
throw error;
}
signal?.throwIfAborted();
const result = await exec.runExec(target, [target.container.name, "/bin/sh", "-c", script], {
timeoutMs: CARRY_TIMEOUT_MS,
// The generic exec retry prepares without the carry lease. A vanished container is
// instead a recoverable carry failure. CLI exec itself is bounded, not abortable.
retry: false,
});
signal?.throwIfAborted();
return result;
}

function cleanup(dir) {
Expand All @@ -153,7 +170,7 @@ export function createContainerCarry({ exec, lifecycle, log = () => {} } = {}) {
}
}

async function copyIn(target, entries = []) {
async function copyIn(target, entries = [], opts = {}) {
if (!target?.artifactDir) throw new Error("this target has no artifact dir — a carry has nowhere to stage");
const stagingDir = carryStagingDir(target, newCarryId());
try {
Expand All @@ -171,7 +188,7 @@ export function createContainerCarry({ exec, lifecycle, log = () => {} } = {}) {
}
}
if (!pairs.length) return { copied: 0 };
const result = await runScript(target, buildCopyInScript(pairs));
const result = await runScript(target, buildCopyInScript(pairs), opts);
if (result.code !== 0) {
throw new Error(`copying session state into ${target.container.name} failed: ${String(result.stderr || "").trim() || `exit ${result.code}`}`);
}
Expand All @@ -181,12 +198,12 @@ export function createContainerCarry({ exec, lifecycle, log = () => {} } = {}) {
}
}

async function copyOut(target, entries = []) {
async function copyOut(target, entries = [], opts = {}) {
if (!target?.artifactDir) throw new Error("this target has no artifact dir — a carry has nowhere to stage");
const stagingDir = carryStagingDir(target, newCarryId());
try {
mkdirSync(stagingDir, { recursive: true, mode: 0o700 });
const result = await runScript(target, buildCopyOutScript(entries, stagingDir));
const result = await runScript(target, buildCopyOutScript(entries, stagingDir), opts);
if (result.code !== 0) {
throw new Error(`copying session state out of ${target.container.name} failed: ${String(result.stderr || "").trim() || `exit ${result.code}`}`);
}
Expand Down
8 changes: 4 additions & 4 deletions src/runtimes/container/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,12 @@ export const containerBackend = Object.freeze({
// The carry pair (contract.js OPTIONAL_METHODS): a thread's engine-native history follows it
// when its channel changes runtime backend. Both halves go through the bind-mounted artifact
// dir — the daemon cannot touch the HOME volume itself. See ./carry.js.
async copyIn(target, entries) {
return runtime().carry.copyIn(target, entries);
async copyIn(target, entries, opts = {}) {
return runtime().carry.copyIn(target, entries, opts);
},

async copyOut(target, entries) {
return runtime().carry.copyOut(target, entries);
async copyOut(target, entries, opts = {}) {
return runtime().carry.copyOut(target, entries, opts);
},

// The relayed Codex login file (contract.js OPTIONAL_METHODS): staged through the artifact dir
Expand Down
9 changes: 5 additions & 4 deletions src/runtimes/contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ export const OPTIONAL_METHODS = Object.freeze([
// authenticate in this runtime?" gate. The local runtime has none: the daemon's own logins are
// right there.
"credentialError",
// copyIn(target, entries) / copyOut(target, entries) → Promise<{ copied: number }> — move
// copyIn(target, entries, opts) / copyOut(target, entries, opts) → Promise<{ copied: number }> — move
// ENGINE STATE between the daemon's filesystem and this runtime's. `copyIn` writes into the
// runtime, `copyOut` reads out of it; both take CarryEntry[] (src/runtimes/copy.js) whose
// runtime, `copyOut` reads out of it. opts carries { lease, signal, announce } into preparation.
// Both take CarryEntry[] (src/runtimes/copy.js) whose
// `from`/`to` are absolute on their own side and share the relative tail `rel`. They exist so a
// thread's engine-native history follows it when its channel changes runtime backend — see
// src/gateway/session-carry.js. A backend that declares neither simply cannot carry, and the
Expand Down Expand Up @@ -202,11 +203,11 @@ export const HELPER_COMMANDS = Object.freeze([
* image's /opt/channelgate bundle). Unknown names throw.
*
* OPTIONAL (see OPTIONAL_METHODS):
* copyIn(target, entries) → Promise<{ copied }> — write engine state from the DAEMON's filesystem
* copyIn(target, entries, opts) → Promise<{ copied }> — write engine state from the DAEMON's filesystem
* into this runtime. Host: a plain node:fs copy. Container: stage under the
* bind-mounted artifact dir, then one exec inside that copies each entry
* into place; the staging dir is removed either way.
* copyOut(target, entries) → Promise<{ copied }> — the same in reverse. Overwrite, never delete, and a
* copyOut(target, entries, opts) → Promise<{ copied }> — the same in reverse. Overwrite, never delete, and a
* missing source is 0 copied rather than an error.
* inspectState(target, request) → Promise<[{ path, mtimeMs, head }]> — the files inside this runtime that
* match `request.globs` (carry-style: `*` inside a segment, never across
Expand Down
Loading
Loading