@ory/argus 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,239 @@
1
+ "use strict";
2
+ /**
3
+ * Shared contract suite for harness plugin tests.
4
+ *
5
+ * Every harness plugin translates the same core decisions (session gates,
6
+ * `gateToolCall`, audit-only) onto its own native signals (exit codes,
7
+ * `{ decision: "block" }`, `{ block: true }`, thrown errors, …). The
8
+ * *decision semantics* are core-owned and tested once in `packages/core`;
9
+ * what varies per harness is only the translation. This suite re-runs the
10
+ * canonical scenario tables through a harness's real entry point via a
11
+ * small adapter, so a plugin cannot ship with a divergent understanding of
12
+ * observe / enforce / fail-open / audit-only — without each plugin
13
+ * re-transcribing the tables.
14
+ *
15
+ * A harness test file calls:
16
+ *
17
+ * ```ts
18
+ * runHarnessContractSuite({
19
+ * harness: "claude-code",
20
+ * tool: "Bash",
21
+ * sessionCanBlock: true,
22
+ * sessionStart: async ({ client, gates }) => {
23
+ * const out = await handleHookEvent(sessionInput(), client, gates);
24
+ * return { blocked: out.decision === "block", reason: out.reason };
25
+ * },
26
+ * toolBefore: async ({ client, gates }, tool) => { ... },
27
+ * });
28
+ * ```
29
+ *
30
+ * and then adds only its genuinely harness-specific tests: export shape,
31
+ * event-name dispatch, block-signal details, input parsing (MCP names,
32
+ * `subagent_type`), unique span enrichment, and the install CLI.
33
+ */
34
+ Object.defineProperty(exports, "__esModule", { value: true });
35
+ exports.runHarnessContractSuite = runHarnessContractSuite;
36
+ const vitest_1 = require("vitest");
37
+ const config_js_1 = require("./config.js");
38
+ const testing_js_1 = require("./testing.js");
39
+ const DISABLED_DECISION = {
40
+ proceed: true,
41
+ mode: "disabled",
42
+ reason: "ORY_USER_LOGIN not enabled",
43
+ };
44
+ function makeGates(userDecision = DISABLED_DECISION) {
45
+ return {
46
+ userLogin: vitest_1.vi.fn(async () => userDecision),
47
+ agentGate: vitest_1.vi.fn(async () => ({
48
+ kind: "none",
49
+ reason: "not configured in contract suite",
50
+ warnings: [],
51
+ })),
52
+ subAgentGate: vitest_1.vi.fn(async () => ({
53
+ kind: "none",
54
+ subAgentType: "none",
55
+ reason: "not configured in contract suite",
56
+ warnings: [],
57
+ })),
58
+ };
59
+ }
60
+ const ENV_KEYS = [
61
+ "ORY_PROJECT_URL",
62
+ "ORY_API_KEY",
63
+ "ORY_SESSION_TOKEN",
64
+ "ORY_OAUTH2_TOKEN",
65
+ "ORY_USER_SESSION_TOKEN",
66
+ "ORY_USER_OAUTH2_TOKEN",
67
+ "ORY_USER_LOGIN",
68
+ "ORY_USER_SUBJECT_ID",
69
+ "ORY_USER_SUBJECT_NAMESPACE",
70
+ "ORY_AGENT_SUBJECT_ID",
71
+ "ORY_PERMISSION_MODE",
72
+ "ORY_PERMISSION_NAMESPACE",
73
+ "ORY_INTERACTIVE_TOOLS",
74
+ ];
75
+ /**
76
+ * Run the canonical harness contract scenarios through the adapter.
77
+ * Call once per harness test file, then add harness-specific tests.
78
+ */
79
+ function runHarnessContractSuite(adapter) {
80
+ const toolCanBlock = adapter.toolCanBlock ?? true;
81
+ (0, vitest_1.describe)(`${adapter.harness} — Ory harness contract`, () => {
82
+ let client;
83
+ let restoreConfigDir;
84
+ const savedEnv = {};
85
+ (0, vitest_1.beforeEach)(() => {
86
+ for (const key of ENV_KEYS) {
87
+ savedEnv[key] = process.env[key];
88
+ delete process.env[key];
89
+ }
90
+ restoreConfigDir = (0, testing_js_1.useTempConfigDir)();
91
+ process.env.ORY_PROJECT_URL = "https://test.projects.oryapis.com";
92
+ client = (0, testing_js_1.createMockClient)({ harness: adapter.harness });
93
+ });
94
+ (0, vitest_1.afterEach)(() => {
95
+ restoreConfigDir();
96
+ for (const key of ENV_KEYS) {
97
+ if (savedEnv[key] === undefined)
98
+ delete process.env[key];
99
+ else
100
+ process.env[key] = savedEnv[key];
101
+ }
102
+ });
103
+ const ctx = (gates = makeGates()) => ({ client, gates });
104
+ // ── Session start never blocks on auth/infra failures ─────────
105
+ (0, vitest_1.describe)("session start is fail-open", () => {
106
+ vitest_1.it.each([
107
+ { name: "valid session token", env: "session", stub: testing_js_1.stubSessionSuccess },
108
+ { name: "inactive session", env: "session", stub: testing_js_1.stubSessionInactive },
109
+ { name: "session network error", env: "session", stub: testing_js_1.stubSessionNetworkError },
110
+ { name: "MFA required", env: "session", stub: testing_js_1.stubSessionMfaRequired },
111
+ { name: "valid OAuth2 token", env: "oauth2", stub: testing_js_1.stubOAuth2Success },
112
+ { name: "inactive OAuth2 token", env: "oauth2", stub: testing_js_1.stubOAuth2Inactive },
113
+ { name: "unconfigured (no project URL)", env: "none", stub: undefined },
114
+ ])("proceeds with $name", async ({ env, stub, name }) => {
115
+ if (name.includes("OAuth2") && adapter.legacyOAuth2 === false)
116
+ return;
117
+ if (env === "session")
118
+ process.env.ORY_SESSION_TOKEN = "token-under-test";
119
+ if (env === "oauth2")
120
+ process.env.ORY_OAUTH2_TOKEN = "token-under-test";
121
+ if (env === "none")
122
+ delete process.env.ORY_PROJECT_URL;
123
+ stub?.(client);
124
+ const outcome = await adapter.sessionStart(ctx());
125
+ (0, vitest_1.expect)(outcome.blocked).toBe(false);
126
+ });
127
+ });
128
+ // ── User-login gate translation ────────────────────────────────
129
+ (0, vitest_1.describe)("user-login gate", () => {
130
+ const declined = {
131
+ proceed: false,
132
+ mode: "declined",
133
+ reason: "User declined the OAuth2 consent screen",
134
+ };
135
+ (0, vitest_1.it)(adapter.sessionCanBlock
136
+ ? "translates a declined login into the native session block"
137
+ : "treats a declined login as advisory (no session block channel)", async () => {
138
+ const gates = makeGates(declined);
139
+ const outcome = await adapter.sessionStart(ctx(gates));
140
+ (0, vitest_1.expect)(outcome.blocked).toBe(adapter.sessionCanBlock);
141
+ if (adapter.sessionCanBlock) {
142
+ (0, vitest_1.expect)(outcome.reason).toBeTruthy();
143
+ }
144
+ });
145
+ (0, vitest_1.it)("runs the agent gate even when the user gate declines", async () => {
146
+ const gates = makeGates(declined);
147
+ await adapter.sessionStart(ctx(gates));
148
+ (0, vitest_1.expect)(gates.agentGate).toHaveBeenCalledOnce();
149
+ });
150
+ (0, vitest_1.it)("proceeds without legacy verification when the gate handled auth", async () => {
151
+ process.env.ORY_SESSION_TOKEN = "should-not-be-verified";
152
+ const verify = (0, testing_js_1.stubSessionSuccess)(client);
153
+ const gates = makeGates({ proceed: true, mode: "ok", reason: "logged in" });
154
+ const outcome = await adapter.sessionStart(ctx(gates));
155
+ (0, vitest_1.expect)(outcome.blocked).toBe(false);
156
+ (0, vitest_1.expect)(verify).not.toHaveBeenCalled();
157
+ });
158
+ (0, vitest_1.it)("falls through to legacy verification when the gate is disabled", async () => {
159
+ process.env.ORY_SESSION_TOKEN = "legacy-token";
160
+ const verify = (0, testing_js_1.stubSessionSuccess)(client);
161
+ const outcome = await adapter.sessionStart(ctx());
162
+ (0, vitest_1.expect)(outcome.blocked).toBe(false);
163
+ (0, vitest_1.expect)(verify).toHaveBeenCalled();
164
+ });
165
+ });
166
+ // ── Tool gate translation ──────────────────────────────────────
167
+ (0, vitest_1.describe)("tool gate", () => {
168
+ vitest_1.it.each([
169
+ { check: "allowed", mode: "enforce", blocked: false, invoke: true, block: false },
170
+ { check: "denied", mode: "enforce", blocked: toolCanBlock, invoke: false, block: true },
171
+ { check: "denied", mode: "observe", blocked: false, invoke: true, block: true },
172
+ { check: "network_error", mode: "enforce", blocked: false, invoke: false, block: false },
173
+ { check: "rate_limited", mode: "enforce", blocked: false, invoke: false, block: false },
174
+ ])("check=$check × $mode → blocked=$blocked", async ({ check, mode, blocked, invoke, block }) => {
175
+ const stubs = {
176
+ allowed: testing_js_1.stubPermissionAllowed,
177
+ denied: testing_js_1.stubPermissionDenied,
178
+ network_error: testing_js_1.stubPermissionNetworkError,
179
+ rate_limited: testing_js_1.stubPermissionRateLimited,
180
+ };
181
+ stubs[check](client);
182
+ process.env.ORY_PERMISSION_MODE = mode;
183
+ const outcome = await adapter.toolBefore(ctx(), adapter.tool);
184
+ (0, vitest_1.expect)(outcome.blocked).toBe(blocked);
185
+ if (blocked)
186
+ (0, vitest_1.expect)(outcome.reason).toBeTruthy();
187
+ const invokeSpans = (0, testing_js_1.getTraceSpans)(client, "tool.invoke");
188
+ const blockSpans = (0, testing_js_1.getTraceSpans)(client, "tool.block");
189
+ if (invoke) {
190
+ (0, vitest_1.expect)(invokeSpans.length).toBeGreaterThanOrEqual(1);
191
+ (0, vitest_1.expect)(invokeSpans[0].attributes?.toolName).toBe(adapter.tool);
192
+ }
193
+ else {
194
+ (0, vitest_1.expect)(invokeSpans).toHaveLength(0);
195
+ }
196
+ if (block) {
197
+ (0, vitest_1.expect)(blockSpans.length).toBeGreaterThanOrEqual(1);
198
+ (0, vitest_1.expect)(blockSpans[0].attributes?.blocked).toBe(mode === "enforce" && toolCanBlock);
199
+ }
200
+ else {
201
+ (0, vitest_1.expect)(blockSpans).toHaveLength(0);
202
+ }
203
+ // Observe-mode denies also emit the audit span; enforce must not.
204
+ const observeSpans = (0, testing_js_1.getTraceSpans)(client, "permission.observe_deny");
205
+ (0, vitest_1.expect)(observeSpans).toHaveLength(check === "denied" && mode === "observe" ? 1 : 0);
206
+ });
207
+ (0, vitest_1.it)("skips the permission check entirely in audit-only mode", async () => {
208
+ (0, config_js_1.saveConfig)({ auditOnly: true });
209
+ const checkSpy = vitest_1.vi.spyOn(client, "checkPermission");
210
+ process.env.ORY_PERMISSION_MODE = "enforce";
211
+ const outcome = await adapter.toolBefore(ctx(), adapter.tool);
212
+ (0, vitest_1.expect)(outcome.blocked).toBe(false);
213
+ (0, vitest_1.expect)(checkSpy).not.toHaveBeenCalled();
214
+ });
215
+ (0, vitest_1.it)("addresses the check to ORY_USER_SUBJECT_ID when set", async () => {
216
+ process.env.ORY_USER_SUBJECT_ID = "user:custom-override";
217
+ (0, testing_js_1.stubPermissionAllowed)(client);
218
+ await adapter.toolBefore(ctx(), adapter.tool);
219
+ const [span] = (0, testing_js_1.getTraceSpans)(client, "tool.invoke");
220
+ (0, vitest_1.expect)(span.attributes?.subjectId).toBe("user:custom-override");
221
+ });
222
+ });
223
+ // ── Post-tool + passthrough ────────────────────────────────────
224
+ if (adapter.toolAfter) {
225
+ (0, vitest_1.it)("records tool.complete after execution", async () => {
226
+ await adapter.toolAfter(ctx(), adapter.tool);
227
+ const spans = (0, testing_js_1.getTraceSpans)(client, "tool.complete");
228
+ (0, vitest_1.expect)(spans.length).toBeGreaterThanOrEqual(1);
229
+ (0, vitest_1.expect)(spans[0].attributes?.toolName).toBe(adapter.tool);
230
+ });
231
+ }
232
+ if (adapter.unknownEvent) {
233
+ (0, vitest_1.it)("passes unknown events through with the native empty response", async () => {
234
+ const raw = await adapter.unknownEvent(ctx());
235
+ (0, vitest_1.expect)(raw).toEqual(adapter.unknownEventOutput);
236
+ });
237
+ }
238
+ });
239
+ }
package/dist/index.d.ts CHANGED
@@ -23,3 +23,4 @@ export { resolveUserSubject, subjectLabel, type UserSubjectRef, } from "./subjec
23
23
  export { formatDenialMessage, formatDenialSummary, formatAlertMessage, formatAlertSummary, alertAttributes, OryDenialError, type DenialContext, type AlertAttributes, } from "./denial.js";
