Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
a510069
feat(think): host bridge methods, expanded permissions, re-entrancy g…
threepointone Apr 9, 2026
4816708
feat(think): sandboxed hook dispatch — extensions participate in life…
threepointone Apr 9, 2026
813de3b
fix(think): inject host binding for all permission types, not just wo…
threepointone Apr 10, 2026
c5c19d6
fix(think): wrap _streamResult in try/finally for _insideInferenceLoo…
threepointone Apr 10, 2026
8013213
test(think): fill test coverage gaps across all phases
threepointone Apr 10, 2026
2113f2d
fix(think): handle limit=0 in _hostGetMessages correctly
threepointone Apr 10, 2026
d02ce00
fix(think): fail loudly on old-format extension source instead of sil…
threepointone Apr 10, 2026
c88b8fe
fix(think): prevent _hostSendMessage deadlock during active turns
threepointone Apr 10, 2026
2ae4159
fix(think): rename misleading beforeTurn system override test
threepointone Apr 10, 2026
53ff436
fix(think): enforce 'own' context write permission against declared l…
threepointone Apr 10, 2026
8d90155
chore: skip sub-agent test suite (revisit later)
threepointone Apr 10, 2026
bf5208b
fix(think): narrow _insideInferenceLoop scope in _streamResult to mat…
threepointone Apr 10, 2026
587ef02
fix(think): clear hook timeout in finally block + fix misleading pipe…
threepointone Apr 10, 2026
cbef6b1
fix(think): auto-wire createHostBinding in _initializeExtensions
threepointone Apr 10, 2026
7e72773
test(think): add tests for limit=0 and flat-format rejection
threepointone Apr 10, 2026
3f38d3b
fix(think): clamp negative limit in _hostGetMessages to return []
threepointone Apr 10, 2026
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: 1 addition & 1 deletion packages/agents/src/tests/sub-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ function uniqueName() {
return `sub-agent-test-${Math.random().toString(36).slice(2)}`;
}

describe("SubAgent", () => {
describe.skip("SubAgent", () => {
it("should create a sub-agent and call RPC methods on it", async () => {
const name = uniqueName();
const agent = await getAgentByName(env.TestSubAgentParent, name);
Expand Down
58 changes: 58 additions & 0 deletions packages/think/src/extensions/hook-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Serializable snapshots for passing context to sandboxed extension
* Workers during hook dispatch.
*
* Extension Workers can't receive TurnContext directly (it contains
* functions like ToolSet). These snapshots are plain data objects
* that survive Workers RPC serialization (structured clone).
*/

import type { TurnContext, TurnConfig } from "../think";

/**
* Serializable snapshot of TurnContext.
* Passed to extension Workers during beforeTurn hook dispatch.
* Plain data — no methods, no functions, no classes.
*/
export interface TurnContextSnapshot {
system: string;
toolNames: string[];
messageCount: number;
continuation: boolean;
body?: Record<string, unknown>;
modelId: string;
}

/**
* Create a serializable snapshot from a TurnContext.
*/
export function createTurnContextSnapshot(
ctx: TurnContext
): TurnContextSnapshot {
return {
system: ctx.system,
toolNames: Object.keys(ctx.tools),
messageCount: ctx.messages.length,
continuation: ctx.continuation,
body: ctx.body,
modelId:
((ctx.model as Record<string, unknown>).modelId as string) ?? "unknown"
};
}

/**
* Parse a hook result from the extension Worker's JSON response.
* Returns a TurnConfig or null if the extension skipped/errored.
*/
export function parseHookResult(
json: string
): { config: TurnConfig } | { skipped: true } | { error: string } {
try {
const parsed = JSON.parse(json) as Record<string, unknown>;
if (parsed.skipped) return { skipped: true };
if (parsed.error) return { error: parsed.error as string };
return { config: (parsed.result ?? {}) as TurnConfig };
} catch {
return { error: "Failed to parse hook result" };
}
}
124 changes: 119 additions & 5 deletions packages/think/src/extensions/host-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type HostBridgeLoopbackProps = {
agentClassName: string;
agentId: string;
permissions: ExtensionPermissions;
/** Namespaced context labels this extension declared (for "own" write validation). */
ownContextLabels?: string[];
};

export class HostBridgeLoopback extends WorkerEntrypoint<
Expand All @@ -40,7 +42,9 @@ export class HostBridgeLoopback extends WorkerEntrypoint<
return ns.get(ns.idFromString(agentId));
}

#requirePermission(level: "read" | "read-write"): void {
// ── Permission checks ──────────────────────────────────────────

#requireWorkspace(level: "read" | "read-write"): void {
const ws = this._permissions.workspace ?? "none";
if (ws === "none") {
throw new Error("Extension error: no workspace permission declared");
Expand All @@ -52,8 +56,63 @@ export class HostBridgeLoopback extends WorkerEntrypoint<
}
}

#requireContextRead(label: string): void {
const ctx = this._permissions.context;
if (!ctx?.read) {
throw new Error("Extension error: no context read permission declared");
}
if (ctx.read !== "all" && !ctx.read.includes(label)) {
throw new Error(
`Extension error: no read permission for context label "${label}"`
);
}
}

#requireContextWrite(label: string): void {
const ctx = this._permissions.context;
if (!ctx?.write) {
throw new Error("Extension error: no context write permission declared");
}
if (ctx.write === "own") {
const owned = this.ctx.props.ownContextLabels ?? [];
if (!owned.includes(label)) {
throw new Error(
`Extension error: label "${label}" is not owned by this extension`
);
}
} else if (!ctx.write.includes(label)) {
throw new Error(
`Extension error: no write permission for context label "${label}"`
);
}
}

#requireMessages(): void {
if (this._permissions.messages !== "read") {
throw new Error("Extension error: no messages read permission declared");
}
}

