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
21 changes: 5 additions & 16 deletions scripts/postinstall.d.mts
Original file line number Diff line number Diff line change
@@ -1,36 +1,25 @@
export function detectPlatformKey(platform?: string, arch?: string): string | null;

export function resolveBinaryPackageName(
platform?: string,
arch?: string
): string | null;

export function findFirstNodeBinary(dirPath: string): string | null;

export function resolveBinaryFromPackage(
packageName: string,
options?: { cwd?: string }
): string;
export function findBundledNativeBinary(nativeDir: string): string | null;

export function selectBinaryForCurrentPlatform(options?: {
platform?: string;
arch?: string;
cwd?: string;
targetNativeDir?: string;
nativeDir?: string;
logger?: { info: (message: string) => void; warn: (message: string) => void };
}):
| {
packageName: string;
sourceBinaryPath: string;
targetBinaryPath: string;
platformKey: string;
binaryPath: string;
}
| null;

export function runPostinstall(options?: {
platform?: string;
arch?: string;
cwd?: string;
targetNativeDir?: string;
nativeDir?: string;
logger?: { info: (message: string) => void; warn: (message: string) => void };
}): boolean;

Expand Down
109 changes: 51 additions & 58 deletions scripts/postinstall.mjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";

const requireFromHere = createRequire(import.meta.url);
/**
* Native-binding provisioning check run at install time.
*
* The napi-rs `.node` binaries ship *inside* `@agent-assembly/sdk` under
* `native/aa-ffi-node/` (see package.json `files` β†’ `native/aa-ffi-node/*.node`)
* and are resolved at load time by `native/aa-ffi-node/index.cjs`. There is no
* separately published per-platform `@agent-assembly/<platform>` binary package
* to install from β€” the declared `@agent-assembly/runtime-*` optionalDependencies
* ship the `aasm` CLI, not a `.node`. The earlier postinstall resolved phantom
* `@agent-assembly/<platformKey>` names that were never published, so it always
* threw, got caught, warned, and no-opped β€” masking a genuine missing-binary
* regression until the loud failure at first native load (AAASM-4137).
*
* This script therefore *verifies* the bundled binding for the current platform
* is present (mirroring `index.cjs` resolution) so a provisioning regression
* fails at install time rather than only at first `initAssembly`.
*/

const SUPPORTED_PLATFORM_KEYS = {
"darwin-arm64": "darwin-arm64",
Expand All @@ -12,90 +27,68 @@ const SUPPORTED_PLATFORM_KEYS = {
"win32-x64": "win32-x64-msvc"
};

const NATIVE_BINDING_DIR = "native/aa-ffi-node";

export function detectPlatformKey(platform = process.platform, arch = process.arch) {
return SUPPORTED_PLATFORM_KEYS[`${platform}-${arch}`] ?? null;
}

export function resolveBinaryPackageName(platform = process.platform, arch = process.arch) {
const platformKey = detectPlatformKey(platform, arch);

if (!platformKey) {
return null;
/**
* Locate the bundled native binding inside `native/aa-ffi-node/`, mirroring the
* runtime loader in `native/aa-ffi-node/index.cjs`: prefer the exact
* `index.node`, otherwise the first platform-suffixed `index.<triple>.node`.
* Returns the absolute path, or `null` when no binding is present.
*/
export function findBundledNativeBinary(nativeDir) {
const directPath = path.join(nativeDir, "index.node");
if (fs.existsSync(directPath)) {
return directPath;
}

return `@agent-assembly/${platformKey}`;
}

export function findFirstNodeBinary(dirPath) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });

for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);

if (entry.isDirectory()) {
const nested = findFirstNodeBinary(entryPath);
if (nested) {
return nested;
}
}

if (entry.isFile() && entry.name.endsWith(".node")) {
return entryPath;
}
if (!fs.existsSync(nativeDir)) {
return null;
}

return null;
}

export function resolveBinaryFromPackage(
packageName,
options = {}
) {
const { cwd = process.cwd() } = options;

const packageJsonPath = requireFromHere.resolve(`${packageName}/package.json`, {
paths: [cwd]
});
const packageDir = path.dirname(packageJsonPath);
const binaryPath = findFirstNodeBinary(packageDir);

if (!binaryPath) {
throw new Error(`No .node file found in ${packageName}`);
}
const platformAddon = fs
.readdirSync(nativeDir)
.find((name) => /^index\..+\.node$/.test(name));

return binaryPath;
return platformAddon ? path.join(nativeDir, platformAddon) : null;
}