24
24
  export { summarizeToolInput, summarizeToolOutput, type ToolInputSummary, type ToolOutputSummary, } from "./tool-metadata.js";
25
25
  export { OtlpExporter, otlpExporterFromEnv, parseKeyValueList, type SpanExporter, type OtlpExporterOptions, type OtlpProtocol, } from "./otel/index.js";
26
+ export { resolveNamespace, sessionStart, gate, complete, registerSubagent, wrapTool, type SessionStartResult, type SessionStartOptions, type RegisterSubagentOptions, type GateResult, type GateOptions, type WrapToolOptions, } from "./adapters.js";
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.printOryConfig = exports.runAgentCommand = exports.runConfigureCommand = exports.AGENT_TOKEN_EXPIRY_SKEW_SEC = exports.clearSubAgentDynamicCredentials = exports.saveSubAgentDynamicCredentials = exports.loadSubAgentDynamicCredentials = exports.clearAgentDynamicCredentials = exports.saveAgentDynamicCredentials = exports.loadAgentDynamicCredentials = exports.registerAgentClient = exports.fetchClientCredentialsToken = exports.ensureSubAgentIdentity = exports.ensureAgentIdentity = exports.resolveAgentCredentials = exports.ensureAuthenticated = exports.ensureUserAuthenticated = exports.TOKEN_EXPIRY_SKEW_SEC = exports.waitForPeerTokensSync = exports.waitForPeerTokens = exports.clearPkceFlightLock = exports.tryAcquirePkceFlightLock = exports.refreshAndSave = exports.isExpired = exports.clearTokens = exports.saveTokens = exports.loadTokens = exports.DEFAULT_LOGIN_TIMEOUT_MS = exports.LOOPBACK_PORTS = exports.buildAuthorizeUrl = exports.sha256Base64Url = exports.generateCodeVerifier = exports.detectHeadless = exports.refreshAccessToken = exports.pkceLogin = exports.getHarnessDataDir = exports.getDataDir = exports.getConfigPath = exports.mutateConfig = exports.resolveConfig = exports.saveConfig = exports.loadConfig = exports.watchTraceFile = exports.formatSpan = exports.deriveTraceId = exports.ActiveSpan = exports.Tracer = exports.redactLogData = exports.DebugLogger = exports.OryAgentClient = void 0;
4
4
  exports.runLocalCommand = exports.ORY_COMMAND_SLUGS = exports.ORY_COMMAND_SKILL_NAMES = exports.ORY_SKILL_NAMES = exports.removeSkillDirs = exports.writeSkillTree = exports.toSkillMarkdown = exports.commandToPlainMarkdown = exports.commandToFrontmatterMarkdown = exports.commandToToml = exports.commandToSkill = exports.renderOryCommands = exports.renderOrySkills = exports.runDevLauncher = exports.unregisterPlugin = exports.registerPlugin = exports.removeMcpServer = exports.mergeMcpServer = exports.mcpServerEntry = exports.resolveMcpServerCommand = exports.printNextSteps = exports.printSetupHelp = exports.removeFlatHooks = exports.mergeFlatHooks = exports.flatHookEntry = exports.removeMatcherHooks = exports.mergeMatcherHooks = exports.matcherHookEntry = exports.resolveHookCommand = exports.isOryHookCommand = exports.writeJsonFile = exports.readJsonFile = exports.parseSetupArgs = exports.printPermissionsSection = exports.printAgentIdentitySection = exports.printUserIdentitySection = exports.runStatusCommand = exports.printPermissionsOnboardingHelp = exports.maybeAutoBootstrap = exports.isUserIdentityCached = exports.runPermissionsCommand = exports.interactiveConfigPrompt = exports.promptForProjectUrl = exports.promptOnTty = exports.isTtyAvailable = exports.runWatchCommand = exports.printTraceTail = exports.printEnvHelp = exports.printLogTail = exports.printEnvironment = void 0;
