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
53 changes: 51 additions & 2 deletions skills/meet-join/bot/src/browser/join-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,47 @@ export interface JoinMeetOptions {
consentMessage: string;
}

/**
* Directory the bot can write diagnostic artifacts to. Matches the
* session-manager's `/out` mount, which is bound back to the host at
* `<workspace>/meets/<meetingId>/out/` — so anything we drop here is
* visible to the operator even after the container is torn down.
*/
const DIAGNOSTICS_DIR = "/out";

/**
* Best-effort: snapshot the current page to `/out/<name>.png` so an
* operator can see exactly what Google Meet was showing when a selector
* timed out. Never re-throws — diagnostics must not mask the real join
* failure that triggered the capture.
*/
async function captureFailureSnapshot(
page: Page,
name: string,
): Promise<string | null> {
const snapPath = `${DIAGNOSTICS_DIR}/${name}.png`;
try {
await page.screenshot({ path: snapPath, fullPage: true });
return snapPath;
} catch {
return null;
}
}

/**
* Best-effort: capture the current page URL so a 301/redirect to a
* sign-in wall (or a completely different Meet surface) is obvious from
* the error message. Returns `null` silently if the page has already
* been closed.
*/
async function safePageUrl(page: Page): Promise<string | null> {
try {
return page.url();
} catch {
return null;
}
}