export function selectBinaryForCurrentPlatform(options = {}) {
const {
platform = process.platform,
arch = process.arch,
cwd = process.cwd(),
targetNativeDir = path.resolve(cwd, "native/aa-ffi-node"),
nativeDir = path.resolve(cwd, NATIVE_BINDING_DIR),
logger = console
} = options;

const packageName = resolveBinaryPackageName(platform, arch);
const platformKey = detectPlatformKey(platform, arch);

if (!packageName) {
if (!platformKey) {
logger.warn(
`[agent-assembly] Unsupported platform: ${platform}-${arch}; skipping binary selection.`
`[agent-assembly] Unsupported platform: ${platform}-${arch}; skipping native binding check.`
);
return null;
}

const sourceBinaryPath = resolveBinaryFromPackage(packageName, { cwd });
const targetBinaryPath = path.join(targetNativeDir, "index.node");
const binaryPath = findBundledNativeBinary(nativeDir);

fs.mkdirSync(targetNativeDir, { recursive: true });
fs.copyFileSync(sourceBinaryPath, targetBinaryPath);
if (!binaryPath) {
throw new Error(
`No bundled native binding (.node) found in ${NATIVE_BINDING_DIR} for ${platformKey}`
);
}

logger.info(`[agent-assembly] Selected binary package ${packageName}`);
logger.info(
`[agent-assembly] Native binding present for ${platformKey}: ${path.basename(binaryPath)}`
);

return {
packageName,
sourceBinaryPath,
targetBinaryPath
platformKey,
binaryPath
};
}

Expand All @@ -107,7 +100,7 @@ export function runPostinstall(options = {}) {
return true;
} catch (error) {
logger.warn(
`[agent-assembly] Failed to select native binary: ${error instanceof Error ? error.message : String(error)}`
`[agent-assembly] Failed to verify native binding: ${error instanceof Error ? error.message : String(error)}`
);
return false;
}
Expand Down
37 changes: 16 additions & 21 deletions src/hooks/ai-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,17 @@ export function createWrappedExecute<TArgs, TResult>(
return options.agentId ? runWithAgentId(options.agentId, run) : run();
};

let decision;
try {
decision = await gatewayClient.check({
action: "tool_call",
toolName: description,
args,
runId
});
} catch {
return executeOriginal();
}
// Let check / approval faults propagate (reject) instead of swallowing them
// into a silent fail-open. A caller-supplied gatewayClient that throws on a
// transport error must NOT be treated as ALLOW β€” this matches the
// with-assembly / wrap-tool wrappers, whose un-caught check() rejects and
// blocks the tool under enforce (AAASM-4137).
const decision = await gatewayClient.check({
action: "tool_call",
toolName: description,
args,
runId
});

if (decision.denied) {
throw new PolicyViolationError(
Expand All @@ -105,16 +105,11 @@ export function createWrappedExecute<TArgs, TResult>(
}

if (decision.pending) {
let approval;
try {
approval = await gatewayClient.waitForApproval(
description,
runId,
options.approvalTimeoutMs
);
} catch {
return executeOriginal();
}
const approval = await gatewayClient.waitForApproval(
description,
runId,
options.approvalTimeoutMs
);
if (approval.denied) {
throw new PolicyViolationError(
`Approval rejected: ${approval.reason ?? "Rejected"}`
Expand Down
37 changes: 16 additions & 21 deletions src/hooks/openai-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,17 @@ export function createPatchedRunTool(
return result;
};

let decision;
try {
decision = await gatewayClient.check({
action: "tool_call",
toolName,
args,
runId
});
} catch {
return executeOriginal();
}
// Let check / approval faults propagate (reject) instead of swallowing them
// into a silent fail-open. A caller-supplied gatewayClient that throws on a
// transport error must NOT be treated as ALLOW β€” this matches the
// with-assembly / wrap-tool wrappers, whose un-caught check() rejects and
// blocks the tool under enforce (AAASM-4137).
const decision = await gatewayClient.check({
action: "tool_call",
toolName,
args,
runId
});

if (decision.denied) {
return formatDeniedToolCallOutput(
Expand All @@ -150,16 +150,11 @@ export function createPatchedRunTool(
}

if (decision.pending) {
let pendingOutput;
try {
pendingOutput = await handlePendingApproval(gatewayClient, {
toolName,
runId,
timeoutMs: options.approvalTimeoutMs
});
} catch {
return executeOriginal();
}
const pendingOutput = await handlePendingApproval(gatewayClient, {
toolName,
runId,
timeoutMs: options.approvalTimeoutMs
});
if (pendingOutput) {
return pendingOutput;
}
Expand Down
22 changes: 12 additions & 10 deletions tests/openai-agents-hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ describe("openai agents adapter", () => {
});
});

it("fails open and executes original tool when gateway check throws", async () => {
it("propagates the fault and blocks the tool when gateway check throws", async () => {
const gateway = createGatewayClientMock();
gateway.check = vi.fn(async () => {
throw new Error("gateway unavailable");
Expand All @@ -284,7 +284,7 @@ describe("openai agents adapter", () => {
approvalTimeoutMs: 4_000
});

const result = await patchedRunTool(
const error = await patchedRunTool(
{
function: {
name: "critical_tool",
Expand All @@ -294,13 +294,14 @@ describe("openai agents adapter", () => {
{
runId: "run-6"
}
);
).catch((e: Error) => e);

expect(result).toEqual({ ok: "fallback-path" });
expect(originalRunTool).toHaveBeenCalledTimes(1);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("gateway unavailable");
expect(originalRunTool).not.toHaveBeenCalled();
});

it("fails open and executes original tool when approval handling throws on PENDING", async () => {
it("propagates the fault and blocks the tool when approval handling throws on PENDING", async () => {
const gateway = createGatewayClientMock();
gateway.check = vi.fn(async () => ({ pending: true, denied: false }));
gateway.waitForApproval = vi.fn(async () => {
Expand All @@ -314,13 +315,14 @@ describe("openai agents adapter", () => {
approvalTimeoutMs: 2_000
});

const result = await patchedRunTool(
const error = await patchedRunTool(
{ function: { name: "pending_tool", arguments: "{}" } },
{ runId: "run-pending-throw" }
);
).catch((e: Error) => e);

expect(result).toEqual({ ok: "approval-fail-open" });
expect(originalRunTool).toHaveBeenCalledTimes(1);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe("approval channel unavailable");
expect(originalRunTool).not.toHaveBeenCalled();
});

it("returns true without re-patching when already patched", async () => {
Expand Down
Loading
Loading