5
- exports.parseKeyValueList = exports.otlpExporterFromEnv = exports.OtlpExporter = exports.summarizeToolOutput = exports.summarizeToolInput = exports.OryDenialError = exports.alertAttributes = exports.formatAlertSummary = exports.formatAlertMessage = exports.formatDenialSummary = exports.formatDenialMessage = exports.subjectLabel = exports.resolveUserSubject = exports.checkMcpPermission = exports.parseMcpToolGeneric = exports.parseGeminiMcpTool = exports.parseClaudeCodeMcpTool = exports.TOOL_EXECUTION_PHASES = exports.USER_FACING_PHASES = exports.HARNESS_LIFECYCLE_MAP = exports.isToolExecutionPhase = exports.isUserFacingPhase = exports.classifyLifecycle = exports.isInteractiveTool = exports.getInteractiveToolCatalog = exports.INTERACTIVE_TOOL_CATALOG = exports.getToolCatalog = exports.ALL_TOOLS = exports.KNOWN_HARNESSES = exports.HARNESS_TOOL_CATALOG = exports.gateToolCall = exports.applyPermissionMode = exports.checkAndDecide = exports.runRegistryCommand = exports.DEV_JAEGER_CONTAINER = exports.stopDevJaeger = exports.ensureDevJaeger = void 0;
5
+ exports.wrapTool = exports.registerSubagent = exports.complete = exports.gate = exports.sessionStart = exports.resolveNamespace = exports.parseKeyValueList = exports.otlpExporterFromEnv = exports.OtlpExporter = exports.summarizeToolOutput = exports.summarizeToolInput = exports.OryDenialError = exports.alertAttributes = exports.formatAlertSummary = exports.formatAlertMessage = exports.formatDenialSummary = exports.formatDenialMessage = exports.subjectLabel = exports.resolveUserSubject = exports.checkMcpPermission = exports.parseMcpToolGeneric = exports.parseGeminiMcpTool = exports.parseClaudeCodeMcpTool = exports.TOOL_EXECUTION_PHASES = exports.USER_FACING_PHASES = exports.HARNESS_LIFECYCLE_MAP = exports.isToolExecutionPhase = exports.isUserFacingPhase = exports.classifyLifecycle = exports.isInteractiveTool = exports.getInteractiveToolCatalog = exports.INTERACTIVE_TOOL_CATALOG = exports.getToolCatalog = exports.ALL_TOOLS = exports.KNOWN_HARNESSES = exports.HARNESS_TOOL_CATALOG = exports.gateToolCall = exports.applyPermissionMode = exports.checkAndDecide = exports.runRegistryCommand = exports.DEV_JAEGER_CONTAINER = exports.stopDevJaeger = exports.ensureDevJaeger = void 0;
6
6
  var client_js_1 = require("./client.js");
