agent-relay-server 0.83.0 → 0.84.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 +113 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-BmgXwQ3z.js → MessageBubble-BTBQr8uG.js} +2 -2
- package/public/assets/{MessageBubble-BmgXwQ3z.js.map → MessageBubble-BTBQr8uG.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-FfVHi1Zs.js → PlanGraphPanel-CoWJJVyD.js} +2 -2
- package/public/assets/{PlanGraphPanel-FfVHi1Zs.js.map → PlanGraphPanel-CoWJJVyD.js.map} +1 -1
- package/public/assets/{activity-CvpXBhkZ.js → activity-XqRFu_9T.js} +2 -2
- package/public/assets/{activity-CvpXBhkZ.js.map → activity-XqRFu_9T.js.map} +1 -1
- package/public/assets/{agents-D198jJhF.js → agents-BWmmWj4Q.js} +2 -2
- package/public/assets/{agents-D198jJhF.js.map → agents-BWmmWj4Q.js.map} +1 -1
- package/public/assets/{automation-BHsr7Ztm.js → automation-DRtOENs0.js} +2 -2
- package/public/assets/{automation-BHsr7Ztm.js.map → automation-DRtOENs0.js.map} +1 -1
- package/public/assets/{chat-DA5mq5zD.js → chat-gfUxbTus.js} +2 -2
- package/public/assets/{chat-DA5mq5zD.js.map → chat-gfUxbTus.js.map} +1 -1
- package/public/assets/display-CWMHll1J.js.map +1 -1
- package/public/assets/{formatted-body-impl-CzXDNAoZ.js → formatted-body-impl-DpNHGXXQ.js} +2 -2
- package/public/assets/{formatted-body-impl-CzXDNAoZ.js.map → formatted-body-impl-DpNHGXXQ.js.map} +1 -1
- package/public/assets/{index-y_a_P2yU.js → index-D8TVUdlC.js} +11 -11
- package/public/assets/index-D8TVUdlC.js.map +1 -0
- package/public/assets/{insights-CFWUwbIj.js → insights-DPaJ5Hme.js} +2 -2
- package/public/assets/{insights-CFWUwbIj.js.map → insights-DPaJ5Hme.js.map} +1 -1
- package/public/assets/{maintenance-B37meN9r.js → maintenance-DsszLb92.js} +2 -2
- package/public/assets/{maintenance-B37meN9r.js.map → maintenance-DsszLb92.js.map} +1 -1
- package/public/assets/{managed-agents-D_uKTfD5.js → managed-agents-DqWPxFE0.js} +2 -2
- package/public/assets/{managed-agents-D_uKTfD5.js.map → managed-agents-DqWPxFE0.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DbhvZmjh.js → markdown-preview-impl-CWR4ILHK.js} +2 -2
- package/public/assets/{markdown-preview-impl-DbhvZmjh.js.map → markdown-preview-impl-CWR4ILHK.js.map} +1 -1
- package/public/assets/{memory-CLleEaHN.js → memory-C2s9bJ38.js} +2 -2
- package/public/assets/{memory-CLleEaHN.js.map → memory-C2s9bJ38.js.map} +1 -1
- package/public/assets/{messages-CHxeZT9m.js → messages-DHRJNAvy.js} +2 -2
- package/public/assets/{messages-CHxeZT9m.js.map → messages-DHRJNAvy.js.map} +1 -1
- package/public/assets/{orchestrators-DCwgTm8t.js → orchestrators-BGNNllJc.js} +2 -2
- package/public/assets/{orchestrators-DCwgTm8t.js.map → orchestrators-BGNNllJc.js.map} +1 -1
- package/public/assets/{overview-DM4t8PeA.js → overview-C1vZfyhW.js} +2 -2
- package/public/assets/{overview-DM4t8PeA.js.map → overview-C1vZfyhW.js.map} +1 -1
- package/public/assets/{pairs-DRtIJUsl.js → pairs-C1uzo8x0.js} +2 -2
- package/public/assets/{pairs-DRtIJUsl.js.map → pairs-C1uzo8x0.js.map} +1 -1
- package/public/assets/{security-D3T8nhcY.js → security-XhAAP6q3.js} +2 -2
- package/public/assets/{security-D3T8nhcY.js.map → security-XhAAP6q3.js.map} +1 -1
- package/public/assets/{select-CGh6MQBA.js → select-DEGUFEjx.js} +2 -2
- package/public/assets/{select-CGh6MQBA.js.map → select-DEGUFEjx.js.map} +1 -1
- package/public/assets/{settings-DmEscdjG.js → settings-DTbVUdlZ.js} +2 -2
- package/public/assets/{settings-DmEscdjG.js.map → settings-DTbVUdlZ.js.map} +1 -1
- package/public/assets/{tasks-L9hPxGh2.js → tasks-zwGo7hon.js} +2 -2
- package/public/assets/{tasks-L9hPxGh2.js.map → tasks-zwGo7hon.js.map} +1 -1
- package/public/assets/{teams-Bnqfnebk.js → teams-Brc5D8ox.js} +2 -2
- package/public/assets/{teams-Bnqfnebk.js.map → teams-Brc5D8ox.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-DZ-cASX7.js → terminal-viewer-impl-C6yUAL8G.js} +2 -2
- package/public/assets/{terminal-viewer-impl-DZ-cASX7.js.map → terminal-viewer-impl-C6yUAL8G.js.map} +1 -1
- package/public/assets/{user-avatar-Gil1NpJ8.js → user-avatar-Bw1R011j.js} +2 -2
- package/public/assets/{user-avatar-Gil1NpJ8.js.map → user-avatar-Bw1R011j.js.map} +1 -1
- package/public/assets/{work-queue-DXfNzGs1.js → work-queue-ge4R7keo.js} +2 -2
- package/public/assets/{work-queue-DXfNzGs1.js.map → work-queue-ge4R7keo.js.map} +1 -1
- package/public/index.html +1 -1
- 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/db/auth-identities.ts +119 -0
- package/src/db/index.ts +1 -0
- package/src/db/migrations.ts +6 -0
- package/src/index.ts +6 -1
- package/src/routes/_shared.ts +40 -2
- package/src/routes/auth.ts +107 -0
- package/src/routes/index.ts +8 -0
- package/src/routes/tokens.ts +8 -2
- package/src/security.ts +1 -1
- package/src/upgrade-install-verify.ts +160 -0
- package/src/upgrade.ts +40 -11
- package/src/validation.ts +2 -0
- package/public/assets/index-y_a_P2yU.js.map +0 -1
package/src/routes/_shared.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { ValidationError, createActivityEvent, createCallbackDelivery, finishCal
|
|
|
5
5
|
import { cleanEpoch, cleanString, optionalEnum } from "../validation";
|
|
6
6
|
import { emitActivityEvent, emitMessageQueued, emitNewMessage } from "../sse";
|
|
7
7
|
import { emitRelayEvent } from "../events";
|
|
8
|
-
import { canonicalHumanAgentId, DEFAULT_USER_ID, errMessage, isRecord, userSinkId } from "agent-relay-sdk";
|
|
8
|
+
import { canonicalHumanAgentId, DEFAULT_USER_ID, errMessage, isHumanAgentId, isRecord, userIdFromAddress, userSinkId } from "agent-relay-sdk";
|
|
9
9
|
import { authorizeAgentForUser, visibleAgentIdsForUser, type AgentAction } from "../agent-authz";
|
|
10
10
|
import { generateSpawnRequestId } from "../spawn-command";
|
|
11
11
|
import { getComponentAuth, getIntegrationAuth, isAuthorized, isRequestAuthorizedFor } from "../security";
|
|
@@ -58,13 +58,51 @@ export function authAuditMetadata(req: Request): Record<string, unknown> {
|
|
|
58
58
|
},
|
|
59
59
|
};
|
|
60
60
|
}
|
|
61
|
-
|
|
61
|
+
if (!hasPresentedCredential(req)) return {};
|
|
62
|
+
// Root (god-token) credential: stamp the seeded-admin identity it now carries (#389 break-glass),
|
|
63
|
+
// so the audit trail records *who* acted instead of an anonymous root.
|
|
64
|
+
const acting = resolveActingUser(req);
|
|
65
|
+
return acting
|
|
66
|
+
? { auth: { type: "root", userId: acting.userId, source: acting.source } }
|
|
67
|
+
: { auth: { type: "root" } };
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
export function isRootCredentialRequest(req: Request): boolean {
|
|
65
71
|
return hasPresentedCredential(req) && isAuthorized(req) && !getComponentAuth(req) && !getIntegrationAuth(req);
|
|
66
72
|
}
|
|
67
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Which human is acting, and how their request was authenticated (#389). The single bridge from a
|
|
76
|
+
* request to a `users.id`:
|
|
77
|
+
* - a human session token (component token whose `sub` is a `user:<id>` address) → that user, via
|
|
78
|
+
* the SDK resolver (never a hand-rolled namespace) — `source: "session"`;
|
|
79
|
+
* - the raw god-token / root credential (`isRootCredentialRequest`) → the seeded admin
|
|
80
|
+
* `DEFAULT_USER_ID` with `source: "break-glass"`. This is what gives the env token an *identity*
|
|
81
|
+
* instead of the anonymous "server" it carried before, while it KEEPS bypassing scope checks
|
|
82
|
+
* unchanged (the gate in src/index.ts is untouched). Works with zero `auth_identities` rows, so
|
|
83
|
+
* it is simultaneously the break-glass path and the first-user bootstrap.
|
|
84
|
+
*
|
|
85
|
+
* Returns null for a machine component/integration token (no human is acting).
|
|
86
|
+
*/
|
|
87
|
+
export type ActingUserSource = "session" | "break-glass";
|
|
88
|
+
|
|
89
|
+
export interface ActingUser {
|
|
90
|
+
userId: string;
|
|
91
|
+
source: ActingUserSource;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveActingUser(req: Request): ActingUser | null {
|
|
95
|
+
const component = getComponentAuth(req);
|
|
96
|
+
if (component && isHumanAgentId(component.sub)) {
|
|
97
|
+
const userId = userIdFromAddress(component.sub);
|
|
98
|
+
if (userId) return { userId, source: "session" };
|
|
99
|
+
}
|
|
100
|
+
if (isRootCredentialRequest(req)) {
|
|
101
|
+
return { userId: DEFAULT_USER_ID, source: "break-glass" };
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
68
106
|
function sanitizeAttribution(raw: unknown): string | undefined {
|
|
69
107
|
if (typeof raw !== "string") return undefined;
|
|
70
108
|
// eslint-disable-next-line no-control-regex
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Authentication & session routes (#389). These four paths sit on a pre-auth PUBLIC ALLOWLIST
|
|
2
|
+
// (src/index.ts) so they are reachable unauthenticated — each handler owns its own auth: login
|
|
3
|
+
// verifies credentials, refresh validates the refresh token, logout revokes the presented session.
|
|
4
|
+
// Everything else on the relay still flows through the existing component-token gate.
|
|
5
|
+
import { isRecord, type User } from "agent-relay-sdk";
|
|
6
|
+
import { error, json, parseBody, type Handler } from "./_shared";
|
|
7
|
+
import { getAuthProvider, listAuthProviders, mintUserSession, resolveRefreshToken } from "../auth";
|
|
8
|
+
import { getAuthIdentity, getUser } from "../db";
|
|
9
|
+
import { revokeToken } from "../token-db.ts";
|
|
10
|
+
import { getComponentAuth } from "../security";
|
|
11
|
+
|
|
12
|
+
/** Public projection of a user for login/refresh responses — never leaks internal-only columns. */
|
|
13
|
+
function userView(user: User): Record<string, unknown> {
|
|
14
|
+
return {
|
|
15
|
+
id: user.id,
|
|
16
|
+
handle: user.handle,
|
|
17
|
+
displayName: user.displayName,
|
|
18
|
+
role: user.role,
|
|
19
|
+
tenantId: user.tenantId,
|
|
20
|
+
avatarUrl: user.avatarUrl,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function sessionResponse(user: User): Response {
|
|
25
|
+
const session = mintUserSession(user);
|
|
26
|
+
return json({
|
|
27
|
+
tokenType: "Bearer",
|
|
28
|
+
accessToken: session.accessToken,
|
|
29
|
+
refreshToken: session.refreshToken,
|
|
30
|
+
expiresIn: session.expiresIn,
|
|
31
|
+
user: userView(user),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** GET /api/auth/providers — the login screen reads this to render available login methods. */
|
|
36
|
+
export const getAuthProvidersRoute: Handler = () =>
|
|
37
|
+
json({
|
|
38
|
+
providers: listAuthProviders().map((provider) => ({
|
|
39
|
+
id: provider.id,
|
|
40
|
+
displayName: provider.displayName,
|
|
41
|
+
flow: provider.flow,
|
|
42
|
+
})),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* POST /api/auth/login — body `{ provider, ...credentials }`. Resolves the provider, verifies the
|
|
47
|
+
* credentials to a `providerSubject`, maps it through `auth_identities` to a user, and mints a
|
|
48
|
+
* session. Every failure returns the same generic 401 (no provider/user/credential enumeration).
|
|
49
|
+
*/
|
|
50
|
+
export const postAuthLoginRoute: Handler = async (req) => {
|
|
51
|
+
const parsed = await parseBody<unknown>(req);
|
|
52
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
53
|
+
if (!isRecord(parsed.body)) return error("login body required", 400);
|
|
54
|
+
const providerId = parsed.body.provider;
|
|
55
|
+
if (typeof providerId !== "string" || !providerId) return error("provider required", 400);
|
|
56
|
+
|
|
57
|
+
const provider = getAuthProvider(providerId);
|
|
58
|
+
if (!provider) return error("invalid credentials", 401);
|
|
59
|
+
|
|
60
|
+
const result = await provider.verify(parsed.body);
|
|
61
|
+
if (!result) return error("invalid credentials", 401);
|
|
62
|
+
|
|
63
|
+
const identity = getAuthIdentity(provider.id, result.providerSubject);
|
|
64
|
+
if (!identity) return error("invalid credentials", 401);
|
|
65
|
+
|
|
66
|
+
const user = getUser(identity.userId);
|
|
67
|
+
if (!user || user.status !== "active") return error("invalid credentials", 401);
|
|
68
|
+
|
|
69
|
+
return sessionResponse(user);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* POST /api/auth/refresh — body `{ refreshToken }`. Validates the refresh token and mints a fresh
|
|
74
|
+
* session, rotating the refresh token: the consumed one is revoked so a captured refresh token is
|
|
75
|
+
* single-use. Returns the same generic 401 on any invalid/expired/revoked token.
|
|
76
|
+
*/
|
|
77
|
+
export const postAuthRefreshRoute: Handler = async (req) => {
|
|
78
|
+
const parsed = await parseBody<unknown>(req);
|
|
79
|
+
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
80
|
+
if (!isRecord(parsed.body)) return error("refresh body required", 400);
|
|
81
|
+
const refreshToken = parsed.body.refreshToken;
|
|
82
|
+
if (typeof refreshToken !== "string" || !refreshToken) return error("refreshToken required", 400);
|
|
83
|
+
|
|
84
|
+
const resolved = resolveRefreshToken(refreshToken);
|
|
85
|
+
if (!resolved) return error("invalid refresh token", 401);
|
|
86
|
+
|
|
87
|
+
const response = sessionResponse(resolved.user);
|
|
88
|
+
// Rotate: revoke the consumed refresh token so it can't be replayed (the fresh pair supersedes it).
|
|
89
|
+
if (resolved.payload.jti) revokeToken(resolved.payload.jti, "rotation");
|
|
90
|
+
return response;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* POST /api/auth/logout — revokes the presented session token and, if supplied, its refresh token.
|
|
95
|
+
* Idempotent: revoking an already-revoked or absent token is a no-op. Always returns 204.
|
|
96
|
+
*/
|
|
97
|
+
export const postAuthLogoutRoute: Handler = async (req) => {
|
|
98
|
+
const component = getComponentAuth(req);
|
|
99
|
+
if (component?.jti) revokeToken(component.jti, "admin");
|
|
100
|
+
|
|
101
|
+
const parsed = await parseBody<unknown>(req);
|
|
102
|
+
if (parsed.ok && isRecord(parsed.body) && typeof parsed.body.refreshToken === "string") {
|
|
103
|
+
const resolved = resolveRefreshToken(parsed.body.refreshToken);
|
|
104
|
+
if (resolved?.payload.jti) revokeToken(resolved.payload.jti, "admin");
|
|
105
|
+
}
|
|
106
|
+
return new Response(null, { status: 204 });
|
|
107
|
+
};
|
package/src/routes/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute,
|
|
|
18
18
|
import { deleteTokenProfileRoute, getTokenById, getTokenMe, getTokenProfiles, getTokens, patchTokenProfile, postIntegrationRuntimeToken, postInteractiveRunnerRuntimeToken, postMcpRuntimeToken, postOrchestratorRunnerToken, postRuntimeTokenRenew, postToken, postTokenProfile, postTokenRevoke } from "./tokens";
|
|
19
19
|
import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceRecoveryBranches, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim, postWorkspaceRecoveryBranchDiscard } from "./workspaces";
|
|
20
20
|
import { error, type Handler } from "./_shared";
|
|
21
|
+
import { getAuthProvidersRoute, postAuthLoginRoute, postAuthLogoutRoute, postAuthRefreshRoute } from "./auth";
|
|
21
22
|
import { getActivityEvents, postActivityEvent } from "./activity";
|
|
22
23
|
import { getAnalyticsRoute, getHealthRoute, getMaintenanceJobs, getStatsRoute, postMaintenanceJobRun, postSystemReap } from "./stats";
|
|
23
24
|
import { getApiDocs, getApiSpec } from "./spec";
|
|
@@ -69,6 +70,13 @@ function route(method: string, path: string, handler: Handler): Route {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
const routes: Route[] = [
|
|
73
|
+
// Auth & sessions (#389). On the pre-auth public allowlist (src/index.ts) — each handler owns
|
|
74
|
+
// its own auth (login verifies credentials, refresh validates the refresh token, logout revokes).
|
|
75
|
+
route("GET", "/api/auth/providers", getAuthProvidersRoute),
|
|
76
|
+
route("POST", "/api/auth/login", postAuthLoginRoute),
|
|
77
|
+
route("POST", "/api/auth/refresh", postAuthRefreshRoute),
|
|
78
|
+
route("POST", "/api/auth/logout", postAuthLogoutRoute),
|
|
79
|
+
|
|
72
80
|
route("POST", "/api/artifacts", postArtifact),
|
|
73
81
|
route("GET", "/api/artifacts", getArtifacts),
|
|
74
82
|
route("GET", "/api/artifacts/:id/content", getArtifactContent),
|
package/src/routes/tokens.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Auto-split from routes.ts (#299). Domain: tokens.
|
|
2
|
-
import { SPAWN_PROVIDERS, isRecord } from "agent-relay-sdk";
|
|
2
|
+
import { SPAWN_PROVIDERS, isRecord, userSinkId } from "agent-relay-sdk";
|
|
3
3
|
import { ValidationError, getOrchestrator, upsertIntegrationRegistry } from "../db";
|
|
4
|
-
import { auditEvent, authAuditMetadata, authorizeRoute, error, isRootCredentialRequest, json, parseBody, type Handler } from "./_shared";
|
|
4
|
+
import { auditEvent, authAuditMetadata, authorizeRoute, error, isRootCredentialRequest, json, parseBody, resolveActingUser, type Handler } from "./_shared";
|
|
5
5
|
import { cleanString, cleanStringArray, cleanTokenConstraints, optionalEnum } from "../validation";
|
|
6
6
|
import { createToken, deleteTokenProfile, getToken, getTokenProfile, listTokenProfiles, listTokens, renewToken, revokeToken, updateTokenProfile, upsertTokenProfile } from "../token-db";
|
|
7
7
|
import { getComponentAuth, getIntegrationAuth, isAuthorized } from "../security";
|
|
@@ -36,6 +36,12 @@ export const getTokenMe: Handler = (req) => {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
if (isAuthorized(req)) {
|
|
39
|
+
// Root (god-token) now carries the seeded-admin identity (#389): report *who* is acting
|
|
40
|
+
// (break-glass → user:default) instead of the anonymous "server" it showed before.
|
|
41
|
+
const acting = resolveActingUser(req);
|
|
42
|
+
if (acting) {
|
|
43
|
+
return json({ actor: userSinkId(acting.userId), userId: acting.userId, source: acting.source, kind: "server", scopes: ["*"] });
|
|
44
|
+
}
|
|
39
45
|
return json({ actor: "server", kind: "server", scopes: ["*"] });
|
|
40
46
|
}
|
|
41
47
|
return error("unauthorized", 401);
|
package/src/security.ts
CHANGED
|
@@ -539,7 +539,7 @@ function isTokenConstraints(value: unknown): value is TokenConstraints {
|
|
|
539
539
|
for (const [key, item] of Object.entries(record)) {
|
|
540
540
|
if (["terminalAttach", "logsRead", "canDelegate"].includes(key)) {
|
|
541
541
|
if (typeof item !== "boolean") return false;
|
|
542
|
-
} else if (key === "cwd" || key === "spawnedBy" || key === "teamId" || key === "projectId") {
|
|
542
|
+
} else if (key === "cwd" || key === "spawnedBy" || key === "teamId" || key === "projectId" || key === "tenantId") {
|
|
543
543
|
if (typeof item !== "string") return false;
|
|
544
544
|
} else if (key === "maxSpawnedAgents") {
|
|
545
545
|
if (typeof item !== "number") return false;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Post-install verification + repair for a runtime-prefix upgrade (#631).
|
|
6
|
+
*
|
|
7
|
+
* A clean `npm install` exit code is NOT proof the upgrade landed: npm reports
|
|
8
|
+
* "up to date" (exit 0, nothing installed) when its hidden
|
|
9
|
+
* `node_modules/.package-lock.json` claims the target version while the extracted
|
|
10
|
+
* package dirs are still stale (lock-vs-disk divergence). That silently stranded
|
|
11
|
+
* macmini2 on 0.82.0 during the v0.83.0 deploy. This module asserts the ACTUAL
|
|
12
|
+
* on-disk versions, repairs the divergence by forcing a real re-extraction, and
|
|
13
|
+
* restores the exec bit that extraction drops from bin entries.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Metadata describing what a runtime-prefix install action must verify. */
|
|
17
|
+
export type VerifyInstall = {
|
|
18
|
+
/** Runtime prefix whose node_modules holds the installed packages. */
|
|
19
|
+
prefix: string;
|
|
20
|
+
targetVersion: string;
|
|
21
|
+
/** Package names (no version spec) to assert + repair + restore exec bits on. */
|
|
22
|
+
packages: string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type CommandOutput = { exitCode: number; stdout: string; stderr: string };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Filesystem operations the verification needs, factored out so tests can drive
|
|
29
|
+
* the assertion/repair/exec-bit logic without a real npm install or touching
|
|
30
|
+
* disk. Defaults to {@link defaultInstallIo}.
|
|
31
|
+
*/
|
|
32
|
+
export type InstallIo = {
|
|
33
|
+
/** Actual installed version from `<prefix>/node_modules/<pkg>/package.json`. */
|
|
34
|
+
readInstalledVersion: (prefix: string, pkg: string) => string | undefined;
|
|
35
|
+
/** Delete `<prefix>/node_modules/<pkg>` so the next install must re-extract it. */
|
|
36
|
+
forceReextract: (prefix: string, pkg: string) => void;
|
|
37
|
+
/** Delete npm's hidden `<prefix>/node_modules/.package-lock.json`. */
|
|
38
|
+
removeHiddenLock: (prefix: string) => void;
|
|
39
|
+
/** chmod +x each of a package's bin entry targets; returns the files changed. */
|
|
40
|
+
restoreExecBits: (prefix: string, pkg: string) => string[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type VersionMismatch = { pkg: string; expected: string; actual?: string };
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Assert the runtime packages reached the target version after npm reported
|
|
47
|
+
* success; on divergence, force a clean re-extraction and re-assert; finally
|
|
48
|
+
* restore the exec bit that npm extraction drops from bin entries. Appends a
|
|
49
|
+
* human-readable trace to `lines` and throws a loud, specific error on failure.
|
|
50
|
+
*/
|
|
51
|
+
export async function verifyAndRepairInstall(
|
|
52
|
+
action: { label: string; command: string[] },
|
|
53
|
+
verify: VerifyInstall,
|
|
54
|
+
runInstall: (command: string[]) => Promise<CommandOutput>,
|
|
55
|
+
io: InstallIo,
|
|
56
|
+
lines: string[],
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const findMismatches = (): VersionMismatch[] =>
|
|
59
|
+
verify.packages
|
|
60
|
+
.map((pkg) => ({ pkg, expected: verify.targetVersion, actual: io.readInstalledVersion(verify.prefix, pkg) }))
|
|
61
|
+
.filter((m) => m.actual !== verify.targetVersion);
|
|
62
|
+
|
|
63
|
+
let mismatches = findMismatches();
|
|
64
|
+
if (mismatches.length > 0) {
|
|
65
|
+
// Lock-vs-disk divergence: npm's hidden node_modules/.package-lock.json
|
|
66
|
+
// claimed the target while the extracted dirs lagged, so `npm install`
|
|
67
|
+
// returned "up to date" and installed nothing. Drop the stale dirs + hidden
|
|
68
|
+
// lock so the reinstall is forced to actually re-extract the tarballs.
|
|
69
|
+
lines.push(
|
|
70
|
+
`Installed versions disagree with ${verify.targetVersion} after npm reported success ` +
|
|
71
|
+
`(lock-vs-disk divergence): ${formatMismatches(mismatches)}. Forcing a clean re-extraction.`,
|
|
72
|
+
);
|
|
73
|
+
for (const m of mismatches) io.forceReextract(verify.prefix, m.pkg);
|
|
74
|
+
io.removeHiddenLock(verify.prefix);
|
|
75
|
+
|
|
76
|
+
const result = await runInstall(action.command);
|
|
77
|
+
if (result.stdout.trim()) lines.push(result.stdout.trim());
|
|
78
|
+
if (result.stderr.trim()) lines.push(result.stderr.trim());
|
|
79
|
+
if (result.exitCode !== 0) {
|
|
80
|
+
const detail = (result.stderr.trim() || result.stdout.trim()).slice(-2000);
|
|
81
|
+
throw new Error(`${action.label} re-extraction failed with exit code ${result.exitCode}${detail ? `:\n${detail}` : ""}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
mismatches = findMismatches();
|
|
85
|
+
if (mismatches.length > 0) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`${action.label} did not install ${verify.targetVersion}: ${formatMismatches(mismatches)}. ` +
|
|
88
|
+
`npm reported success but the runtime is unchanged; re-run the upgrade or inspect ${join(verify.prefix, "node_modules")}.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
lines.push(`Re-extraction reconciled the runtime to ${verify.targetVersion}.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// npm extraction creates bin entry files mode 0644 (no +x). The systemd units
|
|
95
|
+
// ExecStart the .bin symlink → src/index.ts directly, which needs the exec bit
|
|
96
|
+
// or it dies with status=203/EXEC. Restore +x on every upgraded package's bin
|
|
97
|
+
// targets so a re-extracted runtime still starts.
|
|
98
|
+
const restored: string[] = [];
|
|
99
|
+
for (const pkg of verify.packages) restored.push(...io.restoreExecBits(verify.prefix, pkg));
|
|
100
|
+
if (restored.length > 0) {
|
|
101
|
+
lines.push(`Restored exec bit on ${restored.length} runtime bin entr${restored.length === 1 ? "y" : "ies"}.`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatMismatches(mismatches: VersionMismatch[]): string {
|
|
106
|
+
return mismatches.map((m) => `${m.pkg} (expected ${m.expected}, got ${m.actual ?? "not installed"})`).join(", ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Strip the version spec from a package install arg (`name@1.2.3` → `name`). */
|
|
110
|
+
export function packageName(spec: string): string {
|
|
111
|
+
const at = spec.lastIndexOf("@");
|
|
112
|
+
return at > 0 ? spec.slice(0, at) : spec;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const defaultInstallIo: InstallIo = {
|
|
116
|
+
readInstalledVersion(prefix, pkg) {
|
|
117
|
+
const manifest = join(prefix, "node_modules", pkg, "package.json");
|
|
118
|
+
if (!existsSync(manifest)) return undefined;
|
|
119
|
+
try {
|
|
120
|
+
return (JSON.parse(readFileSync(manifest, "utf8")) as { version?: string }).version;
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
forceReextract(prefix, pkg) {
|
|
126
|
+
rmSync(join(prefix, "node_modules", pkg), { recursive: true, force: true });
|
|
127
|
+
},
|
|
128
|
+
removeHiddenLock(prefix) {
|
|
129
|
+
rmSync(join(prefix, "node_modules", ".package-lock.json"), { force: true });
|
|
130
|
+
},
|
|
131
|
+
restoreExecBits(prefix, pkg) {
|
|
132
|
+
const pkgDir = join(prefix, "node_modules", pkg);
|
|
133
|
+
const manifest = join(pkgDir, "package.json");
|
|
134
|
+
if (!existsSync(manifest)) return [];
|
|
135
|
+
let bin: unknown;
|
|
136
|
+
try {
|
|
137
|
+
bin = (JSON.parse(readFileSync(manifest, "utf8")) as { bin?: unknown }).bin;
|
|
138
|
+
} catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
const targets = typeof bin === "string"
|
|
142
|
+
? [bin]
|
|
143
|
+
: bin && typeof bin === "object"
|
|
144
|
+
? Object.values(bin as Record<string, unknown>).filter((v): v is string => typeof v === "string")
|
|
145
|
+
: [];
|
|
146
|
+
const restored: string[] = [];
|
|
147
|
+
for (const rel of targets) {
|
|
148
|
+
const file = join(pkgDir, rel);
|
|
149
|
+
if (!existsSync(file)) continue;
|
|
150
|
+
try {
|
|
151
|
+
chmodSync(file, 0o755);
|
|
152
|
+
restored.push(file);
|
|
153
|
+
} catch {
|
|
154
|
+
// Best-effort: a chmod failure is surfaced later by the service refusing
|
|
155
|
+
// to start; don't abort the whole upgrade on a single unreadable bin.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return restored;
|
|
159
|
+
},
|
|
160
|
+
};
|
package/src/upgrade.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { VERSION, cliRelayUrl, relayAuthHeaders } from "./config";
|
|
|
5
5
|
import type { RuntimeContracts, RuntimePackageMetadata } from "./contracts";
|
|
6
6
|
import { defaultRuntimePrefix, runtimeBinPath } from "./runtime-prefix";
|
|
7
7
|
import { resolveLocalOrchestratorId } from "./upgrade-local-id";
|
|
8
|
+
import { defaultInstallIo, packageName, verifyAndRepairInstall, type InstallIo, type VerifyInstall } from "./upgrade-install-verify";
|
|
8
9
|
import { errMessage, runInstallWithRetry } from "agent-relay-sdk";
|
|
9
10
|
import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
|
|
10
11
|
|
|
@@ -72,6 +73,12 @@ type UpgradeAction = {
|
|
|
72
73
|
reason: string;
|
|
73
74
|
mutates: boolean;
|
|
74
75
|
retryInstall?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Post-install verification metadata for a runtime-prefix install (#631).
|
|
78
|
+
* When set, the executor asserts each package's ACTUAL on-disk version equals
|
|
79
|
+
* the target, repairs lock-vs-disk divergence, and restores bin exec bits.
|
|
80
|
+
*/
|
|
81
|
+
verifyInstall?: VerifyInstall;
|
|
75
82
|
};
|
|
76
83
|
|
|
77
84
|
type UpgradePlan = {
|
|
@@ -183,6 +190,12 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
|
|
|
183
190
|
reason: `Update server${runnerRequested ? ", provider runner" : ""}${orchestratorRequested ? ", and orchestrator" : ""} packages to ${targetVersion}.`,
|
|
184
191
|
mutates: true,
|
|
185
192
|
retryInstall: true,
|
|
193
|
+
// Only the isolated runtime prefix has a deterministic node_modules path we
|
|
194
|
+
// can read actual versions from (and it's the systemd-managed deploy that
|
|
195
|
+
// strands hosts on a silent no-op). Global bun/npm installs are left as-is.
|
|
196
|
+
verifyInstall: snapshot.packageManager === "runtime-npm"
|
|
197
|
+
? { prefix: snapshot.runtimePrefix, targetVersion, packages: packages.map(packageName) }
|
|
198
|
+
: undefined,
|
|
186
199
|
});
|
|
187
200
|
if (snapshot.packageManager === "runtime-npm") {
|
|
188
201
|
actions.push({
|
|
@@ -268,6 +281,8 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
|
|
|
268
281
|
type ExecuteUpgradeOptions = {
|
|
269
282
|
dryRun?: boolean;
|
|
270
283
|
runner?: Runner;
|
|
284
|
+
/** Filesystem ops for post-install verification (injectable for tests). */
|
|
285
|
+
installIo?: InstallIo;
|
|
271
286
|
/** Re-register grace window for post-restart version checks (default 30s). */
|
|
272
287
|
verifyTimeoutMs?: number;
|
|
273
288
|
/** Poll interval while waiting for re-register (default 1s). */
|
|
@@ -283,6 +298,7 @@ type ExecuteUpgradeOptions = {
|
|
|
283
298
|
export async function executeUpgradePlan(plan: UpgradePlan, options: ExecuteUpgradeOptions = {}): Promise<string> {
|
|
284
299
|
if (options.dryRun) return formatUpgradePlan(plan, { dryRun: true });
|
|
285
300
|
const runner = options.runner ?? runCommand;
|
|
301
|
+
const io = options.installIo ?? defaultInstallIo;
|
|
286
302
|
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
|
287
303
|
const probeServerVersion = options.probeServerVersion ?? runningServerVersion;
|
|
288
304
|
const probeOrchestrators = options.probeOrchestrators ?? runningOrchestrators;
|
|
@@ -301,23 +317,36 @@ export async function executeUpgradePlan(plan: UpgradePlan, options: ExecuteUpgr
|
|
|
301
317
|
return value;
|
|
302
318
|
};
|
|
303
319
|
const lines = [`Upgrading Agent Relay to ${plan.targetVersion}`];
|
|
320
|
+
const runInstall = (command: string[]) => runInstallWithRetry(command, runner, {
|
|
321
|
+
sleep,
|
|
322
|
+
retries: options.installRetries,
|
|
323
|
+
baseDelayMs: options.installRetryBaseMs,
|
|
324
|
+
onRetry: ({ attempt, delayMs }) => {
|
|
325
|
+
lines.push(`npm cache race detected; retrying install attempt ${attempt} in ${delayMs}ms.`);
|
|
326
|
+
},
|
|
327
|
+
});
|
|
304
328
|
for (const action of plan.actions) {
|
|
305
329
|
lines.push(`\n${action.label}`);
|
|
306
330
|
lines.push(`$ ${action.command.map(shellQuote).join(" ")}`);
|
|
307
|
-
const result = action.retryInstall
|
|
308
|
-
? await runInstallWithRetry(action.command, runner, {
|
|
309
|
-
sleep,
|
|
310
|
-
retries: options.installRetries,
|
|
311
|
-
baseDelayMs: options.installRetryBaseMs,
|
|
312
|
-
onRetry: ({ attempt, delayMs }) => {
|
|
313
|
-
lines.push(`npm cache race detected; retrying install attempt ${attempt} in ${delayMs}ms.`);
|
|
314
|
-
},
|
|
315
|
-
})
|
|
316
|
-
: runner(action.command);
|
|
331
|
+
const result = action.retryInstall ? await runInstall(action.command) : runner(action.command);
|
|
317
332
|
if (result.stdout.trim()) lines.push(result.stdout.trim());
|
|
318
333
|
if (result.stderr.trim()) lines.push(result.stderr.trim());
|
|
319
334
|
if (result.exitCode !== 0) {
|
|
320
|
-
|
|
335
|
+
// Surface the child command's own output in the thrown error, not just the
|
|
336
|
+
// exit code (#631). The CLI reports only the error message on failure, so a
|
|
337
|
+
// bare "failed with exit code 1" hides the npm resolver failure and forces
|
|
338
|
+
// guess-and-check. The orchestrator self-upgrade path (self-upgrade.ts)
|
|
339
|
+
// already attaches the captured output — mirror it here so the local path
|
|
340
|
+
// is just as diagnosable.
|
|
341
|
+
const detail = (result.stderr.trim() || result.stdout.trim()).slice(-2000);
|
|
342
|
+
throw new Error(`${action.label} failed with exit code ${result.exitCode}${detail ? `:\n${detail}` : ""}`);
|
|
343
|
+
}
|
|
344
|
+
// A clean exit code is NOT proof the upgrade landed: npm reports "up to date"
|
|
345
|
+
// (exit 0, nothing installed) when its hidden lock claims the target while the
|
|
346
|
+
// extracted dirs are stale. Assert actual on-disk versions, repair divergence,
|
|
347
|
+
// and restore exec bits dropped by re-extraction (#631).
|
|
348
|
+
if (action.verifyInstall) {
|
|
349
|
+
await verifyAndRepairInstall(action, action.verifyInstall, runInstall, io, lines);
|
|
321
350
|
}
|
|
322
351
|
}
|
|
323
352
|
if (plan.actions.some((action) => action.command.join(" ") === "systemctl --user restart agent-relay.service")) {
|
package/src/validation.ts
CHANGED
|
@@ -124,6 +124,8 @@ export function cleanTokenConstraints(value: unknown): TokenConstraints | undefi
|
|
|
124
124
|
if (cwd) constraints.cwd = cwd;
|
|
125
125
|
const teamId = cleanString(value.teamId, "constraints.teamId", { max: 120 });
|
|
126
126
|
if (teamId) constraints.teamId = teamId;
|
|
127
|
+
const tenantId = cleanString(value.tenantId, "constraints.tenantId", { max: 120 });
|
|
128
|
+
if (tenantId) constraints.tenantId = tenantId;
|
|
127
129
|
for (const key of ["terminalAttach", "logsRead", "canDelegate"] as const) {
|
|
128
130
|
if (value[key] !== undefined) {
|
|
129
131
|
if (typeof value[key] !== "boolean") throw new ValidationError(`constraints.${key} must be a boolean`);
|