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
2 changes: 1 addition & 1 deletion extensions/agentcore/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const plugin = {
configSource: {
ssmPrefix,
region,
localOverride: pluginConfig.endpoint ? { endpoint: pluginConfig.endpoint } : undefined,
endpointOverride: pluginConfig.endpoint,
},
}),
);
Expand Down
37 changes: 37 additions & 0 deletions extensions/agentcore/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,43 @@ describe("loadAgentCoreConfig", () => {
});
});

// ── endpointOverride (loads from SSM, applies endpoint after) ───────

describe("endpointOverride", () => {
it("applies endpoint override without skipping SSM", async () => {
mockSsmSend.mockImplementation((cmd: any) => {
const name = cmd.input?.Name;
if (name?.endsWith("/runtime-arns")) {
return {
Parameter: {
Value: '["arn:aws:agentcore:us-west-2:123:runtime/real"]',
},
};
}
return { Parameter: { Value: null } };
});

const config = await loadAgentCoreConfig({
ssmPrefix: "/hyperion/beta/agentcore",
endpointOverride: "https://localhost:9999",
});

expect(config.runtimeArns).toEqual(["arn:aws:agentcore:us-west-2:123:runtime/real"]);
expect(config.endpoint).toBe("https://localhost:9999");
expect(mockSsmSend).toHaveBeenCalled();
});

it("does not set endpoint when endpointOverride is undefined", async () => {
mockSsmSend.mockResolvedValue({ Parameter: { Value: null } });

const config = await loadAgentCoreConfig({
ssmPrefix: "/hyperion/beta/agentcore",
});

expect(config.endpoint).toBeUndefined();
});
});

// ── SSM error handling ──────────────────────────────────────────────