/**
* Drive the Google Meet prejoin surface to completion and deliver the consent
* notice.
Expand All @@ -70,8 +111,12 @@ export async function joinMeet(
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const url = await safePageUrl(page);
const snap = await captureFailureSnapshot(page, "prejoin-failure");
throw new Error(
`meet-bot: prejoin name input did not appear within ${PREJOIN_TIMEOUT_MS}ms: ${msg}`,
`meet-bot: prejoin name input did not appear within ${PREJOIN_TIMEOUT_MS}ms: ${msg}` +
(url ? ` (final URL: ${url})` : "") +
(snap ? ` (screenshot: ${snap})` : ""),
);
}

Expand Down Expand Up @@ -99,8 +144,12 @@ export async function joinMeet(
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const url = await safePageUrl(page);
const snap = await captureFailureSnapshot(page, "admission-failure");
throw new Error(
`meet-bot: in-meeting UI did not appear within ${MEETING_ROOM_TIMEOUT_MS}ms (host may not have admitted the bot): ${msg}`,
`meet-bot: in-meeting UI did not appear within ${MEETING_ROOM_TIMEOUT_MS}ms (host may not have admitted the bot): ${msg}` +
(url ? ` (final URL: ${url})` : "") +
(snap ? ` (screenshot: ${snap})` : ""),
);
}

Expand Down
1 change: 1 addition & 0 deletions skills/meet-join/daemon/__tests__/chat-send-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ function makeMockRunnerPointingAt(fakeBot: FakeBotServer) {
stop: mock(async () => {}),
remove: mock(async () => {}),
inspect: mock(async () => ({ Id: runResult.containerId })),
logs: mock(async () => ""),
};
}

Expand Down
38 changes: 38 additions & 0 deletions skills/meet-join/daemon/__tests__/docker-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {

import {
buildCreateBody,
demultiplexDockerLogs,
DockerApiError,
DockerRunner,
dockerSocketUnreachableMessage,
Expand Down Expand Up @@ -450,6 +451,43 @@ describe("extractBoundPorts", () => {
// Mode-aware workspace mounts + host-gateway flag (Phase 1.10 — DinD)
// ---------------------------------------------------------------------------

describe("demultiplexDockerLogs", () => {
// Build a framed chunk matching Docker's multiplexed logs framing:
// [streamType(1)][0,0,0][size(uint32 BE, 4)][payload]
// streamType: 1 = stdout, 2 = stderr.
function frame(stream: 1 | 2, payload: string): Buffer {
const data = Buffer.from(payload, "utf8");
const header = Buffer.alloc(8);
header.writeUInt8(stream, 0);
header.writeUInt32BE(data.length, 4);
return Buffer.concat([header, data]);
}

test("concatenates stdout and stderr frames in order", () => {
const buf = Buffer.concat([
frame(1, "step 1\n"),
frame(2, "warn from stderr\n"),
frame(1, "step 2\n"),
]);
expect(demultiplexDockerLogs(buf)).toBe(
"step 1\nwarn from stderr\nstep 2\n",
);
});

test("returns empty string for an empty buffer", () => {
expect(demultiplexDockerLogs(Buffer.alloc(0))).toBe("");
});

test("drops a truncated trailing frame instead of throwing", () => {
const complete = frame(1, "ok\n");
// Truncate the second frame mid-payload.
const truncated = frame(1, "will not appear").subarray(0, 10);
expect(demultiplexDockerLogs(Buffer.concat([complete, truncated]))).toBe(
"ok\n",
);
});
});

describe("DockerRunner workspace-mount mode branching", () => {
let mock: MockDocker;

Expand Down
1 change: 1 addition & 0 deletions skills/meet-join/daemon/__tests__/e2e-smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ function makeMockRunner() {
stop: mock(async () => {}),
remove: mock(async () => {}),
inspect: mock(async () => ({ Id: "container-e2e-1" })),
logs: mock(async () => ""),
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ function makeMockRunnerPointingAt(fakeBot: FakeBotServer) {
stop: mock(async () => {}),
remove: mock(async () => {}),
inspect: mock(async () => ({ Id: runResult.containerId })),
logs: mock(async () => ""),
};
}

Expand Down
92 changes: 92 additions & 0 deletions skills/meet-join/daemon/__tests__/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ interface MockRunner {
stop: ReturnType<typeof mock>;
remove: ReturnType<typeof mock>;
inspect: ReturnType<typeof mock>;
logs: ReturnType<typeof mock>;
}

function makeMockRunner(
Expand Down Expand Up @@ -82,6 +83,7 @@ function makeMockRunner(
stop: mock(async () => {}),
remove: mock(async () => {}),
inspect: mock(async () => ({ Id: runResult.containerId })),
logs: mock(async () => ""),
};
}

Expand Down Expand Up @@ -280,6 +282,96 @@ describe("MeetSessionManager.join", () => {
await manager.leave("m2", "cleanup");
});

test("token resolver is populated during the container-spawn / audio-ingest window (regression: #26005-ish)", async () => {
// Before the fix, the bot API token only became resolvable once the
// `ActiveSession` record landed in `this.sessions`, which happens
// AFTER `audioIngestPromise` resolves. The bot's `DaemonClient`
// starts POSTing `lifecycle:joining` events well before that, so
// every early event got a 401, tripped the bot's terminal-error
// handler, and the bot shut itself down before it ever reached the
// audio-socket connect or the "Ask to join" click. This test pins
// the resolver in place from the moment the container starts.
//
// We stall the audio ingest's `start()` so the resolver is checked
// during the exact window the bot's HTTP traffic hits.
let resolveIngestStart: () => void = () => {};
const ingestStartPromise = new Promise<void>((r) => {
resolveIngestStart = r;
});
const factory = (): MeetAudioIngestLike => ({
start: mock(async () => {
await ingestStartPromise;
}),
stop: mock(async () => {}),
subscribePcm: mock(() => () => {}),
});

const manager = _createMeetSessionManagerForTests({
dockerRunnerFactory: () => makeMockRunner(),
getProviderKey: async () => "k",
getWorkspaceDir: () => workspaceDir,
botLeaveFetch: async () => {},
audioIngestFactory: factory,
});

const joinPromise = manager.join({
url: "u",
meetingId: "m-pending",
conversationId: "c",
});

// Yield so `join()` gets past token generation + runner.run().
// At this point `this.sessions` does NOT yet contain the session
// (audio-ingest is stalled), but the resolver must still return the
// token the bot is presenting on `Authorization: Bearer …`.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
const pendingToken = getMeetSessionEventRouter().resolveBotApiToken(
"m-pending",
);
expect(pendingToken).toMatch(/^[0-9a-f]{64}$/);

// Let the ingest finish; the session now lands in `this.sessions`
// and the resolver keeps returning the same token.
resolveIngestStart();
const session = await joinPromise;
// Cast away the `| null` from the resolver's return type — we
// already asserted non-null above, but `toMatch` doesn't narrow.
expect(session.botApiToken).toBe(pendingToken as string);
expect(getMeetSessionEventRouter().resolveBotApiToken("m-pending")).toBe(
pendingToken,
);

await manager.leave("m-pending", "cleanup");
expect(
getMeetSessionEventRouter().resolveBotApiToken("m-pending"),
).toBeNull();
});

test("token resolver is cleared when container spawn fails (no pending-token leak)", async () => {
// If `runner.run()` throws, the rollback path must drop the
// pre-registered pending token so a later retry with a fresh token
// doesn't see a stale match.
const runner = makeMockRunner({ runError: new Error("spawn boom") });
const manager = _createMeetSessionManagerForTests({
dockerRunnerFactory: () => runner,
getProviderKey: async () => "k",
getWorkspaceDir: () => workspaceDir,
audioIngestFactory: makeFakeAudioIngestFactory().factory,
});

await expect(
manager.join({
url: "u",
meetingId: "m-spawn-fail",
conversationId: "c",
}),
).rejects.toThrow(/spawn boom/);

expect(
getMeetSessionEventRouter().resolveBotApiToken("m-spawn-fail"),
).toBeNull();
});

test("rejects a second join for the same meeting id", async () => {
const audioIngestFactory = makeFakeAudioIngestFactory();
const manager = _createMeetSessionManagerForTests({
Expand Down
89 changes: 89 additions & 0 deletions skills/meet-join/daemon/docker-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,74 @@ function requestRaw(
});
}

/**
* Like {@link requestRaw} but returns the raw response bytes instead of a
* UTF-8 string. Used by the container-logs fetcher, which has to look at
* byte-level framing (Docker's multiplexed `{type, size, payload}` wrap).
*/
function requestRawBuffer(
socketPath: string,
method: string,
path: string,
): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
const req = httpRequest(
{
socketPath,
method,
path,
headers: { Host: UNIX_SOCKET_HOST, Accept: "*/*" },
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
const buf = Buffer.concat(chunks);
const status = res.statusCode ?? 0;
if (status < 200 || status >= 300) {
reject(
new DockerApiError(method, path, status, buf.toString("utf8")),
);
return;
}
resolve(buf);
});
},
);
req.on("error", (err) => reject(err));
req.end();
});
}