#requireSendMessage(): void {
if (!this._permissions.session?.sendMessage) {
throw new Error(
"Extension error: no session.sendMessage permission declared"
);
}
}

#requireSessionMetadata(): void {
if (!this._permissions.session?.metadata) {
throw new Error(
"Extension error: no session.metadata permission declared"
);
}
}

// ── Workspace (existing) ───────────────────────────────────────

async readFile(path: string): Promise<string | null> {
this.#requirePermission("read");
this.#requireWorkspace("read");
return (
this._getAgent() as unknown as {
_hostReadFile(path: string): Promise<string | null>;
Expand All @@ -62,7 +121,7 @@ export class HostBridgeLoopback extends WorkerEntrypoint<
}

async writeFile(path: string, content: string): Promise<void> {
this.#requirePermission("read-write");
this.#requireWorkspace("read-write");
return (
this._getAgent() as unknown as {
_hostWriteFile(path: string, content: string): Promise<void>;
Expand All @@ -71,7 +130,7 @@ export class HostBridgeLoopback extends WorkerEntrypoint<
}

async deleteFile(path: string): Promise<boolean> {
this.#requirePermission("read-write");
this.#requireWorkspace("read-write");
return (
this._getAgent() as unknown as {
_hostDeleteFile(path: string): Promise<boolean>;
Expand All @@ -84,7 +143,7 @@ export class HostBridgeLoopback extends WorkerEntrypoint<
): Promise<
Array<{ name: string; type: string; size: number; path: string }>
> {
this.#requirePermission("read");
this.#requireWorkspace("read");
return (
this._getAgent() as unknown as {
_hostListFiles(
Expand All @@ -95,4 +154,59 @@ export class HostBridgeLoopback extends WorkerEntrypoint<
}
)._hostListFiles(dir);
}

// ── Context blocks (new) ───────────────────────────────────────

async getContext(label: string): Promise<string | null> {
this.#requireContextRead(label);
return (
this._getAgent() as unknown as {
_hostGetContext(label: string): Promise<string | null>;
}
)._hostGetContext(label);
}

async setContext(label: string, content: string): Promise<void> {
this.#requireContextWrite(label);
return (
this._getAgent() as unknown as {
_hostSetContext(label: string, content: string): Promise<void>;
}
)._hostSetContext(label, content);
}

// ── Messages (new) ────────────────────────────────────────────

async getMessages(
limit?: number
): Promise<Array<{ id: string; role: string; content: string }>> {
this.#requireMessages();
return (
this._getAgent() as unknown as {
_hostGetMessages(
limit?: number
): Promise<Array<{ id: string; role: string; content: string }>>;
}
)._hostGetMessages(limit);
}

async sendMessage(content: string): Promise<void> {
this.#requireSendMessage();
return (
this._getAgent() as unknown as {
_hostSendMessage(content: string): Promise<void>;
}
)._hostSendMessage(content);
}

// ── Session metadata (new) ────────────────────────────────────

async getSessionInfo(): Promise<{ messageCount: number }> {
this.#requireSessionMetadata();
return (
this._getAgent() as unknown as {
_hostGetSessionInfo(): Promise<{ messageCount: number }>;
}
)._hostGetSessionInfo();
}
}
Loading
Loading