agent-relay-server 0.83.1 → 0.85.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/docs/openapi.json +151 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-BTBQr8uG.js → MessageBubble-DqFyach0.js} +2 -2
- package/public/assets/{MessageBubble-BTBQr8uG.js.map → MessageBubble-DqFyach0.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CoWJJVyD.js → PlanGraphPanel-CCvKbYWe.js} +2 -2
- package/public/assets/{PlanGraphPanel-CoWJJVyD.js.map → PlanGraphPanel-CCvKbYWe.js.map} +1 -1
- package/public/assets/{activity-XqRFu_9T.js → activity-C6RvknQQ.js} +2 -2
- package/public/assets/{activity-XqRFu_9T.js.map → activity-C6RvknQQ.js.map} +1 -1
- package/public/assets/{agents-BWmmWj4Q.js → agents-D7HZJ5eG.js} +2 -2
- package/public/assets/{agents-BWmmWj4Q.js.map → agents-D7HZJ5eG.js.map} +1 -1
- package/public/assets/{automation-DRtOENs0.js → automation-BUazA3lr.js} +2 -2
- package/public/assets/{automation-DRtOENs0.js.map → automation-BUazA3lr.js.map} +1 -1
- package/public/assets/{chat-gfUxbTus.js → chat-DBTEaf-H.js} +2 -2
- package/public/assets/{chat-gfUxbTus.js.map → chat-DBTEaf-H.js.map} +1 -1
- package/public/assets/display-CWMHll1J.js.map +1 -1
- package/public/assets/{formatted-body-impl-DpNHGXXQ.js → formatted-body-impl-KG0iExZ-.js} +2 -2
- package/public/assets/{formatted-body-impl-DpNHGXXQ.js.map → formatted-body-impl-KG0iExZ-.js.map} +1 -1
- package/public/assets/index-LUIvWcx6.css +2 -0
- package/public/assets/index-TZskQfQL.js +23 -0
- package/public/assets/index-TZskQfQL.js.map +1 -0
- package/public/assets/{insights-DPaJ5Hme.js → insights-JL1s7Uul.js} +2 -2
- package/public/assets/{insights-DPaJ5Hme.js.map → insights-JL1s7Uul.js.map} +1 -1
- package/public/assets/{maintenance-DsszLb92.js → maintenance-CTMfhXFM.js} +2 -2
- package/public/assets/{maintenance-DsszLb92.js.map → maintenance-CTMfhXFM.js.map} +1 -1
- package/public/assets/{managed-agents-DqWPxFE0.js → managed-agents-C4cb3xQ4.js} +2 -2
- package/public/assets/{managed-agents-DqWPxFE0.js.map → managed-agents-C4cb3xQ4.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-CWR4ILHK.js → markdown-preview-impl-DQIi0O7r.js} +2 -2
- package/public/assets/{markdown-preview-impl-CWR4ILHK.js.map → markdown-preview-impl-DQIi0O7r.js.map} +1 -1
- package/public/assets/{memory-C2s9bJ38.js → memory-DJPXFdgw.js} +2 -2
- package/public/assets/{memory-C2s9bJ38.js.map → memory-DJPXFdgw.js.map} +1 -1
- package/public/assets/{messages-DHRJNAvy.js → messages-RSD2g4Wx.js} +2 -2
- package/public/assets/{messages-DHRJNAvy.js.map → messages-RSD2g4Wx.js.map} +1 -1
- package/public/assets/{orchestrators-BGNNllJc.js → orchestrators-BGIkULBF.js} +2 -2
- package/public/assets/{orchestrators-BGNNllJc.js.map → orchestrators-BGIkULBF.js.map} +1 -1
- package/public/assets/{overview-C1vZfyhW.js → overview-ByE9V8pe.js} +2 -2
- package/public/assets/{overview-C1vZfyhW.js.map → overview-ByE9V8pe.js.map} +1 -1
- package/public/assets/{pairs-C1uzo8x0.js → pairs-C284ntvD.js} +2 -2
- package/public/assets/{pairs-C1uzo8x0.js.map → pairs-C284ntvD.js.map} +1 -1
- package/public/assets/{security-XhAAP6q3.js → security-vDglScsg.js} +2 -2
- package/public/assets/{security-XhAAP6q3.js.map → security-vDglScsg.js.map} +1 -1
- package/public/assets/{select-DEGUFEjx.js → select-DaOiWDBR.js} +2 -2
- package/public/assets/{select-DEGUFEjx.js.map → select-DaOiWDBR.js.map} +1 -1
- package/public/assets/{settings-DTbVUdlZ.js → settings-DiLcbSqx.js} +2 -2
- package/public/assets/{settings-DTbVUdlZ.js.map → settings-DiLcbSqx.js.map} +1 -1
- package/public/assets/{tasks-zwGo7hon.js → tasks-DWJdJra3.js} +2 -2
- package/public/assets/{tasks-zwGo7hon.js.map → tasks-DWJdJra3.js.map} +1 -1
- package/public/assets/{teams-Brc5D8ox.js → teams-FejXkix4.js} +2 -2
- package/public/assets/{teams-Brc5D8ox.js.map → teams-FejXkix4.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-C6yUAL8G.js → terminal-viewer-impl-Bkt1w42o.js} +2 -2
- package/public/assets/{terminal-viewer-impl-C6yUAL8G.js.map → terminal-viewer-impl-Bkt1w42o.js.map} +1 -1
- package/public/assets/{user-avatar-Bw1R011j.js → user-avatar-ClE2dMD8.js} +2 -2
- package/public/assets/{user-avatar-Bw1R011j.js.map → user-avatar-ClE2dMD8.js.map} +1 -1
- package/public/assets/{work-queue-ge4R7keo.js → work-queue-DZdv9kOb.js} +2 -2
- package/public/assets/{work-queue-ge4R7keo.js.map → work-queue-DZdv9kOb.js.map} +1 -1
- package/public/index.html +2 -2
- package/src/agent-death-diagnosis.ts +255 -0
- package/src/agent-lifecycle-events.ts +42 -19
- package/src/auth/index.ts +23 -0
- package/src/auth/password.ts +122 -0
- package/src/auth/provider.ts +60 -0
- package/src/auth/session.ts +92 -0
- package/src/bus.ts +18 -4
- package/src/db/agents.ts +35 -2
- package/src/db/auth-identities.ts +119 -0
- package/src/db/crash-reports.ts +115 -0
- package/src/db/index.ts +2 -0
- package/src/db/migrations.ts +11 -0
- package/src/index.ts +6 -1
- package/src/maintenance/jobs.ts +6 -1
- package/src/maintenance/teams.ts +68 -1
- package/src/mcp/index.ts +2 -1
- package/src/mcp/tools-messaging.ts +38 -0
- package/src/notification-types.ts +2 -0
- package/src/routes/_shared.ts +40 -2
- package/src/routes/agents.ts +11 -1
- package/src/routes/auth.ts +107 -0
- package/src/routes/commands.ts +21 -3
- package/src/routes/index.ts +10 -1
- package/src/routes/orchestrator.ts +14 -1
- package/src/routes/tokens.ts +8 -2
- package/src/security.ts +1 -1
- package/src/services/terminal-provider-exit.ts +33 -0
- package/src/spawn-targets.ts +19 -8
- package/src/team-idle-tracker.ts +15 -0
- package/src/validation.ts +2 -0
- package/src/workspace-actions.ts +5 -1
- package/src/workspace-auto-merge.ts +87 -4
- package/src/workspace-merge.ts +10 -0
- package/public/assets/index-D8TVUdlC.js +0 -22
- package/public/assets/index-D8TVUdlC.js.map +0 -1
- package/public/assets/index-DgZLDL0K.css +0 -2
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Password authentication provider (#389). The only built-in provider in Phase 1. Secrets are
|
|
2
|
+
// hashed with scrypt (node:crypto — no dependency) and compared with `timingSafeEqual`, the same
|
|
3
|
+
// constant-time primitive the token layer uses. The hash is stored in `auth_identities.secret_hash`
|
|
4
|
+
// keyed by (provider="password", provider_subject=<username>).
|
|
5
|
+
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import { createAuthIdentity, getAuthIdentity, type AuthIdentity } from "../db/auth-identities.ts";
|
|
7
|
+
import { isRecord } from "agent-relay-sdk";
|
|
8
|
+
import type { AuthProvider, AuthVerifyResult } from "./provider.ts";
|
|
9
|
+
|
|
10
|
+
/** The provider id stored in `auth_identities.provider` and sent in a login request's `provider`. */
|
|
11
|
+
export const PASSWORD_PROVIDER_ID = "password";
|
|
12
|
+
|
|
13
|
+
// scrypt cost parameters. N=2^14 keeps 128*N*r ≈ 16MB under node's 32MB default maxmem; r=8/p=1 are
|
|
14
|
+
// the standard interactive-login settings. keylen 64. Encoded into the hash string so a future cost
|
|
15
|
+
// bump stays verifiable against existing hashes.
|
|
16
|
+
const SCRYPT_N = 16384;
|
|
17
|
+
const SCRYPT_R = 8;
|
|
18
|
+
const SCRYPT_P = 1;
|
|
19
|
+
const KEYLEN = 64;
|
|
20
|
+
const SALT_BYTES = 16;
|
|
21
|
+
export const DUMMY_PASSWORD_HASH =
|
|
22
|
+
`scrypt$${SCRYPT_N}$${SCRYPT_R}$${SCRYPT_P}$${"00".repeat(SALT_BYTES)}$${"00".repeat(KEYLEN)}`;
|
|
23
|
+
|
|
24
|
+
type PasswordVerifier = (password: string, stored: string | null) => boolean;
|
|
25
|
+
type PasswordIdentityLookup = (provider: string, providerSubject: string) => AuthIdentity | undefined;
|
|
26
|
+
|
|
27
|
+
interface PasswordVerifyAttempt {
|
|
28
|
+
valid: boolean;
|
|
29
|
+
attemptedKdf: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Hash a plaintext password into a self-describing `scrypt$N$r$p$saltHex$hashHex` string. */
|
|
33
|
+
export function hashPassword(password: string): string {
|
|
34
|
+
const salt = randomBytes(SALT_BYTES);
|
|
35
|
+
const derived = scryptSync(password, salt, KEYLEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
|
|
36
|
+
return `scrypt$${SCRYPT_N}$${SCRYPT_R}$${SCRYPT_P}$${salt.toString("hex")}$${derived.toString("hex")}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function verifyPasswordAttempt(password: string, stored: string | null): PasswordVerifyAttempt {
|
|
40
|
+
if (!stored) return { valid: false, attemptedKdf: false };
|
|
41
|
+
const parts = stored.split("$");
|
|
42
|
+
if (parts.length !== 6 || parts[0] !== "scrypt") return { valid: false, attemptedKdf: false };
|
|
43
|
+
const [, nRaw, rRaw, pRaw, saltHex, hashHex] = parts as [string, string, string, string, string, string];
|
|
44
|
+
const N = Number(nRaw);
|
|
45
|
+
const r = Number(rRaw);
|
|
46
|
+
const p = Number(pRaw);
|
|
47
|
+
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) return { valid: false, attemptedKdf: false };
|
|
48
|
+
const expected = Buffer.from(hashHex, "hex");
|
|
49
|
+
if (expected.length <= 0) return { valid: false, attemptedKdf: false };
|
|
50
|
+
let derived: Buffer;
|
|
51
|
+
try {
|
|
52
|
+
derived = scryptSync(password, Buffer.from(saltHex, "hex"), expected.length, { N, r, p });
|
|
53
|
+
} catch {
|
|
54
|
+
return { valid: false, attemptedKdf: false };
|
|
55
|
+
}
|
|
56
|
+
return { valid: derived.length === expected.length && timingSafeEqual(derived, expected), attemptedKdf: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Constant-time verify a plaintext password against a stored `scrypt$...` hash. */
|
|
60
|
+
export function verifyPassword(password: string, stored: string | null): boolean {
|
|
61
|
+
return verifyPasswordAttempt(password, stored).valid;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function verifyPasswordWithDummyFallback(password: string, stored: string | null): boolean {
|
|
65
|
+
const result = verifyPasswordAttempt(password, stored);
|
|
66
|
+
if (result.attemptedKdf) return result.valid;
|
|
67
|
+
verifyPasswordAttempt(password, DUMMY_PASSWORD_HASH);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Link a password login to a user (admin pre-link / first-user bootstrap). Hashes the password and
|
|
73
|
+
* inserts an `auth_identities` row. `username` is the `provider_subject` the user logs in with.
|
|
74
|
+
*/
|
|
75
|
+
export function createPasswordIdentity(input: {
|
|
76
|
+
userId: string;
|
|
77
|
+
username: string;
|
|
78
|
+
password: string;
|
|
79
|
+
metadata?: Record<string, unknown>;
|
|
80
|
+
}): AuthIdentity {
|
|
81
|
+
return createAuthIdentity({
|
|
82
|
+
userId: input.userId,
|
|
83
|
+
provider: PASSWORD_PROVIDER_ID,
|
|
84
|
+
providerSubject: input.username,
|
|
85
|
+
secretHash: hashPassword(input.password),
|
|
86
|
+
metadata: input.metadata,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Parse `{ username, password }` from a login request body. */
|
|
91
|
+
function readCredentials(credentials: unknown): { username: string; password: string } | null {
|
|
92
|
+
if (!isRecord(credentials)) return null;
|
|
93
|
+
const username = credentials.username ?? credentials.handle ?? credentials.email;
|
|
94
|
+
const password = credentials.password;
|
|
95
|
+
if (typeof username !== "string" || !username || typeof password !== "string" || !password) return null;
|
|
96
|
+
return { username, password };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function verifyPasswordCredentials(
|
|
100
|
+
credentials: unknown,
|
|
101
|
+
opts: { lookupIdentity?: PasswordIdentityLookup; verifyStoredPassword?: PasswordVerifier } = {},
|
|
102
|
+
): AuthVerifyResult | null {
|
|
103
|
+
const creds = readCredentials(credentials);
|
|
104
|
+
if (!creds) return null;
|
|
105
|
+
const lookupIdentity = opts.lookupIdentity ?? getAuthIdentity;
|
|
106
|
+
const verifyStoredPassword = opts.verifyStoredPassword ?? verifyPasswordWithDummyFallback;
|
|
107
|
+
const identity = lookupIdentity(PASSWORD_PROVIDER_ID, creds.username);
|
|
108
|
+
const passwordMatches = verifyStoredPassword(creds.password, identity?.secretHash ?? DUMMY_PASSWORD_HASH);
|
|
109
|
+
if (!identity || !passwordMatches) return null;
|
|
110
|
+
return { providerSubject: creds.username };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const passwordAuthProvider: AuthProvider = {
|
|
114
|
+
id: PASSWORD_PROVIDER_ID,
|
|
115
|
+
displayName: "Password",
|
|
116
|
+
flow: "password",
|
|
117
|
+
verify(credentials: unknown): AuthVerifyResult | null {
|
|
118
|
+
// Unknown username, malformed stored hash, and wrong password all pay one scrypt verification
|
|
119
|
+
// before returning null, so login failure timing does not identify linked accounts.
|
|
120
|
+
return verifyPasswordCredentials(credentials);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Pluggable authentication providers (#389). An AuthProvider turns a login request into a
|
|
2
|
+
// `providerSubject` — the stable external identifier (a username, an OAuth `sub`, an email) that
|
|
3
|
+
// `auth_identities` maps onto a `users.id`. The login route is provider-agnostic: it resolves the
|
|
4
|
+
// provider, calls `verify()`, then looks the returned subject up in `auth_identities`. Adding a
|
|
5
|
+
// provider (GitHub OAuth in Phase 2, magic-link, …) is a `register()` call plus rows — no schema
|
|
6
|
+
// change, no route change (the pluggability guarantee #389 must hold).
|
|
7
|
+
//
|
|
8
|
+
// In-process registry. The registry knows ONLY the interface — concrete providers register
|
|
9
|
+
// themselves via the composition root (src/auth/index.ts), which keeps this module free of any
|
|
10
|
+
// dependency on a concrete provider (no registry ↔ plugin cycle).
|
|
11
|
+
|
|
12
|
+
/** How a provider proves identity — drives the login UI and which fields `verify()` reads. */
|
|
13
|
+
export type AuthProviderFlow = "password" | "oauth" | "magic-link";
|
|
14
|
+
|
|
15
|
+
/** Result of a successful `verify()`: the external subject to resolve against `auth_identities`. */
|
|
16
|
+
export interface AuthVerifyResult {
|
|
17
|
+
providerSubject: string;
|
|
18
|
+
/** Optional provider-supplied profile bits (display name, email) for first-link/auto-provision. */
|
|
19
|
+
profile?: Record<string, unknown>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AuthProvider {
|
|
23
|
+
/** Stable id; the `auth_identities.provider` value and the `provider` field a login request sends. */
|
|
24
|
+
id: string;
|
|
25
|
+
/** Human label for the login UI. */
|
|
26
|
+
displayName: string;
|
|
27
|
+
flow: AuthProviderFlow;
|
|
28
|
+
/**
|
|
29
|
+
* Begin an interactive (redirect) flow — returns where to send the user (e.g. an OAuth authorize
|
|
30
|
+
* URL). Optional: password is single-step and omits it. Phase 2 (GitHub OAuth) implements it.
|
|
31
|
+
*/
|
|
32
|
+
begin?(req: Request): Promise<{ redirectUrl: string } | null> | ({ redirectUrl: string } | null);
|
|
33
|
+
/**
|
|
34
|
+
* Verify the presented credentials and return the authenticated subject, or null on failure.
|
|
35
|
+
* MUST be constant-ish on failure (no "unknown user" vs "bad password" distinction leaking to
|
|
36
|
+
* the caller — both return null). The route maps a non-null subject to a user.
|
|
37
|
+
*/
|
|
38
|
+
verify(credentials: unknown): Promise<AuthVerifyResult | null> | (AuthVerifyResult | null);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const registry = new Map<string, AuthProvider>();
|
|
42
|
+
|
|
43
|
+
/** Register (or replace) a provider. Idempotent by id — re-registering overrides, which is how
|
|
44
|
+
* tests install a throwaway provider to prove pluggability. */
|
|
45
|
+
export function registerAuthProvider(provider: AuthProvider): void {
|
|
46
|
+
registry.set(provider.id, provider);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Remove a provider by id (test teardown / runtime disable). Returns true if one was removed. */
|
|
50
|
+
export function unregisterAuthProvider(id: string): boolean {
|
|
51
|
+
return registry.delete(id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getAuthProvider(id: string): AuthProvider | undefined {
|
|
55
|
+
return registry.get(id);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function listAuthProviders(): AuthProvider[] {
|
|
59
|
+
return [...registry.values()];
|
|
60
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Human session tokens (#389). A session is a component token minted via the existing `createToken`
|
|
2
|
+
// — the `tokens` table IS the session store, so `verifyComponentToken` already enforces expiry and
|
|
3
|
+
// revocation, and "sign out everywhere" is just revoking every token for a `sub`. The session token
|
|
4
|
+
// reuses ONLY the signing primitive: it carries a new `sub` namespace (`user:<id>`) and a new
|
|
5
|
+
// `role` ("user"), so no machine-token path branches on it.
|
|
6
|
+
import { userSinkId, userIdFromAddress, isHumanAgentId, type User, type UserRole } from "agent-relay-sdk";
|
|
7
|
+
import { createToken } from "../token-db.ts";
|
|
8
|
+
import { verifyComponentToken } from "../security.ts";
|
|
9
|
+
import { getUser } from "../db/users.ts";
|
|
10
|
+
import type { ComponentToken } from "../types.ts";
|
|
11
|
+
|
|
12
|
+
/** Access token lifetime (~1h) — short, because refresh re-mints it silently. */
|
|
13
|
+
export const ACCESS_TTL_SECONDS = 60 * 60;
|
|
14
|
+
/** Refresh token lifetime (~30d) — the dashboard re-mints the access token until this expires. */
|
|
15
|
+
export const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
16
|
+
/** Marker scope carried ONLY by refresh tokens. It matches no route's required scope, so a refresh
|
|
17
|
+
* token can never act as an access token; the refresh route validates it explicitly. */
|
|
18
|
+
export const REFRESH_SCOPE = "auth:refresh";
|
|
19
|
+
|
|
20
|
+
/** The `role` stamped on a human session token. New namespace — no machine path branches on it. */
|
|
21
|
+
export const USER_TOKEN_ROLE = "user";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Map a user role to the scopes its session carries. STOPGAP for #392 (real RBAC): admins get full
|
|
25
|
+
* access via the `admin:*` alias (→ `system:admin`, which `hasScope` short-circuits everywhere), and
|
|
26
|
+
* everyone else gets a read-only baseline. When #392 lands the per-role scope matrix, replace this
|
|
27
|
+
* one function — every session inherits the new policy with no other change.
|
|
28
|
+
*/
|
|
29
|
+
export function scopesForUserRole(role: UserRole): string[] {
|
|
30
|
+
if (role === "admin") return ["admin:*"];
|
|
31
|
+
if (role === "operator") return ["agent:read", "agent:write", "message:read", "message:send", "task:read", "task:write"];
|
|
32
|
+
return ["agent:read", "message:read", "task:read"];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MintedSession {
|
|
36
|
+
accessToken: string;
|
|
37
|
+
refreshToken: string;
|
|
38
|
+
accessJti: string;
|
|
39
|
+
refreshJti: string;
|
|
40
|
+
expiresIn: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Mint an access + refresh token pair for a logged-in user. `constraints.tenantId` carries the
|
|
45
|
+
* SaaS seam (identity-only — enforces nothing). Both tokens share the `user:<id>` sub so a single
|
|
46
|
+
* "sign out everywhere" revoke (by sub) kills the whole session family.
|
|
47
|
+
*/
|
|
48
|
+
export function mintUserSession(user: User): MintedSession {
|
|
49
|
+
const sub = userSinkId(user.id);
|
|
50
|
+
const constraints = user.tenantId ? { tenantId: user.tenantId } : undefined;
|
|
51
|
+
const access = createToken({
|
|
52
|
+
sub,
|
|
53
|
+
role: USER_TOKEN_ROLE,
|
|
54
|
+
scope: scopesForUserRole(user.role),
|
|
55
|
+
constraints,
|
|
56
|
+
ttlSeconds: ACCESS_TTL_SECONDS,
|
|
57
|
+
createdBy: `auth:login:${user.id}`,
|
|
58
|
+
});
|
|
59
|
+
const refresh = createToken({
|
|
60
|
+
sub,
|
|
61
|
+
role: USER_TOKEN_ROLE,
|
|
62
|
+
scope: [REFRESH_SCOPE],
|
|
63
|
+
constraints,
|
|
64
|
+
ttlSeconds: REFRESH_TTL_SECONDS,
|
|
65
|
+
createdBy: `auth:refresh:${user.id}`,
|
|
66
|
+
});
|
|
67
|
+
return {
|
|
68
|
+
accessToken: access.token,
|
|
69
|
+
refreshToken: refresh.token,
|
|
70
|
+
accessJti: access.record.jti,
|
|
71
|
+
refreshJti: refresh.record.jti,
|
|
72
|
+
expiresIn: ACCESS_TTL_SECONDS,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Validate a refresh token and resolve it to its live user. Returns null unless the token verifies
|
|
78
|
+
* (signature + unexpired + unrevoked, via `verifyComponentToken`), carries the refresh scope, names
|
|
79
|
+
* a human `sub`, and that user still exists and is active. The caller mints a fresh session for the
|
|
80
|
+
* user.
|
|
81
|
+
*/
|
|
82
|
+
export function resolveRefreshToken(token: string): { user: User; payload: ComponentToken } | null {
|
|
83
|
+
const payload = verifyComponentToken(token);
|
|
84
|
+
if (!payload) return null;
|
|
85
|
+
if (!payload.scope.includes(REFRESH_SCOPE)) return null;
|
|
86
|
+
if (!isHumanAgentId(payload.sub)) return null;
|
|
87
|
+
const userId = userIdFromAddress(payload.sub);
|
|
88
|
+
if (!userId) return null;
|
|
89
|
+
const user = getUser(userId);
|
|
90
|
+
if (!user || user.status !== "active") return null;
|
|
91
|
+
return { user, payload };
|
|
92
|
+
}
|
package/src/bus.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { getCommand, updateCommand } from "./commands-db";
|
|
|
7
7
|
import { emitCommandEvent } from "./command-events";
|
|
8
8
|
import { getLifecycleManager } from "./lifecycle-manager";
|
|
9
9
|
import { noteAgentTimelineEvent, noteCompactionCommandCompleted } from "./compaction-watch";
|
|
10
|
+
import { reconcileTerminalProviderExit } from "./services/terminal-provider-exit";
|
|
10
11
|
import { registerAgent } from "./services/register-agent";
|
|
11
12
|
import { authContextFromBus } from "./services/auth-context";
|
|
12
13
|
import { commandAuthorizationResource, dispatchCommand } from "./services/dispatch-command";
|
|
@@ -143,10 +144,19 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
143
144
|
case "heartbeat":
|
|
144
145
|
withRegistered(ws, frame, (conn) => {
|
|
145
146
|
if (conn.agentId) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
// #633 — a runner whose provider terminally exited stays alive and keeps sending idle
|
|
148
|
+
// keepalives (semanticStatus is never "offline"). Those must NOT resurrect an agent the
|
|
149
|
+
// server already marked offline for that terminal exit, or the death would keep masking
|
|
150
|
+
// itself. Ignore the keepalive (the row ages out via the normal offline-prune); a genuine
|
|
151
|
+
// resume sends a `status`/`register` frame, which clears the hold below.
|
|
152
|
+
const current = getAgent(conn.agentId);
|
|
153
|
+
const heldOffline = current?.status === "offline" && isRecord(current.meta?.terminalProviderExit);
|
|
154
|
+
if (!heldOffline) {
|
|
155
|
+
const runtime = runtimeFromMeta(frame.payload.meta);
|
|
156
|
+
heartbeat(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch }, runtime);
|
|
157
|
+
if (frame.payload.status) setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
|
|
158
|
+
emitAgentStatusEvent(conn.agentId);
|
|
159
|
+
}
|
|
150
160
|
}
|
|
151
161
|
if (frame.id) send(ws, { type: "ack", payload: { frameId: frame.id } });
|
|
152
162
|
});
|
|
@@ -180,6 +190,10 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
|
|
|
180
190
|
emitAgentStatusEvent(conn.agentId);
|
|
181
191
|
// #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
|
|
182
192
|
probeWorkspaceOnTurnEnd(before, after);
|
|
193
|
+
// #633 — silent clean-exit: a runner that stays alive after its provider terminally exited
|
|
194
|
+
// marks the offline edge with `terminalProviderExit`; the owning service turns that into a
|
|
195
|
+
// terminal event + #636 diagnosis (and clears the hold on a genuine resume).
|
|
196
|
+
reconcileTerminalProviderExit(conn.agentId, before, after);
|
|
183
197
|
});
|
|
184
198
|
return;
|
|
185
199
|
case "ack":
|
package/src/db/agents.ts
CHANGED
|
@@ -331,6 +331,36 @@ export function mergeAgentMeta(id: string, meta: Record<string, unknown>, guard?
|
|
|
331
331
|
return result.changes > 0;
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
/** #633/#634 — persist a durable "this child became ready" marker. Death classification
|
|
335
|
+
* (`notifyAgentOffline`) keys `agent.exited` vs `agent.spawn_failed` off readiness, but the live
|
|
336
|
+
* signal is an in-memory set that a relay restart wipes — so a long-ready child's later clean exit
|
|
337
|
+
* would mis-fire `spawn_failed`. This stamp survives the restart in SQLite, so the offline edge
|
|
338
|
+
* still classifies as `agent.exited`. Idempotent: only stamped once (first ready). */
|
|
339
|
+
export function markAgentReadyNotified(id: string, at: number = Date.now()): boolean {
|
|
340
|
+
const agent = getAgent(id);
|
|
341
|
+
if (!agent) return false;
|
|
342
|
+
if (typeof agent.meta?.readyNotifiedAt === "number") return false;
|
|
343
|
+
const merged = { ...(agent.meta ?? {}), readyNotifiedAt: at };
|
|
344
|
+
return getDb().query("UPDATE agents SET meta = ? WHERE id = ?").run(JSON.stringify(merged), id).changes > 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Delete specific keys from an agent's meta blob. Returns true if anything changed. Used to clear
|
|
348
|
+
* the #633 `terminalProviderExit` hold when a dead provider genuinely resumes. */
|
|
349
|
+
export function clearAgentMetaKeys(id: string, keys: string[]): boolean {
|
|
350
|
+
const agent = getAgent(id);
|
|
351
|
+
if (!agent?.meta) return false;
|
|
352
|
+
const meta = { ...agent.meta };
|
|
353
|
+
let changed = false;
|
|
354
|
+
for (const key of keys) {
|
|
355
|
+
if (key in meta) {
|
|
356
|
+
delete meta[key];
|
|
357
|
+
changed = true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (!changed) return false;
|
|
361
|
+
return getDb().query("UPDATE agents SET meta = ? WHERE id = ?").run(JSON.stringify(meta), id).changes > 0;
|
|
362
|
+
}
|
|
363
|
+
|
|
334
364
|
export function recordAgentExitDiagnostics(id: string, diagnostics: ManagedSessionExitDiagnostics): AgentCard | null {
|
|
335
365
|
const agent = getAgent(id);
|
|
336
366
|
if (!agent) return null;
|
|
@@ -341,9 +371,12 @@ export function recordAgentExitDiagnostics(id: string, diagnostics: ManagedSessi
|
|
|
341
371
|
lastExit: diagnostics,
|
|
342
372
|
lastExitAt: diagnostics.detectedAt || now,
|
|
343
373
|
};
|
|
374
|
+
// #636 — do NOT clobber last_seen with `now`: it is the agent's genuine last heartbeat, and the
|
|
375
|
+
// death diagnosis derives the real heartbeat gap from it (the orchestrator exit path diagnoses
|
|
376
|
+
// off the row returned here). "When we detected the exit" lives in meta.lastExitAt instead.
|
|
344
377
|
const result = getDb()
|
|
345
|
-
.query("UPDATE agents SET status = 'offline', ready = 0, meta =
|
|
346
|
-
.run(JSON.stringify(merged),
|
|
378
|
+
.query("UPDATE agents SET status = 'offline', ready = 0, meta = ? WHERE id = ?")
|
|
379
|
+
.run(JSON.stringify(merged), id);
|
|
347
380
|
if (result.changes <= 0) return null;
|
|
348
381
|
closeOpenPairsForAgent(id, now);
|
|
349
382
|
return getAgent(id);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Pluggable authentication identities (#389 auth & sessions). Maps an external login —
|
|
2
|
+
// a (provider, provider_subject) pair such as ("password", "admin") or ("github", "12345") —
|
|
3
|
+
// onto a relational `users.id`. The login flow authenticates a provider_subject, looks it up
|
|
4
|
+
// here, and mints a session token for the resolved user. Provider-agnostic by construction:
|
|
5
|
+
// `secret_hash` is nullable so an OAuth/magic-link identity is just a row with no stored secret —
|
|
6
|
+
// a new provider needs ZERO schema change (the #389 pluggability guarantee).
|
|
7
|
+
//
|
|
8
|
+
// DDL home (single owner; the barrel re-exports, never inlines DDL). Wired into applyMigrations
|
|
9
|
+
// right after seedDefaultUser(), mirroring the idempotent ensureUsersSchema() pattern.
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { getDb } from "./connection.ts";
|
|
12
|
+
|
|
13
|
+
export interface AuthIdentity {
|
|
14
|
+
id: string;
|
|
15
|
+
userId: string;
|
|
16
|
+
provider: string;
|
|
17
|
+
providerSubject: string;
|
|
18
|
+
/** Opaque provider-managed secret material (e.g. a scrypt password hash). Null for secretless
|
|
19
|
+
* providers (OAuth, magic-link) whose proof lives with the upstream provider, not here. */
|
|
20
|
+
secretHash: string | null;
|
|
21
|
+
metadata: Record<string, unknown>;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface AuthIdentityRow {
|
|
26
|
+
id: string;
|
|
27
|
+
user_id: string;
|
|
28
|
+
provider: string;
|
|
29
|
+
provider_subject: string;
|
|
30
|
+
secret_hash: string | null;
|
|
31
|
+
metadata: string | null;
|
|
32
|
+
created_at: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Single DDL home for `auth_identities` (#389). Idempotent (CREATE TABLE IF NOT EXISTS), so it is
|
|
37
|
+
* safe on every boot/migration — an old single-user DB gains the table on upgrade, with zero rows,
|
|
38
|
+
* and the god-token break-glass keeps working without any identity present. `UNIQUE(provider,
|
|
39
|
+
* provider_subject)` makes a login subject resolve to exactly one identity; `user_id` cascades on
|
|
40
|
+
* user delete so removing a human drops their logins.
|
|
41
|
+
*/
|
|
42
|
+
export function ensureAuthIdentitiesSchema(): void {
|
|
43
|
+
getDb().run(`
|
|
44
|
+
CREATE TABLE IF NOT EXISTS auth_identities (
|
|
45
|
+
id TEXT PRIMARY KEY,
|
|
46
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
47
|
+
provider TEXT NOT NULL,
|
|
48
|
+
provider_subject TEXT NOT NULL,
|
|
49
|
+
secret_hash TEXT,
|
|
50
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
51
|
+
created_at INTEGER NOT NULL,
|
|
52
|
+
UNIQUE(provider, provider_subject)
|
|
53
|
+
)
|
|
54
|
+
`);
|
|
55
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_auth_identities_user ON auth_identities(user_id)");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function rowToIdentity(row: AuthIdentityRow): AuthIdentity {
|
|
59
|
+
return {
|
|
60
|
+
id: row.id,
|
|
61
|
+
userId: row.user_id,
|
|
62
|
+
provider: row.provider,
|
|
63
|
+
providerSubject: row.provider_subject,
|
|
64
|
+
secretHash: row.secret_hash,
|
|
65
|
+
metadata: row.metadata ? (JSON.parse(row.metadata) as Record<string, unknown>) : {},
|
|
66
|
+
createdAt: row.created_at,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Resolve a login subject to its identity, or undefined if no such (provider, subject) exists. */
|
|
71
|
+
export function getAuthIdentity(provider: string, providerSubject: string): AuthIdentity | undefined {
|
|
72
|
+
const row = getDb()
|
|
73
|
+
.query("SELECT * FROM auth_identities WHERE provider = ? AND provider_subject = ?")
|
|
74
|
+
.get(provider, providerSubject) as AuthIdentityRow | null;
|
|
75
|
+
return row ? rowToIdentity(row) : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** All identities linked to a user — used by account UIs and "unlink provider" flows. */
|
|
79
|
+
export function listAuthIdentitiesForUser(userId: string): AuthIdentity[] {
|
|
80
|
+
const rows = getDb()
|
|
81
|
+
.query("SELECT * FROM auth_identities WHERE user_id = ? ORDER BY created_at")
|
|
82
|
+
.all(userId) as AuthIdentityRow[];
|
|
83
|
+
return rows.map(rowToIdentity);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Link a login to a user. `secretHash` is null for secretless providers. Throws on a duplicate
|
|
88
|
+
* (provider, provider_subject) — the caller surfaces it as a 409/validation error.
|
|
89
|
+
*/
|
|
90
|
+
export function createAuthIdentity(input: {
|
|
91
|
+
userId: string;
|
|
92
|
+
provider: string;
|
|
93
|
+
providerSubject: string;
|
|
94
|
+
secretHash?: string | null;
|
|
95
|
+
metadata?: Record<string, unknown>;
|
|
96
|
+
}): AuthIdentity {
|
|
97
|
+
const id = randomUUID();
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
getDb()
|
|
100
|
+
.query(
|
|
101
|
+
`INSERT INTO auth_identities (id, user_id, provider, provider_subject, secret_hash, metadata, created_at)
|
|
102
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
103
|
+
)
|
|
104
|
+
.run(
|
|
105
|
+
id,
|
|
106
|
+
input.userId,
|
|
107
|
+
input.provider,
|
|
108
|
+
input.providerSubject,
|
|
109
|
+
input.secretHash ?? null,
|
|
110
|
+
JSON.stringify(input.metadata ?? {}),
|
|
111
|
+
now,
|
|
112
|
+
);
|
|
113
|
+
return getAuthIdentity(input.provider, input.providerSubject)!;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Remove an identity by id. Returns true if a row was deleted. */
|
|
117
|
+
export function deleteAuthIdentity(id: string): boolean {
|
|
118
|
+
return getDb().query("DELETE FROM auth_identities WHERE id = ?").run(id).changes > 0;
|
|
119
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// #636 — persisted crash post-mortems. Stored in their own table (no FK to agents) so a death
|
|
2
|
+
// verdict survives `pruneOfflineAgents` / `deleteAgent` and stays retrievable by agentId after the
|
|
3
|
+
// worktree/session reaper has cleaned up. One row per (agentId, createdAt); the latest wins on read.
|
|
4
|
+
import { getDb } from "./connection.ts";
|
|
5
|
+
import { parseJson } from "../utils";
|
|
6
|
+
import type { AgentDeathDiagnosis, CrashReport } from "../types";
|
|
7
|
+
|
|
8
|
+
interface CrashReportRow {
|
|
9
|
+
agent_id: string;
|
|
10
|
+
parent_id: string | null;
|
|
11
|
+
team_id: string | null;
|
|
12
|
+
spawn_request_id: string | null;
|
|
13
|
+
provider: string | null;
|
|
14
|
+
label: string | null;
|
|
15
|
+
cause: string;
|
|
16
|
+
reason: string;
|
|
17
|
+
diagnosis: string;
|
|
18
|
+
created_at: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ensureCrashReportsSchema(): void {
|
|
22
|
+
getDb().run(`
|
|
23
|
+
CREATE TABLE IF NOT EXISTS crash_reports (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
agent_id TEXT NOT NULL,
|
|
26
|
+
parent_id TEXT,
|
|
27
|
+
team_id TEXT,
|
|
28
|
+
spawn_request_id TEXT,
|
|
29
|
+
provider TEXT,
|
|
30
|
+
label TEXT,
|
|
31
|
+
cause TEXT NOT NULL,
|
|
32
|
+
reason TEXT NOT NULL,
|
|
33
|
+
diagnosis TEXT NOT NULL,
|
|
34
|
+
created_at INTEGER NOT NULL
|
|
35
|
+
)
|
|
36
|
+
`);
|
|
37
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_crash_reports_agent ON crash_reports(agent_id, created_at DESC)");
|
|
38
|
+
getDb().run("CREATE INDEX IF NOT EXISTS idx_crash_reports_parent ON crash_reports(parent_id, created_at DESC)");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function rowToCrashReport(row: CrashReportRow): CrashReport {
|
|
42
|
+
return {
|
|
43
|
+
agentId: row.agent_id,
|
|
44
|
+
...(row.parent_id ? { parentId: row.parent_id } : {}),
|
|
45
|
+
...(row.team_id ? { teamId: row.team_id } : {}),
|
|
46
|
+
...(row.spawn_request_id ? { spawnRequestId: row.spawn_request_id } : {}),
|
|
47
|
+
...(row.provider ? { provider: row.provider } : {}),
|
|
48
|
+
...(row.label ? { label: row.label } : {}),
|
|
49
|
+
reason: row.reason,
|
|
50
|
+
diagnosis: parseJson<AgentDeathDiagnosis>(row.diagnosis, {
|
|
51
|
+
cause: "unknown",
|
|
52
|
+
confidence: "low",
|
|
53
|
+
summary: "Cause undetermined",
|
|
54
|
+
evidence: [],
|
|
55
|
+
detectedAt: row.created_at,
|
|
56
|
+
}),
|
|
57
|
+
createdAt: row.created_at,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function recordCrashReport(report: CrashReport): void {
|
|
62
|
+
getDb()
|
|
63
|
+
.query(
|
|
64
|
+
`INSERT INTO crash_reports (agent_id, parent_id, team_id, spawn_request_id, provider, label, cause, reason, diagnosis, created_at)
|
|
65
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
66
|
+
)
|
|
67
|
+
.run(
|
|
68
|
+
report.agentId,
|
|
69
|
+
report.parentId ?? null,
|
|
70
|
+
report.teamId ?? null,
|
|
71
|
+
report.spawnRequestId ?? null,
|
|
72
|
+
report.provider ?? null,
|
|
73
|
+
report.label ?? null,
|
|
74
|
+
report.diagnosis.cause,
|
|
75
|
+
report.reason,
|
|
76
|
+
JSON.stringify(report.diagnosis),
|
|
77
|
+
report.createdAt,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The most recent crash report for an agent, or null if none was ever recorded. */
|
|
82
|
+
export function getCrashReport(agentId: string): CrashReport | null {
|
|
83
|
+
const row = getDb()
|
|
84
|
+
.query("SELECT * FROM crash_reports WHERE agent_id = ? ORDER BY created_at DESC, id DESC LIMIT 1")
|
|
85
|
+
.get(agentId) as CrashReportRow | undefined;
|
|
86
|
+
return row ? rowToCrashReport(row) : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ListCrashReportsFilter {
|
|
90
|
+
/** Restrict to children of this spawn-parent (the parent/self scope for the MCP tool). */
|
|
91
|
+
parentId?: string;
|
|
92
|
+
/** Restrict to a single agent. */
|
|
93
|
+
agentId?: string;
|
|
94
|
+
limit?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function listCrashReports(filter: ListCrashReportsFilter = {}): CrashReport[] {
|
|
98
|
+
const where: string[] = [];
|
|
99
|
+
const params: (string | number)[] = [];
|
|
100
|
+
if (filter.parentId) {
|
|
101
|
+
where.push("parent_id = ?");
|
|
102
|
+
params.push(filter.parentId);
|
|
103
|
+
}
|
|
104
|
+
if (filter.agentId) {
|
|
105
|
+
where.push("agent_id = ?");
|
|
106
|
+
params.push(filter.agentId);
|
|
107
|
+
}
|
|
108
|
+
const limit = Math.max(1, Math.min(filter.limit ?? 50, 200));
|
|
109
|
+
const rows = getDb()
|
|
110
|
+
.query(
|
|
111
|
+
`SELECT * FROM crash_reports ${where.length ? `WHERE ${where.join(" AND ")}` : ""} ORDER BY created_at DESC, id DESC LIMIT ?`,
|
|
112
|
+
)
|
|
113
|
+
.all(...params, limit) as CrashReportRow[];
|
|
114
|
+
return rows.map(rowToCrashReport);
|
|
115
|
+
}
|
package/src/db/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ export * from "./delivery.ts";
|
|
|
17
17
|
export * from "./message-reads.ts";
|
|
18
18
|
export * from "./inbox.ts";
|
|
19
19
|
export * from "./users.ts";
|
|
20
|
+
export * from "./auth-identities.ts";
|
|
20
21
|
export * from "./agent-ownership.ts";
|
|
21
22
|
export * from "./drive-leases.ts";
|
|
22
23
|
export * from "./activity.ts";
|
|
@@ -26,6 +27,7 @@ export * from "./workspaces.ts";
|
|
|
26
27
|
export * from "./merge-lease.ts";
|
|
27
28
|
export * from "./continuations.ts";
|
|
28
29
|
export * from "./continuation-archives.ts";
|
|
30
|
+
export * from "./crash-reports.ts";
|
|
29
31
|
export * from "./plans.ts";
|
|
30
32
|
export * from "./teams.ts";
|
|
31
33
|
export * from "./blackboard.ts";
|
package/src/db/migrations.ts
CHANGED
|
@@ -16,9 +16,11 @@ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_ME
|
|
|
16
16
|
import { matchAgents } from "../agent-ref";
|
|
17
17
|
import { getDb } from "./connection.ts";
|
|
18
18
|
import { ensureUsersSchema, seedDefaultUser } from "./users.ts";
|
|
19
|
+
import { ensureAuthIdentitiesSchema } from "./auth-identities.ts";
|
|
19
20
|
import { ensureAgentOwnershipSchema, seedDefaultAgentOwnership } from "./agent-ownership.ts";
|
|
20
21
|
import { ensureDriveLeaseSchema } from "./drive-leases.ts";
|
|
21
22
|
import { ensureContinuationArchiveSearchSchema } from "./continuation-archives.ts";
|
|
23
|
+
import { ensureCrashReportsSchema } from "./crash-reports.ts";
|
|
22
24
|
import { normalizeReactionEmoji } from "./mappers.ts";
|
|
23
25
|
import { normalizeProviderQuotaAccountKeys } from "./provider-quotas.ts";
|
|
24
26
|
import { projectRootsMissingIssueRepo, setProjectIssueRepo } from "./projects.ts";
|
|
@@ -794,12 +796,21 @@ export function applyMigrations(): void {
|
|
|
794
796
|
)
|
|
795
797
|
`);
|
|
796
798
|
|
|
799
|
+
// #636 — persisted crash post-mortems. Own table, no FK to agents, so a death verdict outlives
|
|
800
|
+
// the offline-prune/reaper and stays retrievable by agentId.
|
|
801
|
+
ensureCrashReportsSchema();
|
|
802
|
+
|
|
797
803
|
// First-class human users (#388 keystone #393): ensure the table (single DDL home in users.ts)
|
|
798
804
|
// and seed the admin-level default user mapped to the existing single human. P0 + idempotent —
|
|
799
805
|
// bare 'user' == user:default resolve to users.id='default' via the SDK resolver; no bytes move.
|
|
800
806
|
ensureUsersSchema();
|
|
801
807
|
seedDefaultUser();
|
|
802
808
|
|
|
809
|
+
// Pluggable login identities (#389): the (provider, provider_subject) → users.id map behind real
|
|
810
|
+
// login + sessions. Ensured right after the user seed (FK → users) and idempotent — an old
|
|
811
|
+
// single-user DB gains it empty, and the god-token break-glass keeps working with zero rows.
|
|
812
|
+
ensureAuthIdentitiesSchema();
|
|
813
|
+
|
|
803
814
|
// Agent ownership/visibility table (#392). FK-references both agents and users, so it ensures
|
|
804
815
|
// AFTER both exist. The backfill (seedDefaultAgentOwnership) runs below, once the built-in
|
|
805
816
|
// agents are in, so they can be excluded from ownership.
|
package/src/index.ts
CHANGED
|
@@ -268,7 +268,12 @@ export function createFetchHandler(
|
|
|
268
268
|
const matched = matchRoute(req.method, url.pathname);
|
|
269
269
|
if (matched) {
|
|
270
270
|
const publicPaths = ["/api/spec", "/api/docs", "/api/health"];
|
|
271
|
-
|
|
271
|
+
// Pre-auth allowlist (#389): the auth endpoints must be reachable unauthenticated — they ARE
|
|
272
|
+
// the way a session is obtained. Each handler self-authenticates (login verifies credentials,
|
|
273
|
+
// refresh validates the refresh token, logout revokes the presented token). Everything else
|
|
274
|
+
// still flows through the component-token gate below.
|
|
275
|
+
const publicAuthPaths = ["/api/auth/providers", "/api/auth/login", "/api/auth/refresh", "/api/auth/logout"];
|
|
276
|
+
if (!publicPaths.includes(url.pathname) && !publicAuthPaths.includes(url.pathname)) {
|
|
272
277
|
const integrationAuth = getIntegrationAuth(req);
|
|
273
278
|
const componentAuth = getComponentAuth(req);
|
|
274
279
|
const handlerOwnsScopedAuth = url.pathname === "/api/tokens/me" ||
|