7
7
  Object.defineProperty(exports, "OryAgentClient", { enumerable: true, get: function () { return client_js_1.OryAgentClient; } });
8
8
  var logger_js_1 = require("./logger.js");
@@ -164,3 +164,10 @@ var index_js_3 = require("./otel/index.js");
164
164
  Object.defineProperty(exports, "OtlpExporter", { enumerable: true, get: function () { return index_js_3.OtlpExporter; } });
165
165
  Object.defineProperty(exports, "otlpExporterFromEnv", { enumerable: true, get: function () { return index_js_3.otlpExporterFromEnv; } });
166
166
  Object.defineProperty(exports, "parseKeyValueList", { enumerable: true, get: function () { return index_js_3.parseKeyValueList; } });
167
+ var adapters_js_1 = require("./adapters.js");
168
+ Object.defineProperty(exports, "resolveNamespace", { enumerable: true, get: function () { return adapters_js_1.resolveNamespace; } });
169
+ Object.defineProperty(exports, "sessionStart", { enumerable: true, get: function () { return adapters_js_1.sessionStart; } });
170
+ Object.defineProperty(exports, "gate", { enumerable: true, get: function () { return adapters_js_1.gate; } });
171
+ Object.defineProperty(exports, "complete", { enumerable: true, get: function () { return adapters_js_1.complete; } });
172
+ Object.defineProperty(exports, "registerSubagent", { enumerable: true, get: function () { return adapters_js_1.registerSubagent; } });
173
+ Object.defineProperty(exports, "wrapTool", { enumerable: true, get: function () { return adapters_js_1.wrapTool; } });
package/dist/lifecycle.js CHANGED
@@ -44,6 +44,7 @@ exports.isToolExecutionPhase = isToolExecutionPhase;
44
44
  * `passthrough` via {@link classifyLifecycle}.
