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
9 changes: 9 additions & 0 deletions .changeset/ordinary-session-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@sapiom/harness": minor
---

Track ordinary session input delivery and runtime ownership so partial input, preemption, stale ingest events, and shutdown are handled consistently.

Background input now yields to terminal keystrokes received while its durable pre-write hook is pending. A submission displaced before writing compensates its durable claim so it can be recovered safely.

**Breaking for embedders** (minor while the package is pre-1.0): `SessionManager.write()` can throw an isolation error with code `SESSION_INPUT_ISOLATION_REQUIRED` when prior partial input cannot be cleared. Callers forwarding terminal bytes should handle this failure and keep the terminal available for a later retry instead of assuming every call returns a boolean.
32 changes: 22 additions & 10 deletions packages/harness/src/core/ingest-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,38 @@ import { IngestCredentialRegistry } from "./ingest-credentials.js";
describe("IngestCredentialRegistry", () => {
it("binds an opaque credential to exactly one session", () => {
const tokens = ["token-a", "token-b"];
const registry = new IngestCredentialRegistry(() => tokens.shift()!);
const epochs = ["epoch-a", "epoch-b"];
const registry = new IngestCredentialRegistry(
() => tokens.shift()!,
() => epochs.shift()!,
);
const first = registry.issue("session-a");
const second = registry.issue("session-b");

expect(registry.authenticate("session-a", first)).toBe(true);
expect(registry.authenticate("session-b", second)).toBe(true);
expect(registry.authenticate("session-b", first)).toBe(false);
expect(registry.authenticate("session-a", second)).toBe(false);
expect(registry.authenticate("unknown", first)).toBe(false);
expect(first).toEqual({ token: "token-a", runtimeEpoch: "epoch-a" });
expect(second).toEqual({ token: "token-b", runtimeEpoch: "epoch-b" });
expect(registry.authenticate("session-a", first.token)).toBe("epoch-a");
expect(registry.authenticate("session-b", second.token)).toBe("epoch-b");
expect(registry.authenticate("session-b", first.token)).toBeNull();
expect(registry.authenticate("session-a", second.token)).toBeNull();
expect(registry.authenticate("unknown", first.token)).toBeNull();
});

it("rotates on a new launch and revokes on terminal cleanup", () => {
const tokens = ["old-token", "new-token"];
const registry = new IngestCredentialRegistry(() => tokens.shift()!);
const epochs = ["old-epoch", "new-epoch"];
const registry = new IngestCredentialRegistry(
() => tokens.shift()!,
() => epochs.shift()!,
);
const oldToken = registry.issue("session-a");
const newToken = registry.issue("session-a");

expect(registry.authenticate("session-a", oldToken)).toBe(false);
expect(registry.authenticate("session-a", newToken)).toBe(true);
expect(registry.authenticate("session-a", oldToken.token)).toBeNull();
expect(registry.authenticate("session-a", newToken.token)).toBe(
"new-epoch",
);
registry.revoke("session-a");
expect(registry.authenticate("session-a", newToken)).toBe(false);
expect(registry.authenticate("session-a", newToken.token)).toBeNull();
});
});
45 changes: 32 additions & 13 deletions packages/harness/src/core/ingest-credentials.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";

export interface IngestCredentialProvider {
/** Issues a new opaque capability and invalidates any prior one for this id. */
issue(sessionId: string): string;
authenticate(sessionId: string, token: string): boolean;
/** Issues a new opaque capability/runtime epoch and invalidates any prior one. */
issue(sessionId: string): IssuedIngestCredential;
/** Returns the server-owned runtime epoch for this exact capability. */
authenticate(sessionId: string, token: string): string | null;
revoke(sessionId: string): void;
}

export interface IssuedIngestCredential {
token: string;
/** Opaque process-local identity for the PTY generation receiving `token`. */
runtimeEpoch: string;
}

const EMPTY_DIGEST = Buffer.alloc(32);

function digest(token: string): Buffer {
Expand All @@ -20,31 +27,43 @@ function digest(token: string): Buffer {
* the registry intentionally does not persist.
*/
export class IngestCredentialRegistry implements IngestCredentialProvider {
private readonly digests = new Map<string, Buffer>();
private readonly credentials = new Map<
string,
{ digest: Buffer; runtimeEpoch: string }
>();

constructor(
private readonly generateToken: () => string = () =>
randomBytes(32).toString("base64url"),
private readonly generateRuntimeEpoch: () => string = () =>
randomBytes(16).toString("base64url"),
) {}

issue(sessionId: string): string {
issue(sessionId: string): IssuedIngestCredential {
if (!sessionId) throw new Error("ingest credential requires a session id");
const token = this.generateToken();
if (!token || token.length > 512) {
throw new Error("invalid generated ingest credential");
}
this.digests.set(sessionId, digest(token));
return token;
const runtimeEpoch = this.generateRuntimeEpoch();
if (!runtimeEpoch || runtimeEpoch.length > 128) {
throw new Error("invalid generated ingest runtime epoch");
}
this.credentials.set(sessionId, { digest: digest(token), runtimeEpoch });
return { token, runtimeEpoch };
}

authenticate(sessionId: string, token: string): boolean {
if (!sessionId || !token || token.length > 512) return false;
const expected = this.digests.get(sessionId);
const matches = timingSafeEqual(expected ?? EMPTY_DIGEST, digest(token));
return expected !== undefined && matches;
authenticate(sessionId: string, token: string): string | null {
if (!sessionId || !token || token.length > 512) return null;
const expected = this.credentials.get(sessionId);
const matches = timingSafeEqual(
expected?.digest ?? EMPTY_DIGEST,
digest(token),
);
return expected !== undefined && matches ? expected.runtimeEpoch : null;
}

revoke(sessionId: string): void {
this.digests.delete(sessionId);
this.credentials.delete(sessionId);
}
}
Loading
Loading