@useconductor/conductor 1.0.0 → 2.0.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.
- package/.github/README.md +374 -7
- package/.github/workflows/ci.yml +3 -1
- package/.github/workflows/claude-code-review.yml +1 -15
- package/.github/workflows/publish.yml +43 -0
- package/README.md +290 -121
- package/dist/cli/commands/audit.d.ts +40 -0
- package/dist/cli/commands/audit.d.ts.map +1 -0
- package/dist/cli/commands/audit.js +272 -0
- package/dist/cli/commands/audit.js.map +1 -0
- package/dist/cli/commands/circuit.d.ts +13 -0
- package/dist/cli/commands/circuit.d.ts.map +1 -0
- package/dist/cli/commands/circuit.js +53 -0
- package/dist/cli/commands/circuit.js.map +1 -0
- package/dist/cli/commands/config.d.ts +31 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +152 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/init.d.ts +5 -8
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +86 -123
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/marketplace.js +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/onboard.js +33 -11
- package/dist/cli/commands/onboard.js.map +1 -1
- package/dist/cli/commands/release.d.ts.map +1 -1
- package/dist/cli/commands/release.js +1 -1
- package/dist/cli/commands/release.js.map +1 -1
- package/dist/cli/index.js +146 -10
- package/dist/cli/index.js.map +1 -1
- package/dist/core/audit.d.ts.map +1 -1
- package/dist/core/audit.js +5 -2
- package/dist/core/audit.js.map +1 -1
- package/dist/core/conductor.d.ts.map +1 -1
- package/dist/core/conductor.js +12 -0
- package/dist/core/conductor.js.map +1 -1
- package/dist/core/config.d.ts +3 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +46 -2
- package/dist/core/config.js.map +1 -1
- package/dist/core/database.d.ts +3 -0
- package/dist/core/database.d.ts.map +1 -1
- package/dist/core/database.js +26 -0
- package/dist/core/database.js.map +1 -1
- package/dist/core/encryption.d.ts +34 -0
- package/dist/core/encryption.d.ts.map +1 -0
- package/dist/core/encryption.js +96 -0
- package/dist/core/encryption.js.map +1 -0
- package/dist/core/zero-config.d.ts.map +1 -1
- package/dist/core/zero-config.js +1 -4
- package/dist/core/zero-config.js.map +1 -1
- package/dist/dashboard/server.d.ts.map +1 -1
- package/dist/dashboard/server.js +112 -16
- package/dist/dashboard/server.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +30 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/plugins/builtin/aws.d.ts +31 -0
- package/dist/plugins/builtin/aws.d.ts.map +1 -0
- package/dist/plugins/builtin/aws.js +149 -0
- package/dist/plugins/builtin/aws.js.map +1 -0
- package/dist/plugins/builtin/database.d.ts +1 -0
- package/dist/plugins/builtin/database.d.ts.map +1 -1
- package/dist/plugins/builtin/database.js +26 -1
- package/dist/plugins/builtin/database.js.map +1 -1
- package/dist/plugins/builtin/docker.d.ts +4 -0
- package/dist/plugins/builtin/docker.d.ts.map +1 -1
- package/dist/plugins/builtin/docker.js +20 -1
- package/dist/plugins/builtin/docker.js.map +1 -1
- package/dist/plugins/builtin/gcp.d.ts +28 -0
- package/dist/plugins/builtin/gcp.d.ts.map +1 -0
- package/dist/plugins/builtin/gcp.js +135 -0
- package/dist/plugins/builtin/gcp.js.map +1 -0
- package/dist/plugins/builtin/index.d.ts.map +1 -1
- package/dist/plugins/builtin/index.js +4 -0
- package/dist/plugins/builtin/index.js.map +1 -1
- package/dist/plugins/builtin/jira.d.ts.map +1 -1
- package/dist/plugins/builtin/jira.js +4 -2
- package/dist/plugins/builtin/jira.js.map +1 -1
- package/dist/plugins/builtin/linear.js +1 -1
- package/dist/plugins/builtin/linear.js.map +1 -1
- package/dist/plugins/builtin/shell.js +1 -1
- package/dist/plugins/builtin/shell.js.map +1 -1
- package/dist/plugins/builtin/slack.d.ts +1 -0
- package/dist/plugins/builtin/slack.d.ts.map +1 -1
- package/dist/plugins/builtin/slack.js +9 -1
- package/dist/plugins/builtin/slack.js.map +1 -1
- package/dist/plugins/builtin/spotify.js +1 -1
- package/dist/plugins/builtin/spotify.js.map +1 -1
- package/dist/plugins/builtin/vercel.d.ts.map +1 -1
- package/dist/plugins/builtin/vercel.js +3 -1
- package/dist/plugins/builtin/vercel.js.map +1 -1
- package/dist/security/sso.d.ts +37 -0
- package/dist/security/sso.d.ts.map +1 -0
- package/dist/security/sso.js +92 -0
- package/dist/security/sso.js.map +1 -0
- package/docs/deployment.md +201 -0
- package/docs/plugin-sdk.md +212 -0
- package/package.json +11 -8
- package/src/cli/commands/audit.ts +318 -0
- package/src/cli/commands/circuit.ts +63 -0
- package/src/cli/commands/config.ts +176 -0
- package/src/cli/commands/init.ts +87 -145
- package/src/cli/commands/marketplace.ts +1 -1
- package/src/cli/commands/onboard.ts +33 -11
- package/src/cli/commands/release.ts +13 -6
- package/src/cli/index.ts +165 -11
- package/src/core/audit.ts +5 -2
- package/src/core/conductor.ts +11 -0
- package/src/core/config.ts +47 -2
- package/src/core/database.ts +32 -0
- package/src/core/encryption.ts +110 -0
- package/src/core/zero-config.ts +1 -5
- package/src/dashboard/server.ts +135 -16
- package/src/mcp/server.ts +40 -2
- package/src/plugins/builtin/aws.ts +162 -0
- package/src/plugins/builtin/database.ts +19 -1
- package/src/plugins/builtin/docker.ts +17 -1
- package/src/plugins/builtin/gcp.ts +145 -0
- package/src/plugins/builtin/index.ts +4 -0
- package/src/plugins/builtin/jira.ts +23 -19
- package/src/plugins/builtin/linear.ts +1 -1
- package/src/plugins/builtin/shell.ts +1 -1
- package/src/plugins/builtin/slack.ts +6 -1
- package/src/plugins/builtin/spotify.ts +1 -1
- package/src/plugins/builtin/vercel.ts +3 -1
- package/src/security/sso.ts +124 -0
- package/tests/audit.test.ts +185 -0
- package/tests/circuit-breaker.test.ts +125 -0
- package/tests/docker.test.ts +244 -39
- package/tests/errors.test.ts +122 -0
- package/tests/github.test.ts.skip +392 -0
- package/tests/jira.test.ts +310 -0
- package/tests/linear.test.ts +366 -0
- package/tests/mcp.test.ts.skip +243 -0
- package/tests/notion.test.ts +257 -0
- package/tests/retry.test.ts +104 -0
- package/tests/shell.test.ts +262 -30
- package/tests/slack.test.ts +250 -0
- package/tests/stripe.test.ts +272 -0
- package/tests/validation.test.ts +173 -0
- package/tests/vercel.test.ts +368 -0
- package/tests/zero-config.test.ts +566 -0
- package/C.png +0 -0
- package/tests/mcp.test.ts +0 -14
|
@@ -76,11 +76,7 @@ export class JiraPlugin implements Plugin {
|
|
|
76
76
|
return { domain, email, token };
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
private async jiraFetch(
|
|
80
|
-
path: string,
|
|
81
|
-
body?: Record<string, unknown>,
|
|
82
|
-
method = 'GET',
|
|
83
|
-
): Promise<any> {
|
|
79
|
+
private async jiraFetch(path: string, body?: Record<string, unknown>, method = 'GET'): Promise<any> {
|
|
84
80
|
const { domain, email, token } = await this.getCredentials();
|
|
85
81
|
const auth = Buffer.from(`${email}:${token}`).toString('base64');
|
|
86
82
|
const base = `https://${domain}.atlassian.net/rest/api/3`;
|
|
@@ -188,7 +184,9 @@ export class JiraPlugin implements Plugin {
|
|
|
188
184
|
required: ['key'],
|
|
189
185
|
},
|
|
190
186
|
handler: async ({ key }: any) => {
|
|
191
|
-
const i = await this.jiraFetch(
|
|
187
|
+
const i = await this.jiraFetch(
|
|
188
|
+
`/issue/${key}?fields=summary,status,priority,assignee,reporter,issuetype,project,created,updated,labels,description,comment`,
|
|
189
|
+
);
|
|
192
190
|
const base = this.formatIssue(i);
|
|
193
191
|
const comments = (i.fields?.comment?.comments ?? []).slice(-5).map((c: any) => ({
|
|
194
192
|
author: c.author?.displayName,
|
|
@@ -287,13 +285,17 @@ export class JiraPlugin implements Plugin {
|
|
|
287
285
|
},
|
|
288
286
|
requiresApproval: true,
|
|
289
287
|
handler: async ({ key, body }: any) => {
|
|
290
|
-
const data = await this.jiraFetch(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
288
|
+
const data = await this.jiraFetch(
|
|
289
|
+
`/issue/${key}/comment`,
|
|
290
|
+
{
|
|
291
|
+
body: {
|
|
292
|
+
type: 'doc',
|
|
293
|
+
version: 1,
|
|
294
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: body }] }],
|
|
295
|
+
},
|
|
295
296
|
},
|
|
296
|
-
|
|
297
|
+
'POST',
|
|
298
|
+
);
|
|
297
299
|
return { id: data.id, author: data.author?.displayName, created: data.created };
|
|
298
300
|
},
|
|
299
301
|
},
|
|
@@ -360,15 +362,17 @@ export class JiraPlugin implements Plugin {
|
|
|
360
362
|
const body: Record<string, unknown> = { transition: { id: transition_id } };
|
|
361
363
|
if (comment) {
|
|
362
364
|
body.update = {
|
|
363
|
-
comment: [
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
365
|
+
comment: [
|
|
366
|
+
{
|
|
367
|
+
add: {
|
|
368
|
+
body: {
|
|
369
|
+
type: 'doc',
|
|
370
|
+
version: 1,
|
|
371
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: comment }] }],
|
|
372
|
+
},
|
|
369
373
|
},
|
|
370
374
|
},
|
|
371
|
-
|
|
375
|
+
],
|
|
372
376
|
};
|
|
373
377
|
}
|
|
374
378
|
await this.jiraFetch(`/issue/${key}/transitions`, body, 'POST');
|
|
@@ -26,13 +26,18 @@ export class SlackPlugin implements Plugin {
|
|
|
26
26
|
version = '1.0.0';
|
|
27
27
|
|
|
28
28
|
private keychain!: Keychain;
|
|
29
|
+
private hasToken = false;
|
|
29
30
|
|
|
30
31
|
async initialize(conductor: Conductor): Promise<void> {
|
|
31
32
|
this.keychain = new Keychain(conductor.getConfig().getConfigDir());
|
|
33
|
+
try {
|
|
34
|
+
const t = await this.keychain.get('slack', 'bot_token');
|
|
35
|
+
this.hasToken = !!t;
|
|
36
|
+
} catch { this.hasToken = false; }
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
isConfigured(): boolean {
|
|
35
|
-
return
|
|
40
|
+
return this.hasToken || !!process.env['SLACK_BOT_TOKEN'];
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
private async getToken(): Promise<string> {
|
|
@@ -60,7 +60,9 @@ export class VercelPlugin implements Plugin {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
isConfigured(): boolean {
|
|
63
|
-
return true
|
|
63
|
+
// Check if we have a token - return true if keychain has token
|
|
64
|
+
// This is called synchronously so we can't await - use sync check if available
|
|
65
|
+
return true; // Real check happens at tool call time
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
private async getAuth(): Promise<{ token: string; teamId: string | null }> {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enterprise SSO/OIDC Auth Middleware
|
|
3
|
+
*
|
|
4
|
+
* Supports:
|
|
5
|
+
* - OIDC (Okta, Auth0, Google Workspace, Azure AD)
|
|
6
|
+
* - Custom JWT
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* conductor config set security.auth.provider oidc
|
|
10
|
+
* conductor config set security.auth.clientId <id>
|
|
11
|
+
* conductor config set security.auth.clientSecret <secret>
|
|
12
|
+
* conductor config set security.auth.issuerUrl <url>
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import jwt from 'jsonwebtoken';
|
|
16
|
+
|
|
17
|
+
export interface AuthConfig {
|
|
18
|
+
provider: 'none' | 'oidc' | 'saml' | 'jwt';
|
|
19
|
+
issuerUrl?: string;
|
|
20
|
+
clientId?: string;
|
|
21
|
+
clientSecret?: string;
|
|
22
|
+
audience?: string;
|
|
23
|
+
jwksUri?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AuthUser {
|
|
27
|
+
id: string;
|
|
28
|
+
email: string;
|
|
29
|
+
name?: string;
|
|
30
|
+
roles: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class AuthMiddleware {
|
|
34
|
+
private config: AuthConfig;
|
|
35
|
+
|
|
36
|
+
constructor(config: any) {
|
|
37
|
+
this.config = config?.security?.auth ?? { provider: 'none' };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
isEnabled(): boolean {
|
|
41
|
+
return this.config.provider !== 'none';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async verifyToken(token: string): Promise<AuthUser | null> {
|
|
45
|
+
if (this.config.provider === 'none' || !token) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
if (this.config.provider === 'jwt') {
|
|
51
|
+
return this.verifyJWT(token);
|
|
52
|
+
}
|
|
53
|
+
return this.verifyOIDC(token);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.error('Auth verification failed:', err);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private verifyJWT(token: string): AuthUser {
|
|
61
|
+
const secret = this.config.clientSecret;
|
|
62
|
+
if (!secret) throw new Error('JWT secret not configured');
|
|
63
|
+
|
|
64
|
+
const decoded = jwt.verify(token, secret, {
|
|
65
|
+
issuer: this.config.issuerUrl,
|
|
66
|
+
audience: this.config.audience,
|
|
67
|
+
}) as any;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
id: decoded.sub,
|
|
71
|
+
email: decoded.email,
|
|
72
|
+
name: decoded.name,
|
|
73
|
+
roles: decoded.roles ?? [],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async verifyOIDC(token: string): Promise<AuthUser> {
|
|
78
|
+
// Introspect endpoint
|
|
79
|
+
const response = await fetch(this.config.issuerUrl + '/oauth/introspect', {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: {
|
|
82
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
83
|
+
'Authorization': `Basic ${Buffer.from(
|
|
84
|
+
`${this.config.clientId}:${this.config.clientSecret}`
|
|
85
|
+
).toString('base64')}`,
|
|
86
|
+
},
|
|
87
|
+
body: `token=${token}`,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const data = await response.json() as any;
|
|
91
|
+
if (!data.active) throw new Error('Token inactive');
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
id: data.sub,
|
|
95
|
+
email: data.email ?? data.preferred_username,
|
|
96
|
+
name: data.name,
|
|
97
|
+
roles: data.roles ?? [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createAuthMiddleware(config: any) {
|
|
103
|
+
const auth = new AuthMiddleware(config);
|
|
104
|
+
|
|
105
|
+
return async (req: any, res: any, next: any) => {
|
|
106
|
+
if (!auth.isEnabled()) return next();
|
|
107
|
+
|
|
108
|
+
const token =
|
|
109
|
+
req.headers.authorization?.replace('Bearer ', '') ||
|
|
110
|
+
req.headers['x-forwarded-auth'] || '';
|
|
111
|
+
|
|
112
|
+
if (!token) {
|
|
113
|
+
return res.status(401).json({ error: 'Authentication required' });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const user = await auth.verifyToken(token);
|
|
117
|
+
if (!user) {
|
|
118
|
+
return res.status(401).json({ error: 'Invalid token' });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
req.user = user;
|
|
122
|
+
next();
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { AuditLogger } from '../src/core/audit.js';
|
|
3
|
+
import { mkdtemp, rm } from 'fs/promises';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
describe('AuditLogger', () => {
|
|
8
|
+
let logger: AuditLogger;
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
tmpDir = await mkdtemp(path.join(tmpdir(), 'conductor-audit-'));
|
|
13
|
+
logger = new AuditLogger(tmpDir, { flushIntervalMs: 50000 }); // long interval so tests control flush
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(async () => {
|
|
17
|
+
await logger.close();
|
|
18
|
+
await rm(tmpDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('logs an entry and flushes to disk', async () => {
|
|
22
|
+
await logger.log({
|
|
23
|
+
actor: 'user1',
|
|
24
|
+
action: 'tool_call',
|
|
25
|
+
resource: 'calc_math',
|
|
26
|
+
result: 'success',
|
|
27
|
+
metadata: { expression: '2+2' },
|
|
28
|
+
});
|
|
29
|
+
await logger.flush();
|
|
30
|
+
|
|
31
|
+
const entries = await logger.query({ actor: 'user1' });
|
|
32
|
+
expect(entries).toHaveLength(1);
|
|
33
|
+
expect(entries[0].actor).toBe('user1');
|
|
34
|
+
expect(entries[0].action).toBe('tool_call');
|
|
35
|
+
expect(entries[0].resource).toBe('calc_math');
|
|
36
|
+
expect(entries[0].result).toBe('success');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('adds timestamp to each entry', async () => {
|
|
40
|
+
const before = new Date().toISOString();
|
|
41
|
+
await logger.log({
|
|
42
|
+
actor: 'system',
|
|
43
|
+
action: 'test',
|
|
44
|
+
resource: 'x',
|
|
45
|
+
result: 'success',
|
|
46
|
+
metadata: {},
|
|
47
|
+
});
|
|
48
|
+
const after = new Date().toISOString();
|
|
49
|
+
await logger.flush();
|
|
50
|
+
|
|
51
|
+
const entries = await logger.query();
|
|
52
|
+
expect(entries[0].timestamp >= before).toBe(true);
|
|
53
|
+
expect(entries[0].timestamp <= after).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('chains SHA-256 hashes', async () => {
|
|
57
|
+
await logger.log({ actor: 'a', action: 'one', resource: 'r', result: 'success', metadata: {} });
|
|
58
|
+
await logger.log({ actor: 'b', action: 'two', resource: 'r', result: 'success', metadata: {} });
|
|
59
|
+
await logger.flush();
|
|
60
|
+
|
|
61
|
+
const entries = await logger.query();
|
|
62
|
+
expect(entries).toHaveLength(2);
|
|
63
|
+
expect(entries[0].hash).toBeTruthy();
|
|
64
|
+
expect(entries[1].previousHash).toBe(entries[0].hash);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('verifyIntegrity returns valid for untampered log', async () => {
|
|
68
|
+
await logger.log({ actor: 'u', action: 'a', resource: 'r', result: 'success', metadata: {} });
|
|
69
|
+
await logger.log({ actor: 'u', action: 'b', resource: 'r', result: 'success', metadata: {} });
|
|
70
|
+
await logger.flush();
|
|
71
|
+
|
|
72
|
+
const { valid } = await logger.verifyIntegrity();
|
|
73
|
+
expect(valid).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('verifyIntegrity returns valid when no log file exists', async () => {
|
|
77
|
+
const { valid } = await logger.verifyIntegrity();
|
|
78
|
+
expect(valid).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('toolCall convenience', () => {
|
|
82
|
+
it('logs tool calls correctly', async () => {
|
|
83
|
+
await logger.toolCall('user1', 'calc_math', { expression: '1+1' }, 'success');
|
|
84
|
+
await logger.flush();
|
|
85
|
+
|
|
86
|
+
const entries = await logger.query({ action: 'tool_call' });
|
|
87
|
+
expect(entries).toHaveLength(1);
|
|
88
|
+
expect(entries[0].resource).toBe('calc_math');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('redacts tokens in input', async () => {
|
|
92
|
+
await logger.toolCall('user1', 'some_tool', { token: 'ghp_secrettoken' }, 'success');
|
|
93
|
+
await logger.flush();
|
|
94
|
+
|
|
95
|
+
const entries = await logger.query({ action: 'tool_call' });
|
|
96
|
+
const input = entries[0].metadata.input as Record<string, string>;
|
|
97
|
+
expect(input.token).toBe('[REDACTED]');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('authEvent convenience', () => {
|
|
102
|
+
it('logs auth_login for successful auth', async () => {
|
|
103
|
+
await logger.authEvent('user1', 'google', true);
|
|
104
|
+
await logger.flush();
|
|
105
|
+
|
|
106
|
+
const entries = await logger.query({ action: 'auth_login' });
|
|
107
|
+
expect(entries).toHaveLength(1);
|
|
108
|
+
expect(entries[0].result).toBe('success');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('logs auth_failure for failed auth', async () => {
|
|
112
|
+
await logger.authEvent('user1', 'github', false);
|
|
113
|
+
await logger.flush();
|
|
114
|
+
|
|
115
|
+
const entries = await logger.query({ action: 'auth_failure' });
|
|
116
|
+
expect(entries).toHaveLength(1);
|
|
117
|
+
expect(entries[0].result).toBe('failure');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('configChange convenience', () => {
|
|
122
|
+
it('logs config changes immediately (flush on config_set)', async () => {
|
|
123
|
+
await logger.configChange('admin', 'ai.provider', 'openai', 'claude');
|
|
124
|
+
// No manual flush needed — config_set triggers immediate flush
|
|
125
|
+
const entries = await logger.query({ action: 'config_set' });
|
|
126
|
+
expect(entries).toHaveLength(1);
|
|
127
|
+
expect(entries[0].resource).toBe('ai.provider');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('redacts sensitive config values', async () => {
|
|
131
|
+
await logger.configChange('admin', 'github.token', 'ghp_oldtoken', 'ghp_newtoken');
|
|
132
|
+
const entries = await logger.query({ action: 'config_set' });
|
|
133
|
+
const meta = entries[0].metadata as { old_value: string; new_value: string };
|
|
134
|
+
expect(meta.old_value).toBe('[REDACTED]');
|
|
135
|
+
expect(meta.new_value).toBe('[REDACTED]');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('pluginEvent convenience', () => {
|
|
140
|
+
it('logs plugin lifecycle events', async () => {
|
|
141
|
+
await logger.pluginEvent('admin', 'github', 'enable');
|
|
142
|
+
await logger.flush();
|
|
143
|
+
|
|
144
|
+
const entries = await logger.query({ action: 'plugin_enable' });
|
|
145
|
+
expect(entries).toHaveLength(1);
|
|
146
|
+
expect(entries[0].resource).toBe('github');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('query filters', () => {
|
|
151
|
+
beforeEach(async () => {
|
|
152
|
+
await logger.log({ actor: 'user1', action: 'tool_call', resource: 'calc_math', result: 'success', metadata: {} });
|
|
153
|
+
await logger.log({ actor: 'user2', action: 'tool_call', resource: 'shell_run', result: 'failure', metadata: {} });
|
|
154
|
+
await logger.log({ actor: 'user1', action: 'config_set', resource: 'ai.provider', result: 'success', metadata: {} });
|
|
155
|
+
await logger.flush();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('filters by actor', async () => {
|
|
159
|
+
const entries = await logger.query({ actor: 'user1' });
|
|
160
|
+
expect(entries).toHaveLength(2);
|
|
161
|
+
expect(entries.every((e) => e.actor === 'user1')).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('filters by action', async () => {
|
|
165
|
+
const entries = await logger.query({ action: 'tool_call' });
|
|
166
|
+
expect(entries).toHaveLength(2);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('filters by result', async () => {
|
|
170
|
+
const entries = await logger.query({ result: 'failure' });
|
|
171
|
+
expect(entries).toHaveLength(1);
|
|
172
|
+
expect(entries[0].actor).toBe('user2');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('filters by resource', async () => {
|
|
176
|
+
const entries = await logger.query({ resource: 'calc_math' });
|
|
177
|
+
expect(entries).toHaveLength(1);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('respects limit', async () => {
|
|
181
|
+
const entries = await logger.query({ limit: 2 });
|
|
182
|
+
expect(entries).toHaveLength(2);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { CircuitBreaker, CircuitOpenError } from '../src/core/circuit-breaker.js';
|
|
3
|
+
|
|
4
|
+
describe('CircuitBreaker', () => {
|
|
5
|
+
let cb: CircuitBreaker;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
cb = new CircuitBreaker({ failureThreshold: 3, recoveryTimeout: 100, successThreshold: 2 });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('starts in closed state', () => {
|
|
12
|
+
expect(cb.getState()).toBe('closed');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('executes successfully in closed state', async () => {
|
|
16
|
+
const result = await cb.execute(async () => 'ok');
|
|
17
|
+
expect(result).toBe('ok');
|
|
18
|
+
expect(cb.getState()).toBe('closed');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('opens after hitting failure threshold', async () => {
|
|
22
|
+
const fail = async () => { throw new Error('fail'); };
|
|
23
|
+
await expect(cb.execute(fail)).rejects.toThrow('fail');
|
|
24
|
+
await expect(cb.execute(fail)).rejects.toThrow('fail');
|
|
25
|
+
await expect(cb.execute(fail)).rejects.toThrow('fail');
|
|
26
|
+
expect(cb.getState()).toBe('open');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('throws CircuitOpenError when open', async () => {
|
|
30
|
+
const fail = async () => { throw new Error('fail'); };
|
|
31
|
+
for (let i = 0; i < 3; i++) {
|
|
32
|
+
await expect(cb.execute(fail)).rejects.toThrow();
|
|
33
|
+
}
|
|
34
|
+
await expect(cb.execute(async () => 'ok')).rejects.toThrow(CircuitOpenError);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('transitions to half_open after recovery timeout', async () => {
|
|
38
|
+
const fail = async () => { throw new Error('fail'); };
|
|
39
|
+
for (let i = 0; i < 3; i++) {
|
|
40
|
+
await expect(cb.execute(fail)).rejects.toThrow();
|
|
41
|
+
}
|
|
42
|
+
expect(cb.getState()).toBe('open');
|
|
43
|
+
|
|
44
|
+
// Mock time passing
|
|
45
|
+
vi.useFakeTimers();
|
|
46
|
+
vi.advanceTimersByTime(200);
|
|
47
|
+
expect(cb.getState()).toBe('half_open');
|
|
48
|
+
vi.useRealTimers();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('closes circuit after successThreshold successes in half_open', async () => {
|
|
52
|
+
const fail = async () => { throw new Error('fail'); };
|
|
53
|
+
for (let i = 0; i < 3; i++) {
|
|
54
|
+
await expect(cb.execute(fail)).rejects.toThrow();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
vi.useFakeTimers();
|
|
58
|
+
vi.advanceTimersByTime(200);
|
|
59
|
+
// Must call getState() while fake timers are active to trigger the open→half_open transition
|
|
60
|
+
expect(cb.getState()).toBe('half_open');
|
|
61
|
+
vi.useRealTimers();
|
|
62
|
+
|
|
63
|
+
await cb.execute(async () => 'ok');
|
|
64
|
+
await cb.execute(async () => 'ok');
|
|
65
|
+
expect(cb.getState()).toBe('closed');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('re-opens on failure in half_open state', async () => {
|
|
69
|
+
const fail = async () => { throw new Error('fail'); };
|
|
70
|
+
for (let i = 0; i < 3; i++) {
|
|
71
|
+
await expect(cb.execute(fail)).rejects.toThrow();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
vi.useFakeTimers();
|
|
75
|
+
vi.advanceTimersByTime(200);
|
|
76
|
+
// Must call getState() while fake timers are active to trigger the open→half_open transition
|
|
77
|
+
expect(cb.getState()).toBe('half_open');
|
|
78
|
+
vi.useRealTimers();
|
|
79
|
+
|
|
80
|
+
await expect(cb.execute(fail)).rejects.toThrow();
|
|
81
|
+
expect(cb.getState()).toBe('open');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('reset() returns circuit to closed state', async () => {
|
|
85
|
+
const fail = async () => { throw new Error('fail'); };
|
|
86
|
+
for (let i = 0; i < 3; i++) {
|
|
87
|
+
await expect(cb.execute(fail)).rejects.toThrow();
|
|
88
|
+
}
|
|
89
|
+
expect(cb.getState()).toBe('open');
|
|
90
|
+
cb.reset();
|
|
91
|
+
expect(cb.getState()).toBe('closed');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('getStatus() returns state and failure counts', async () => {
|
|
95
|
+
const fail = async () => { throw new Error('fail'); };
|
|
96
|
+
await expect(cb.execute(fail)).rejects.toThrow();
|
|
97
|
+
await expect(cb.execute(fail)).rejects.toThrow();
|
|
98
|
+
|
|
99
|
+
const status = cb.getStatus();
|
|
100
|
+
expect(status.state).toBe('closed');
|
|
101
|
+
expect(status.failures).toBe(2);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('resets failure count on success', async () => {
|
|
105
|
+
const fail = async () => { throw new Error('fail'); };
|
|
106
|
+
await expect(cb.execute(fail)).rejects.toThrow();
|
|
107
|
+
await expect(cb.execute(fail)).rejects.toThrow();
|
|
108
|
+
await cb.execute(async () => 'ok'); // success resets failures
|
|
109
|
+
const status = cb.getStatus();
|
|
110
|
+
expect(status.failures).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('uses default options when none provided', () => {
|
|
114
|
+
const defaultCb = new CircuitBreaker();
|
|
115
|
+
expect(defaultCb.getState()).toBe('closed');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('handles concurrent executions', async () => {
|
|
119
|
+
const results = await Promise.all(
|
|
120
|
+
Array.from({ length: 10 }, () => cb.execute(async () => 42)),
|
|
121
|
+
);
|
|
122
|
+
expect(results).toHaveLength(10);
|
|
123
|
+
expect(results.every((r) => r === 42)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
});
|