45
45
  */
46
46
  exports.HARNESS_LIFECYCLE_MAP = {
47
+ // ── Coding-agent harnesses ──
47
48
  "claude-code": {
48
49
  SessionStart: "session.start",
49
50
  SessionEnd: "session.stop",
@@ -88,6 +89,134 @@ exports.HARNESS_LIFECYCLE_MAP = {
88
89
  "tool.execute.before": "tool.before",
89
90
  "tool.execute.after": "tool.after",
90
91
  },
92
+ // Continue's `Stop` brackets a turn (not the session) and `ConfigChange`
93
+ // carries no lifecycle meaning — both fall through to passthrough and are
94
+ // traced as audit spans by the handler.
95
+ continue: {
96
+ SessionStart: "session.start",
97
+ SessionEnd: "session.stop",
98
+ PreToolUse: "tool.before",
99
+ PostToolUse: "tool.after",
100
+ PostToolUseFailure: "tool.failure",
101
+ PermissionRequest: "permission.ask",
102
+ UserPromptSubmit: "user.prompt",
103
+ SubagentStart: "subagent.start",
104
+ SubagentStop: "subagent.stop",
105
+ PreCompact: "compaction",
106
+ },
107
+ // Goose's file/shell notification hooks surface activity to the user and
108
+ // are trace-only — user-facing, never gated.
109
+ goose: {
110
+ SessionStart: "session.start",
111
+ SessionEnd: "session.stop",
112
+ Stop: "session.stop",
113
+ PreToolUse: "tool.before",
114
+ PostToolUse: "tool.after",
115
+ PostToolUseFailure: "tool.failure",
116
+ UserPromptSubmit: "user.prompt",
117
+ BeforeReadFile: "user.interaction",
118
+ AfterFileEdit: "user.interaction",
119
+ BeforeShellExecution: "user.interaction",
120
+ AfterShellExecution: "user.interaction",
121
+ },
122
+ cline: {
123
+ agent_start: "session.start",
124
+ agent_resume: "session.start",
125
+ agent_abort: "session.stop",
126
+ agent_end: "session.stop",
127
+ agent_error: "session.stop",
128
+ session_shutdown: "session.stop",
129
+ tool_call: "tool.before",
130
+ tool_result: "tool.after",
131
+ prompt_submit: "user.prompt",
132
+ pre_compact: "compaction",
133
+ },
134
+ // Amp is hybrid: session.start / tool.result are in-process plugin events;
135
+ // tool.call is gated by the subprocess permission delegate. agent.start /
136
+ // agent.end bracket a turn and fall through to passthrough.
137
+ amp: {
138
+ "session.start": "session.start",
139
+ "tool.call": "tool.before",
140
+ "tool.result": "tool.after",
141
+ },
142
+ // Pi runs session start in the extension factory body (awaited before
143
+ // startup); `session_start` is the harness's equivalent event name.
144
+ pi: {
145
+ session_start: "session.start",
146
+ tool_call: "tool.before",
147
+ tool_result: "tool.after",
148
+ },
149
+ // Google Antigravity (`agy` CLI / IDE). Subprocess hooks with
150
+ // Claude-Code-style snake_case payloads. PreInvocation/PostInvocation
151
+ // bracket each agent turn (not user prompts), so they fall through to
152
+ // passthrough and are traced as audit spans by the handler.
153
+ antigravity: {
154
+ SessionStart: "session.start",
155
+ SessionEnd: "session.stop",
156
+ Stop: "session.stop",
157
+ PreToolUse: "tool.before",
158
+ PostToolUse: "tool.after",
159
+ Notification: "user.interaction",
160
+ },
161
+ // ── Agent SDK integrations ── (parity with `ory_argus.lifecycle`; each
162
+ // entry names the canonical extension point the integration hooks)
163
+ langchain: {
164
+ "wrap_tool_call.before": "tool.before",
165
+ "wrap_tool_call.after": "tool.after",
166
+ on_tool_start: "tool.before",
167
+ on_tool_end: "tool.after",
168
+ on_tool_error: "tool.failure",
169
+ },
170
+ "openai-agents": {
171
+ tool_input_guardrail: "tool.before",
172
+ tool_output_guardrail: "tool.after",
173
+ on_tool_start: "tool.before",
174
+ on_tool_end: "tool.after",
175
+ on_handoff: "subagent.start",
176
+ },
177
+ crewai: {
178
+ ToolUsageStartedEvent: "tool.before",
179
+ ToolUsageFinishedEvent: "tool.after",
180
+ ToolUsageErrorEvent: "tool.failure",
181
+ },
182
+ llamaindex: {
183
+ AgentToolCallEvent: "tool.before",
184
+ function_call: "tool.before",
185
+ function_call_result: "tool.after",
186
+ },
187
+ "pydantic-ai": {
188
+ "call_tool.before": "tool.before",
189
+ "call_tool.after": "tool.after",
190
+ },
191
+ "agent-framework": {
192
+ "function_middleware.before": "tool.before",
193
+ "function_middleware.after": "tool.after",
194
+ },
195
+ "vercel-ai": {
196
+ "execute.before": "tool.before",
197
+ "execute.after": "tool.after",
198
+ },
199
+ "claude-agent-sdk": {
200
+ PreToolUse: "tool.before",
201
+ PostToolUse: "tool.after",
202
+ },
203
+ "google-adk": {
204
+ before_tool_callback: "tool.before",
205
+ after_tool_callback: "tool.after",
206
+ transfer_to_agent: "subagent.start",
207
+ },
208
+ mastra: {
209
+ beforeToolCall: "tool.before",
210
+ afterToolCall: "tool.after",
211
+ },
212
+ strands: {
213
+ BeforeToolCallEvent: "tool.before",
214
+ AfterToolCallEvent: "tool.after",
215
+ },
216
+ "cloudflare-agents": {
217
+ "execute.before": "tool.before",
218
+ "execute.after": "tool.after",
219
+ },
91
220
  };
92
221
  /**
93
222
  * Resolve a harness event name to its canonical {@link LifecyclePhase}.
@@ -73,8 +73,8 @@ export declare const GATEWAY_PORT = 4000;
73
73
  export declare const GATEWAY_URL = "http://localhost:4000";
74
74
  export declare const CONSOLE_PORT = 4100;
75
75
  export declare const CONSOLE_URL = "http://localhost:4100";
76
- export declare const LOGIN_UI_PORT = 3000;
77
- export declare const LOGIN_UI_URL = "http://localhost:3000";
76
+ export declare const LOGIN_UI_PORT = 4455;
77
+ export declare const LOGIN_UI_URL = "http://localhost:4455";
78
78
  export declare const KRATOS_PUBLIC_PORT = 4433;
79
79
  export declare const KRATOS_ADMIN_PORT = 4434;
80
80
  export declare const KETO_READ_PORT = 4466;
@@ -189,6 +189,14 @@ function nginxGatewayService() {
189
189
  * `COOKIE_SECRET` and `CSRF_COOKIE_SECRET` are required by the image —
190
190
  * the container exits at startup if they're missing. Both must be at
191
191
  * least 32 chars. Fixed dev-only values so launches are deterministic.
192
+ *
193
+ * `DANGEROUSLY_DISABLE_SECURE_CSRF_COOKIES=true` is required for local
194
+ * dev (HTTP, not HTTPS). Without it the csrf-csrf library sets `Secure`
195
+ * cookies, which the browser silently drops over plain HTTP. This causes
196
+ * every self-service flow (login, registration, settings) to fail with a
197
+ * 403 CSRF error, and the registration handler's redirectOnSoftError
198
+ * enters an infinite redirect loop — pegging CPU and making the UI
199
+ * unusable. Only safe for loopback-local dev.
192
200
  */
193
201
  function loginUiService() {
194
202
  return ` login-ui:
@@ -207,6 +215,7 @@ function loginUiService() {
207
215
  COOKIE_SECRET: "ory-agent-plugins-local-cookie-secret-32+"
208
216
  CSRF_COOKIE_NAME: "ory_agent_plugins_local_csrf"
209
217
  CSRF_COOKIE_SECRET: "ory-agent-plugins-local-csrf-secret-32+!"
218
+ DANGEROUSLY_DISABLE_SECURE_CSRF_COOKIES: "true"
210
219
  depends_on:
211
220
  - kratos
212
221
  - hydra
@@ -616,7 +625,7 @@ exports.GATEWAY_PORT = 4000;
616
625
  exports.GATEWAY_URL = `http://localhost:${exports.GATEWAY_PORT}`;
617
626
  exports.CONSOLE_PORT = 4100;
618
627
  exports.CONSOLE_URL = `http://localhost:${exports.CONSOLE_PORT}`;
619
- exports.LOGIN_UI_PORT = 3000;
628
+ exports.LOGIN_UI_PORT = 4455;
620
629
  exports.LOGIN_UI_URL = `http://localhost:${exports.LOGIN_UI_PORT}`;
621
630
  exports.KRATOS_PUBLIC_PORT = 4433;
622
631
  exports.KRATOS_ADMIN_PORT = 4434;
@@ -24,10 +24,12 @@ export interface EnsureLocalOryStackResult {
24
24
  * Bring up the local Ory stack and wait for the gateway to be healthy.
25
25
  *
26
26
  * Idempotent and fail-graceful: returns a typed result instead of exiting.
27
- * If the gateway is already healthy the function short-circuits without
28
- * touching Docker. When `quiet` is set, progress is logged to stderr only
29
- * (suitable for the dev launcher); otherwise it prints to stdout in the
30
- * format `localUp` historically used.
27
+ * If the gateway is already healthy AND the running stack's config matches
28
+ * what the current code generates (verified by a config fingerprint), the
29
+ * function short-circuits without touching Docker; otherwise it regenerates
30
+ * the config and runs `docker compose up -d --wait` to reconcile. When
31
+ * `quiet` is set, progress is logged to stderr only (suitable for the dev
32
+ * launcher); otherwise it prints to stdout in the format `localUp` historically used.
31
33
  */
32
34
  export declare function ensureLocalOryStack(opts?: {
33
35
  quiet?: boolean;
@@ -57,6 +57,7 @@ exports.localEnv = localEnv;
57
57
  exports.localConfigure = localConfigure;
58
58
  const fs = __importStar(require("node:fs"));
59
59
  const path = __importStar(require("node:path"));
60
+ const crypto = __importStar(require("node:crypto"));
60
61
  const node_child_process_1 = require("node:child_process");
61
62
  const configs_js_1 = require("./configs.js");
62
63
  const health_js_1 = require("./health.js");
@@ -196,28 +197,86 @@ function dumpCapturedOutput(label, result) {
196
197
  process.stderr.write(`── end ${label} output ──\n\n`);
197
198
  }
198
199
  // ─── Write config files ────────────────────────────────────────────
200
+ /**
201
+ * Render the full set of local-stack config files as `{ relPath, content }`
202
+ * pairs. Pure function of the code, `projectRoot`, and `consoleCfg` — no I/O.
203
+ * `writeConfigs` writes these and `configFingerprint` hashes them, so the
204
+ * on-disk stack and its change-detection signal never drift.
205
+ */
206
+ function renderConfigs(projectRoot, consoleCfg) {
207
+ return [
208
+ {
209
+ relPath: "docker-compose.yml",
210
+ content: (0, configs_js_1.dockerComposeYaml)(projectRoot, consoleCfg),
211
+ },
212
+ { relPath: "kratos/kratos.yml", content: (0, configs_js_1.kratosConfigYaml)() },
213
+ {
214
+ relPath: "kratos/identity-schema.json",
215
+ content: (0, configs_js_1.kratosIdentitySchema)(),
216
+ },
217
+ { relPath: "keto/keto.yml", content: (0, configs_js_1.ketoConfigYaml)() },
218
+ { relPath: "hydra/hydra.yml", content: (0, configs_js_1.hydraConfigYaml)() },
219
+ // nginx config is always emitted — the gateway is no longer optional.
220
+ { relPath: "nginx/nginx.conf", content: (0, configs_js_1.nginxConf)() },
221
+ ];
222
+ }
223
+ /**
224
+ * Stable content hash of the rendered config set. Used to decide whether an
225
+ * already-running stack still matches the config the current code would
226
+ * generate; a change (e.g. the login-UI port, a service env var) invalidates
227
+ * the fast-path short-circuit so the stack is reconciled instead of reused.
228
+ */
229
+ function configFingerprint(projectRoot, consoleCfg) {
230
+ const hash = crypto.createHash("sha256");
231
+ for (const { relPath, content } of renderConfigs(projectRoot, consoleCfg)) {
232
+ hash.update(relPath);
233
+ hash.update("\0");
234
+ hash.update(content);
235
+ hash.update("\0");
236
+ }
237
+ return hash.digest("hex");
238
+ }
239
+ const CONFIG_FINGERPRINT_FILE = ".config-fingerprint";
240
+ /** Read the fingerprint recorded after the last successful bring-up. */
241
+ function readConfigFingerprint(localDir) {
242
+ try {
243
+ return fs
244
+ .readFileSync(path.join(localDir, CONFIG_FINGERPRINT_FILE), "utf8")
245
+ .trim();
246
+ }
247
+ catch {
248
+ return undefined;
249
+ }
250
+ }
251
+ /** Record the fingerprint of the config the running stack was brought up with. */
252
+ function writeConfigFingerprint(localDir, fingerprint) {
253
+ try {
254
+ fs.writeFileSync(path.join(localDir, CONFIG_FINGERPRINT_FILE), fingerprint + "\n");
255
+ }
256
+ catch {
257
+ // Best-effort: a missing fingerprint only costs a reconcile next launch.
258
+ }
259
+ }
199
260
  function writeConfigs(localDir, projectRoot, consoleCfg) {
200
261
  ensureDir(localDir);
201
262
  ensureDir(path.join(localDir, "kratos"));
202
263
  ensureDir(path.join(localDir, "keto"));
203
264
  ensureDir(path.join(localDir, "hydra"));
204
265
  ensureDir(path.join(localDir, "nginx"));
205
- fs.writeFileSync(path.join(localDir, "docker-compose.yml"), (0, configs_js_1.dockerComposeYaml)(projectRoot, consoleCfg));
206
- fs.writeFileSync(path.join(localDir, "kratos", "kratos.yml"), (0, configs_js_1.kratosConfigYaml)());
207
- fs.writeFileSync(path.join(localDir, "kratos", "identity-schema.json"), (0, configs_js_1.kratosIdentitySchema)());
208
- fs.writeFileSync(path.join(localDir, "keto", "keto.yml"), (0, configs_js_1.ketoConfigYaml)());
209
- fs.writeFileSync(path.join(localDir, "hydra", "hydra.yml"), (0, configs_js_1.hydraConfigYaml)());
210
- // nginx config is always emitted — the gateway is no longer optional.
211
- fs.writeFileSync(path.join(localDir, "nginx", "nginx.conf"), (0, configs_js_1.nginxConf)());
266
+ for (const { relPath, content } of renderConfigs(projectRoot, consoleCfg)) {
267
+ fs.writeFileSync(path.join(localDir, relPath), content);
268
+ }
212
269
  }
213
270
  /**
214
271
  * Bring up the local Ory stack and wait for the gateway to be healthy.
215
272
  *
216
273
  * Idempotent and fail-graceful: returns a typed result instead of exiting.
217
- * If the gateway is already healthy the function short-circuits without
218
- * touching Docker. When `quiet` is set, progress is logged to stderr only
219
- * (suitable for the dev launcher); otherwise it prints to stdout in the
220
- * format `localUp` historically used.
274
+ * If the gateway is already healthy AND the running stack's config matches
275
+ * what the current code generates (verified by a config fingerprint), the
276
+ * function short-circuits without touching Docker; otherwise it regenerates
277
+ * the config and runs `docker compose up -d --wait` to reconcile. When
278
+ * `quiet` is set, progress is logged to stderr only (suitable for the dev
279
+ * launcher); otherwise it prints to stdout in the format `localUp` historically used.
221
280
  */
222
281
  async function ensureLocalOryStack(opts = {}) {
223
282
  const localDir = opts.localDir ?? getLocalDir();
@@ -232,13 +291,25 @@ async function ensureLocalOryStack(opts = {}) {
232
291
  }
233
292
  const gatewayCfg = (0, configs_js_1.getGatewayConfig)();
234
293
  const healthUrl = `${configs_js_1.GATEWAY_URL}${gatewayCfg.healthPath}`;
235
- // Short-circuit if the gateway is already serving keeps repeated dev
236
- // launches fast and avoids re-pulling/restarting healthy containers.
237
- // Skip the short-circuit when ORY_CONSOLE_LITE_PATH is explicitly set so
238
- // a path change on a subsequent run actually triggers a rebuild (compose
239
- // diffs the regenerated YAML and recreates the console when needed).
294
+ // Short-circuit if the gateway is already serving AND the running stack was
295
+ // brought up with the same config the current code would generate — keeps
296
+ // repeated dev launches fast without re-pulling/restarting healthy
297
+ // containers. Gateway health alone is not enough: a config change to any
298
+ // service (e.g. the login-UI port, a service env var) leaves the gateway
299
+ // healthy while the rest of the stack runs stale config, so we also require
300
+ // the config fingerprint to match. On a mismatch we fall through and let
301
+ // `docker compose up -d --wait` diff the regenerated YAML and recreate only
302
+ // the changed containers.
303
+ //
304
+ // Skip the short-circuit entirely when ORY_CONSOLE_LITE_PATH is explicitly
305
+ // set so a rebuild is forced even when the path is unchanged (the console
306
+ // source tree behind the path may have changed, which the fingerprint of the
307
+ // generated config can't see).
240
308
  const explicitConsoleLiteOverride = (process.env.ORY_CONSOLE_LITE_PATH ?? "").trim() !== "";
309
+ const fingerprint = configFingerprint(projectRoot, consoleCfg);
310
+ const configUnchanged = readConfigFingerprint(localDir) === fingerprint;
241
311
  if (!explicitConsoleLiteOverride &&
312
+ configUnchanged &&
242
313
  (await isGatewayHealthy(1_500, healthUrl))) {
243
314
  return { status: "already-running", gatewayUrl: configs_js_1.GATEWAY_URL, localDir };
244
315
  }
@@ -320,6 +391,10 @@ async function ensureLocalOryStack(opts = {}) {
320
391
  detail: "Gateway did not become healthy within 2 minutes.",
321
392
  };
322
393
  }
394
+ // Record the fingerprint of the config the stack was actually brought up
395
+ // with (consoleCfg may have fallen back to disabled above), so the next
396
+ // launch can short-circuit only while the config is unchanged.
397
+ writeConfigFingerprint(localDir, configFingerprint(projectRoot, consoleCfg));
323
398
  return { status: "started", gatewayUrl: configs_js_1.GATEWAY_URL, localDir };
324
399
  }
325
400
  async function localUp(opts = {}) {