/**
* Strip Docker's 8-byte multiplexed framing from a logs response body so
* the result reads like the container's combined stdout/stderr would on
* the terminal. Frame format:
*
* ```
* [0] stream type (0=stdin, 1=stdout, 2=stderr)
* [1..3] zero padding
* [4..7] payload size (big-endian uint32)
* [8..] payload bytes
* ```
*
* Any malformed tail (truncated mid-frame) is silently dropped — this is
* a diagnostic helper, not a reliable log pipeline.
*/
export function demultiplexDockerLogs(buf: Buffer): string {
const parts: string[] = [];
let offset = 0;
while (offset + 8 <= buf.length) {
const size = buf.readUInt32BE(offset + 4);
const start = offset + 8;
const end = start + size;
if (end > buf.length) break;
parts.push(buf.subarray(start, end).toString("utf8"));
offset = end;
}
return parts.join("");
}

export class DockerRunner {
readonly socketPath: string;
private readonly resolveMode: () => DaemonRuntimeMode;
Expand Down Expand Up @@ -431,6 +499,27 @@ export class DockerRunner {
);
}

/**
* Fetch the container's accumulated stdout/stderr as a single string.
*
* Wraps `GET /containers/<id>/logs?stdout=1&stderr=1`. The API emits a
* multiplexed framing (8-byte header, then payload) when the container
* was not started with a TTY — we always spawn without TTY, so the
* stream needs demultiplexing before it's human-readable. This is a
* best-effort diagnostic hook called from the rollback path; any
* Docker-side error is wrapped as {@link DockerApiError} so callers can
* swallow it without losing the original join failure.
*/
async logs(
containerId: string,
opts: { tailLines?: number } = {},
): Promise<string> {
const tail = opts.tailLines ?? "all";
const path = `/${DOCKER_API_VERSION}/containers/${containerId}/logs?stdout=1&stderr=1&tail=${tail}`;
const raw = await requestRawBuffer(this.socketPath, "GET", path);
return demultiplexDockerLogs(raw);
}

// -------------------------------------------------------------------------
// Internals
// -------------------------------------------------------------------------
Expand Down
Loading
Loading