describe("SSM error handling", () => {
Expand Down
3 changes: 3 additions & 0 deletions extensions/agentcore/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export type AgentCoreConfigSource = {
region?: string;
/** Override for local development (skip SSM, use provided config). */
localOverride?: Partial<AgentCoreRuntimeConfig>;
/** AgentCore endpoint override — applied after SSM loading (does NOT skip SSM). */
endpointOverride?: string;
};

/**
Expand Down Expand Up @@ -73,6 +75,7 @@ export async function loadAgentCoreConfig(
runtimeArns,
memoryNamespacePrefix,
defaultModel,
...(source.endpointOverride ? { endpoint: source.endpointOverride } : {}),
};
}

Expand Down
6 changes: 4 additions & 2 deletions extensions/agentcore/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,10 @@ export class AgentCoreRuntime implements AcpRuntime {

const runtimeArn = pickRuntimeArn(this.config.runtimeArns);

// For Hyperion, the OC agentId IS the tenant user_id.
const tenantId = agent;
// [claude-infra] Derive tenant user_id from the Hyperion session key
// (format: "tenant_{userId}:{agentId}:{rest}"), not from `agent` which
// may be a shared logical name like "main" across different tenants.
const tenantId = extractTenantId(sessionKey) ?? agent;
// [claude-infra] Multi-instance: extract agent instance ID from session key.
const agentId = extractAgentId(sessionKey);

Expand Down
1 change: 1 addition & 0 deletions extensions/hyperion/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function createHyperionPluginService(
dynamoConfig,
docClient,
kmsClient,
defaultConfig: ctx.config,
});

setHyperionRuntime(runtime);
Expand Down
13 changes: 8 additions & 5 deletions extensions/nova/src/credentials.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { NovaConfig } from "./types.js";
import { resolveNovaCredentials } from "./credentials.js";
import type { NovaConfig } from "./types.js";

describe("resolveNovaCredentials", () => {
const savedEnv: Record<string, string | undefined> = {};
Expand All @@ -17,10 +17,13 @@ describe("resolveNovaCredentials", () => {
});

afterEach(() => {
process.env.NOVA_BASE_URL = savedEnv.NOVA_BASE_URL;
process.env.NOVA_API_KEY = savedEnv.NOVA_API_KEY;
process.env.NOVA_USER_ID = savedEnv.NOVA_USER_ID;
process.env.NOVA_DEVICE_ID = savedEnv.NOVA_DEVICE_ID;
for (const [key, val] of Object.entries(savedEnv)) {
if (val === undefined) {
delete process.env[key];
} else {
process.env[key] = val;
}
}
});

it("resolves credentials from config", () => {
Expand Down
33 changes: 33 additions & 0 deletions src/hyperion/dynamodb-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,39 @@ export class HyperionDynamoDBClient {
);
}

/**
* Atomically consume a pairing code: deletes it only if it still exists.
* Returns the deleted item, or null if it was already consumed.
* Uses ConditionExpression to prevent double-redemption races.
*/
async consumePairingCode(code: string): Promise<PairingCode | null> {
const { DeleteCommand } = await import("@aws-sdk/lib-dynamodb");
try {
const result = await this.docClient.send(
new DeleteCommand({
TableName: this.config.pairingCodesTableName,
Key: { code },
ConditionExpression: "attribute_exists(code)",
ReturnValues: "ALL_OLD",
}),
);
const item = (result as { Attributes?: PairingCode }).Attributes;
if (!item) {
return null;
}
// DynamoDB TTL is eventually consistent — check expiry explicitly.
if (item.expires_at <= Math.floor(Date.now() / 1000)) {
return null;
}
return item;
} catch (err) {
if ((err as { name?: string }).name === "ConditionalCheckFailedException") {
return null;
}
throw err;
}
}

// -- User Credentials -- [claude-infra] composite key: user_id + agent_id

async getUserCredentials(
Expand Down
32 changes: 8 additions & 24 deletions src/hyperion/pairing-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ function createMockDbClient() {
return {
putPairingCode: vi.fn(),
getPairingCode: vi.fn(),
consumePairingCode: vi.fn(),
deletePairingCode: vi.fn(),
putChannelLink: vi.fn(),
deleteChannelLink: vi.fn(),
} as unknown as HyperionDynamoDBClient & {
putPairingCode: ReturnType<typeof vi.fn>;
getPairingCode: ReturnType<typeof vi.fn>;
consumePairingCode: ReturnType<typeof vi.fn>;
deletePairingCode: ReturnType<typeof vi.fn>;
putChannelLink: ReturnType<typeof vi.fn>;
deleteChannelLink: ReturnType<typeof vi.fn>;
Expand Down Expand Up @@ -105,9 +107,8 @@ describe("HyperionPairingStore", () => {
};

it("creates ChannelLink with correct data, inherits agent_id from pairing code", async () => {
dbClient.getPairingCode.mockResolvedValueOnce(basePairingCode);
dbClient.consumePairingCode.mockResolvedValueOnce(basePairingCode);
dbClient.putChannelLink.mockResolvedValueOnce(undefined);
dbClient.deletePairingCode.mockResolvedValueOnce(undefined);

const link = await store.redeemPairingCode({
code: "ABCD1234",
Expand All @@ -129,17 +130,16 @@ describe("HyperionPairingStore", () => {
});

it("normalizes code to uppercase", async () => {
dbClient.getPairingCode.mockResolvedValueOnce(basePairingCode);
dbClient.consumePairingCode.mockResolvedValueOnce(basePairingCode);
dbClient.putChannelLink.mockResolvedValueOnce(undefined);
dbClient.deletePairingCode.mockResolvedValueOnce(undefined);

await store.redeemPairingCode({
code: " abcd1234 ",
platform: "telegram",
platformUserId: "tg-user-99",
});

expect(dbClient.getPairingCode).toHaveBeenCalledWith("ABCD1234");
expect(dbClient.consumePairingCode).toHaveBeenCalledWith("ABCD1234");
});

it("returns null for empty code", async () => {
Expand All @@ -153,8 +153,8 @@ describe("HyperionPairingStore", () => {
expect(dbClient.getPairingCode).not.toHaveBeenCalled();
});

it("returns null if pairing code not found", async () => {
dbClient.getPairingCode.mockResolvedValueOnce(null);
it("returns null if pairing code not found (already consumed)", async () => {
dbClient.consumePairingCode.mockResolvedValueOnce(null);

const link = await store.redeemPairingCode({
code: "NONEXIST",
Expand All @@ -167,7 +167,7 @@ describe("HyperionPairingStore", () => {
});

it("returns null if platform doesn't match", async () => {
dbClient.getPairingCode.mockResolvedValueOnce(basePairingCode);
dbClient.consumePairingCode.mockResolvedValueOnce(basePairingCode);

const link = await store.redeemPairingCode({
code: "ABCD1234",
Expand All @@ -178,22 +178,6 @@ describe("HyperionPairingStore", () => {
expect(link).toBeNull();
expect(dbClient.putChannelLink).not.toHaveBeenCalled();
});

it("deletes consumed code (best effort)", async () => {
dbClient.getPairingCode.mockResolvedValueOnce(basePairingCode);
dbClient.putChannelLink.mockResolvedValueOnce(undefined);
dbClient.deletePairingCode.mockRejectedValueOnce(new Error("Delete failed"));

const link = await store.redeemPairingCode({
code: "ABCD1234",
platform: "telegram",
platformUserId: "tg-user-99",
});

// Link should still be returned even though delete failed
expect(link).not.toBeNull();
expect(dbClient.deletePairingCode).toHaveBeenCalledWith("ABCD1234");
});
});

describe("validatePairingCode", () => {
Expand Down
8 changes: 3 additions & 5 deletions src/hyperion/pairing-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,9 @@ export class HyperionPairingStore {
return null;
}

// Fetch and validate the pairing code.
const pairingCode = await this.dbClient.getPairingCode(normalizedCode);
// Atomically consume the pairing code — conditional delete ensures only one
// concurrent redeem succeeds, preventing double-bind race conditions.
const pairingCode = await this.dbClient.consumePairingCode(normalizedCode);
if (!pairingCode) {
return null;
}
Expand All @@ -118,9 +119,6 @@ export class HyperionPairingStore {

await this.dbClient.putChannelLink(channelLink);

// Delete the consumed code (best-effort — TTL will clean up regardless).
await this.dbClient.deletePairingCode(normalizedCode).catch(() => {});

return channelLink;
}

Expand Down
20 changes: 13 additions & 7 deletions src/hyperion/tenant-config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,23 +215,29 @@ export class TenantConfigLoader {
}

// Inject per-user tool API keys from encrypted credential store.
// Each search provider resolves its key from a provider-specific path:
// brave_search → tools.web.search.apiKey (default/brave)
// gemini/grok/kimi/perplexity → tools.web.search.<provider>.apiKey
if (credentials?.tool_keys) {
const web = config.tools?.web ?? {};
const search = web.search ?? {};
const web = { ...config.tools?.web };
const search = { ...web.search } as Record<string, unknown>;
for (const [toolName, apiKey] of Object.entries(credentials.tool_keys)) {
if (
toolName === "brave_search" ||
if (toolName === "brave_search") {
search.apiKey = apiKey;
} else if (
toolName === "gemini" ||
toolName === "grok" ||
toolName === "kimi" ||
toolName === "perplexity"
) {
config.tools = {
...config.tools,
web: { ...web, search: { ...search, apiKey } },
search[toolName] = {
...(search[toolName] as Record<string, unknown> | undefined),
apiKey,
};
}
}
web.search = search;
config.tools = { ...config.tools, web };
}

// Apply tenant-level skill permissions.
Expand Down
Loading