Add runtime event telemetry and auth-mode config hardening
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildClaudeAuthEnv, loadConfig, resolveAnthropicToken } from "../src/config.js";
|
||||
import {
|
||||
buildClaudeAuthEnv,
|
||||
loadConfig,
|
||||
resolveAnthropicToken,
|
||||
resolveOpenAiApiKey,
|
||||
} from "../src/config.js";
|
||||
|
||||
test("loads defaults and freezes config", () => {
|
||||
const config = loadConfig({});
|
||||
@@ -11,10 +16,25 @@ test("loads defaults and freezes config", () => {
|
||||
assert.equal(config.discovery.fileRelativePath, ".agent-context/resources.json");
|
||||
assert.equal(config.security.violationHandling, "hard_abort");
|
||||
assert.equal(config.security.commandTimeoutMs, 120000);
|
||||
assert.equal(config.runtimeEvents.logPath, ".ai_ops/events/runtime-events.ndjson");
|
||||
assert.equal(config.runtimeEvents.discordMinSeverity, "critical");
|
||||
assert.deepEqual(config.runtimeEvents.discordAlwaysNotifyTypes, [
|
||||
"session.started",
|
||||
"session.completed",
|
||||
"session.failed",
|
||||
]);
|
||||
assert.equal(config.provider.openAiAuthMode, "auto");
|
||||
assert.equal(Object.isFrozen(config), true);
|
||||
assert.equal(Object.isFrozen(config.orchestration), true);
|
||||
});
|
||||
|
||||
test("validates OPENAI_AUTH_MODE values", () => {
|
||||
assert.throws(
|
||||
() => loadConfig({ OPENAI_AUTH_MODE: "oauth" }),
|
||||
/OPENAI_AUTH_MODE must be one of/,
|
||||
);
|
||||
});
|
||||
|
||||
test("validates boolean env values", () => {
|
||||
assert.throws(
|
||||
() => loadConfig({ CODEX_SKIP_GIT_CHECK: "maybe" }),
|
||||
@@ -29,6 +49,13 @@ test("validates security violation mode", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("validates runtime discord severity mode", () => {
|
||||
assert.throws(
|
||||
() => loadConfig({ AGENT_RUNTIME_DISCORD_MIN_SEVERITY: "verbose" }),
|
||||
/Runtime event severity/,
|
||||
);
|
||||
});
|
||||
|
||||
test("prefers CLAUDE_CODE_OAUTH_TOKEN over ANTHROPIC_API_KEY", () => {
|
||||
const config = loadConfig({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "oauth-token",
|
||||
@@ -57,3 +84,23 @@ test("falls back to ANTHROPIC_API_KEY when oauth token is absent", () => {
|
||||
assert.equal(authEnv.CLAUDE_CODE_OAUTH_TOKEN, undefined);
|
||||
assert.equal(authEnv.ANTHROPIC_API_KEY, "api-key");
|
||||
});
|
||||
|
||||
test("resolveOpenAiApiKey respects chatgpt auth mode", () => {
|
||||
const config = loadConfig({
|
||||
OPENAI_AUTH_MODE: "chatgpt",
|
||||
CODEX_API_KEY: "codex-key",
|
||||
OPENAI_API_KEY: "openai-key",
|
||||
});
|
||||
|
||||
assert.equal(resolveOpenAiApiKey(config.provider), undefined);
|
||||
});
|
||||
|
||||
test("resolveOpenAiApiKey prefers CODEX_API_KEY in auto mode", () => {
|
||||
const config = loadConfig({
|
||||
OPENAI_AUTH_MODE: "auto",
|
||||
CODEX_API_KEY: "codex-key",
|
||||
OPENAI_API_KEY: "openai-key",
|
||||
});
|
||||
|
||||
assert.equal(resolveOpenAiApiKey(config.provider), "codex-key");
|
||||
});
|
||||
|
||||
@@ -107,6 +107,53 @@ test("runCodexPrompt wires client options and parses final output", async () =>
|
||||
assert.equal(closed, true);
|
||||
});
|
||||
|
||||
test("runCodexPrompt omits apiKey when OPENAI_AUTH_MODE=chatgpt", async () => {
|
||||
const config = loadConfig({
|
||||
OPENAI_AUTH_MODE: "chatgpt",
|
||||
CODEX_API_KEY: "codex-token",
|
||||
OPENAI_API_KEY: "openai-token",
|
||||
OPENAI_BASE_URL: "https://api.example.com/v1",
|
||||
});
|
||||
|
||||
let capturedClientInput: Record<string, unknown> | undefined;
|
||||
|
||||
const sessionContext: SessionContext = {
|
||||
provider: "codex",
|
||||
sessionId: "session-codex-chatgpt",
|
||||
mcp: {},
|
||||
promptWithContext: "prompt with context",
|
||||
runtimeInjection: {
|
||||
workingDirectory: "/tmp/worktree",
|
||||
env: {
|
||||
HOME: "/home/tester",
|
||||
},
|
||||
discoveryFilePath: "/tmp/worktree/.agent-context/resources.json",
|
||||
},
|
||||
runInSession: async <T>(run: () => Promise<T>) => run(),
|
||||
close: async () => {},
|
||||
};
|
||||
|
||||
await runCodexPrompt("ignored", {
|
||||
config,
|
||||
createSessionContextFn: async () => sessionContext,
|
||||
createCodexClient: (input) => {
|
||||
capturedClientInput = input as Record<string, unknown>;
|
||||
return {
|
||||
startThread: () => ({
|
||||
run: async () => ({
|
||||
finalResponse: "ok",
|
||||
}),
|
||||
}),
|
||||
};
|
||||
},
|
||||
writeOutput: () => {},
|
||||
});
|
||||
|
||||
assert.equal(capturedClientInput?.["apiKey"], undefined);
|
||||
assert.equal(capturedClientInput?.["baseUrl"], "https://api.example.com/v1");
|
||||
assert.deepEqual(capturedClientInput?.["env"], sessionContext.runtimeInjection.env);
|
||||
});
|
||||
|
||||
test("runClaudePrompt wires auth env, stream parsing, and output", async () => {
|
||||
const config = loadConfig({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "oauth-token",
|
||||
@@ -193,6 +240,68 @@ test("runClaudePrompt wires auth env, stream parsing, and output", async () => {
|
||||
assert.equal(closed, true);
|
||||
});
|
||||
|
||||
test("runClaudePrompt uses ambient Claude login when no token env is configured", async () => {
|
||||
const config = loadConfig({});
|
||||
|
||||
let queryInput:
|
||||
| {
|
||||
prompt: string;
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const sessionContext: SessionContext = {
|
||||
provider: "claude",
|
||||
sessionId: "session-claude-no-key",
|
||||
mcp: {},
|
||||
promptWithContext: "augmented prompt",
|
||||
runtimeInjection: {
|
||||
workingDirectory: "/tmp/claude-worktree",
|
||||
env: {
|
||||
HOME: "/home/tester",
|
||||
PATH: "/usr/bin",
|
||||
},
|
||||
discoveryFilePath: "/tmp/claude-worktree/.agent-context/resources.json",
|
||||
},
|
||||
runInSession: async <T>(run: () => Promise<T>) => run(),
|
||||
close: async () => {},
|
||||
};
|
||||
|
||||
const queryFn: ClaudeQueryFunction = ((input: {
|
||||
prompt: string;
|
||||
options?: Record<string, unknown>;
|
||||
}) => {
|
||||
queryInput = input;
|
||||
const stream = createMessageStream([
|
||||
{
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "ok",
|
||||
} as SDKMessage,
|
||||
]);
|
||||
|
||||
return {
|
||||
...stream,
|
||||
close: () => {},
|
||||
} as ReturnType<ClaudeQueryFunction>;
|
||||
}) as ClaudeQueryFunction;
|
||||
|
||||
await runClaudePrompt("ignored", {
|
||||
config,
|
||||
createSessionContextFn: async () => sessionContext,
|
||||
queryFn,
|
||||
writeOutput: () => {},
|
||||
});
|
||||
|
||||
assert.equal(queryInput?.options?.apiKey, undefined);
|
||||
assert.equal(queryInput?.options?.authToken, undefined);
|
||||
|
||||
const env = queryInput?.options?.env as Record<string, string | undefined> | undefined;
|
||||
assert.equal(env?.HOME, "/home/tester");
|
||||
assert.equal(env?.CLAUDE_CODE_OAUTH_TOKEN, undefined);
|
||||
assert.equal(env?.ANTHROPIC_API_KEY, undefined);
|
||||
});
|
||||
|
||||
test("readClaudeResult throws on non-success result events", async () => {
|
||||
const stream = createMessageStream([
|
||||
{
|
||||
|
||||
94
tests/runtime-events.test.ts
Normal file
94
tests/runtime-events.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import {
|
||||
RuntimeEventPublisher,
|
||||
createDiscordWebhookRuntimeEventSink,
|
||||
createFileRuntimeEventSink,
|
||||
} from "../src/telemetry/index.js";
|
||||
|
||||
test("runtime event file sink writes ndjson events", async () => {
|
||||
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-runtime-events-"));
|
||||
const logPath = resolve(root, "runtime-events.ndjson");
|
||||
const publisher = new RuntimeEventPublisher({
|
||||
sinks: [createFileRuntimeEventSink(logPath)],
|
||||
});
|
||||
|
||||
await publisher.publish({
|
||||
type: "session.started",
|
||||
severity: "info",
|
||||
sessionId: "session-1",
|
||||
message: "Session started.",
|
||||
metadata: {
|
||||
entryNodeId: "entry",
|
||||
},
|
||||
});
|
||||
|
||||
const lines = (await readFile(logPath, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0);
|
||||
assert.equal(lines.length, 1);
|
||||
const parsed = JSON.parse(lines[0] ?? "{}") as Record<string, unknown>;
|
||||
assert.equal(parsed.type, "session.started");
|
||||
assert.equal(parsed.severity, "info");
|
||||
assert.equal(parsed.sessionId, "session-1");
|
||||
});
|
||||
|
||||
test("discord runtime sink supports severity threshold and always-notify types", async () => {
|
||||
const requests: Array<{
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
const discordSink = createDiscordWebhookRuntimeEventSink({
|
||||
webhookUrl: "https://discord.example/webhook",
|
||||
minSeverity: "critical",
|
||||
alwaysNotifyTypes: ["session.started", "session.completed"],
|
||||
fetchFn: async (url, init) => {
|
||||
requests.push({
|
||||
url: String(url),
|
||||
body: JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>,
|
||||
});
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
|
||||
const publisher = new RuntimeEventPublisher({
|
||||
sinks: [discordSink],
|
||||
});
|
||||
|
||||
await publisher.publish({
|
||||
type: "session.started",
|
||||
severity: "info",
|
||||
sessionId: "session-1",
|
||||
message: "Session started.",
|
||||
});
|
||||
await publisher.publish({
|
||||
type: "node.attempt.completed",
|
||||
severity: "warning",
|
||||
sessionId: "session-1",
|
||||
nodeId: "node-1",
|
||||
attempt: 1,
|
||||
message: "Validation failed.",
|
||||
});
|
||||
await publisher.publish({
|
||||
type: "session.failed",
|
||||
severity: "critical",
|
||||
sessionId: "session-1",
|
||||
message: "Session failed.",
|
||||
});
|
||||
|
||||
assert.equal(requests.length, 2);
|
||||
assert.equal(requests[0]?.url, "https://discord.example/webhook");
|
||||
const firstPayload = requests[0]?.body;
|
||||
assert.ok(firstPayload);
|
||||
const firstEmbeds = firstPayload.embeds as Array<Record<string, unknown>>;
|
||||
assert.equal(firstEmbeds[0]?.title, "session.started");
|
||||
const secondPayload = requests[1]?.body;
|
||||
assert.ok(secondPayload);
|
||||
const secondEmbeds = secondPayload.embeds as Array<Record<string, unknown>>;
|
||||
assert.equal(secondEmbeds[0]?.title, "session.failed");
|
||||
});
|
||||
Reference in New Issue
Block a user