@opengeni/api-router 0.4.1 → 0.5.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/LICENSE +190 -0
- package/dist/app.js +1 -1
- package/dist/{chunk-2JL5OXRE.js → chunk-DQ5TIRDZ.js} +2205 -664
- package/dist/chunk-DQ5TIRDZ.js.map +1 -0
- package/dist/index.js +16 -6
- package/dist/index.js.map +1 -1
- package/package.json +12 -18
- package/src/app.ts +39 -6
- package/src/http/auth.ts +6 -0
- package/src/integrations/oauth-client.ts +899 -0
- package/src/mcp/documents.ts +124 -20
- package/src/mcp/server.ts +37 -2
- package/src/mcp/toolspace.ts +627 -0
- package/src/routes/connections.ts +179 -0
- package/src/routes/documents.ts +87 -3
- package/src/routes/enrollments.ts +1 -0
- package/src/sandbox/channel-a.ts +1 -1
- package/src/sandbox/machines.ts +2 -0
- package/src/sandbox/metrics-ingestion.ts +41 -10
- package/src/sandbox/viewer.ts +1 -1
- package/dist/chunk-2JL5OXRE.js.map +0 -1
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectionResponse,
|
|
3
|
+
CreateConnectionRequest,
|
|
4
|
+
IntegrationClientMetadata,
|
|
5
|
+
ListConnectionsResponse,
|
|
6
|
+
OAuthStartRequest,
|
|
7
|
+
OAuthStartResponse,
|
|
8
|
+
UpdateConnectionRequest,
|
|
9
|
+
} from "@opengeni/contracts";
|
|
10
|
+
import { requireAccessGrant, requireEnvironmentEncryption } from "@opengeni/core";
|
|
11
|
+
import {
|
|
12
|
+
createConnection,
|
|
13
|
+
encryptEnvironmentValue,
|
|
14
|
+
getConnectionMetadata,
|
|
15
|
+
listConnectionsMetadata,
|
|
16
|
+
revokeConnection,
|
|
17
|
+
updateConnection,
|
|
18
|
+
} from "@opengeni/db";
|
|
19
|
+
import type { ApiRouteDeps } from "@opengeni/core";
|
|
20
|
+
import type { Hono } from "hono";
|
|
21
|
+
import { HTTPException } from "hono/http-exception";
|
|
22
|
+
import {
|
|
23
|
+
completeMcpOAuthCallback,
|
|
24
|
+
integrationBaseUrl,
|
|
25
|
+
startMcpOAuth,
|
|
26
|
+
} from "../integrations/oauth-client";
|
|
27
|
+
|
|
28
|
+
export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
29
|
+
const { db, settings } = deps;
|
|
30
|
+
|
|
31
|
+
function assertIntegrationsEnabled(): void {
|
|
32
|
+
if (!settings.integrationsEnabled) {
|
|
33
|
+
throw new HTTPException(404, { message: "integrations are not enabled for this deployment" });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
app.get("/v1/workspaces/:workspaceId/connections", async (c) => {
|
|
38
|
+
const workspaceId = c.req.param("workspaceId");
|
|
39
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
|
|
40
|
+
return c.json(ListConnectionsResponse.parse({
|
|
41
|
+
connections: await listConnectionsMetadata(db, workspaceId, grant.subjectId),
|
|
42
|
+
}));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
app.post("/v1/workspaces/:workspaceId/connections", async (c) => {
|
|
46
|
+
const workspaceId = c.req.param("workspaceId");
|
|
47
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
48
|
+
const payload = CreateConnectionRequest.parse(await c.req.json());
|
|
49
|
+
const key = requireEnvironmentEncryption(settings);
|
|
50
|
+
const subjectId = writableSubjectId(payload.subjectId, grant.subjectId);
|
|
51
|
+
const connection = await createConnection(db, {
|
|
52
|
+
accountId: grant.accountId,
|
|
53
|
+
workspaceId,
|
|
54
|
+
subjectId,
|
|
55
|
+
providerDomain: payload.providerDomain,
|
|
56
|
+
kind: payload.kind,
|
|
57
|
+
credentialEncrypted: encryptCredentialBundle(key, payload.credential),
|
|
58
|
+
grantedScopes: payload.grantedScopes,
|
|
59
|
+
expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null,
|
|
60
|
+
metadata: payload.metadata,
|
|
61
|
+
createdBySubjectId: grant.subjectId,
|
|
62
|
+
});
|
|
63
|
+
return c.json(ConnectionResponse.parse({ connection }), 201);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
app.get("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
67
|
+
const workspaceId = c.req.param("workspaceId");
|
|
68
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
|
|
69
|
+
const connection = await getConnectionMetadata(db, workspaceId, c.req.param("connectionId"), grant.subjectId);
|
|
70
|
+
if (!connection) {
|
|
71
|
+
throw new HTTPException(404, { message: "connection not found" });
|
|
72
|
+
}
|
|
73
|
+
return c.json(ConnectionResponse.parse({ connection }));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
app.patch("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
77
|
+
const workspaceId = c.req.param("workspaceId");
|
|
78
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
79
|
+
const payload = UpdateConnectionRequest.parse(await c.req.json());
|
|
80
|
+
// Status is not a free-form field: revocation goes through DELETE, and the
|
|
81
|
+
// broker owns needs_reauth/error. Reactivating a connection is only
|
|
82
|
+
// meaningful together with a fresh credential bundle — otherwise a PATCH
|
|
83
|
+
// could clear the broker's re-auth signal while stale tokens stay in place.
|
|
84
|
+
if (payload.status !== undefined) {
|
|
85
|
+
if (payload.status !== "active") {
|
|
86
|
+
throw new HTTPException(400, { message: "status can only be set to \"active\"; use DELETE to revoke" });
|
|
87
|
+
}
|
|
88
|
+
if (payload.credential === undefined) {
|
|
89
|
+
throw new HTTPException(400, { message: "reactivating a connection requires a new credential" });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const key = payload.credential === undefined ? null : requireEnvironmentEncryption(settings);
|
|
93
|
+
const subjectId = payload.subjectId === undefined ? undefined : writableSubjectId(payload.subjectId, grant.subjectId);
|
|
94
|
+
const connection = await updateConnection(db, {
|
|
95
|
+
workspaceId,
|
|
96
|
+
connectionId: c.req.param("connectionId"),
|
|
97
|
+
visibleToSubjectId: grant.subjectId,
|
|
98
|
+
updatedBySubjectId: grant.subjectId,
|
|
99
|
+
...(payload.providerDomain !== undefined ? { providerDomain: payload.providerDomain } : {}),
|
|
100
|
+
...(subjectId !== undefined ? { subjectId } : {}),
|
|
101
|
+
...(payload.kind !== undefined ? { kind: payload.kind } : {}),
|
|
102
|
+
...(payload.status !== undefined ? { status: payload.status } : {}),
|
|
103
|
+
...(payload.credential !== undefined && key ? { credentialEncrypted: encryptCredentialBundle(key, payload.credential) } : {}),
|
|
104
|
+
...(payload.grantedScopes !== undefined ? { grantedScopes: payload.grantedScopes } : {}),
|
|
105
|
+
...(payload.expiresAt !== undefined ? { expiresAt: payload.expiresAt ? new Date(payload.expiresAt) : null } : {}),
|
|
106
|
+
...(payload.metadata !== undefined ? { metadata: payload.metadata } : {}),
|
|
107
|
+
});
|
|
108
|
+
if (!connection) {
|
|
109
|
+
throw new HTTPException(404, { message: "connection not found" });
|
|
110
|
+
}
|
|
111
|
+
return c.json(ConnectionResponse.parse({ connection }));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
app.delete("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
115
|
+
const workspaceId = c.req.param("workspaceId");
|
|
116
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
117
|
+
const connection = await revokeConnection(db, workspaceId, c.req.param("connectionId"), grant.subjectId);
|
|
118
|
+
if (!connection) {
|
|
119
|
+
throw new HTTPException(404, { message: "connection not found" });
|
|
120
|
+
}
|
|
121
|
+
return c.json(ConnectionResponse.parse({ connection }));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
app.post("/v1/workspaces/:workspaceId/connections/oauth/start", async (c) => {
|
|
125
|
+
assertIntegrationsEnabled();
|
|
126
|
+
const workspaceId = c.req.param("workspaceId");
|
|
127
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
128
|
+
const parsed = OAuthStartRequest.safeParse(await c.req.json());
|
|
129
|
+
if (!parsed.success) {
|
|
130
|
+
throw new HTTPException(400, { message: parsed.error.issues[0]?.message ?? "invalid OAuth start request" });
|
|
131
|
+
}
|
|
132
|
+
const payload = parsed.data;
|
|
133
|
+
const result = await startMcpOAuth({ db, settings }, {
|
|
134
|
+
accountId: grant.accountId,
|
|
135
|
+
workspaceId,
|
|
136
|
+
subjectId: grant.subjectId,
|
|
137
|
+
requestUrl: c.req.url,
|
|
138
|
+
payload,
|
|
139
|
+
});
|
|
140
|
+
return c.json(OAuthStartResponse.parse(result));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
app.get("/v1/integrations/oauth/callback", async (c) => {
|
|
144
|
+
assertIntegrationsEnabled();
|
|
145
|
+
const result = await completeMcpOAuthCallback({ db, settings }, {
|
|
146
|
+
code: c.req.query("code"),
|
|
147
|
+
state: c.req.query("state"),
|
|
148
|
+
requestUrl: c.req.url,
|
|
149
|
+
});
|
|
150
|
+
return c.redirect(result.redirectTo, 302);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
app.get("/v1/integrations/oauth/client-metadata.json", (c) => {
|
|
154
|
+
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
|
|
155
|
+
const metadataUrl = `${baseUrl}/v1/integrations/oauth/client-metadata.json`;
|
|
156
|
+
return c.json(IntegrationClientMetadata.parse({
|
|
157
|
+
client_id: metadataUrl,
|
|
158
|
+
client_name: "OpenGeni",
|
|
159
|
+
redirect_uris: [`${baseUrl}/v1/integrations/oauth/callback`],
|
|
160
|
+
token_endpoint_auth_method: "none",
|
|
161
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
162
|
+
response_types: ["code"],
|
|
163
|
+
}));
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function writableSubjectId(requested: string | null | undefined, grantSubjectId: string): string | null {
|
|
168
|
+
if (requested == null) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
if (requested !== grantSubjectId) {
|
|
172
|
+
throw new HTTPException(403, { message: "cannot write a connection for another subject" });
|
|
173
|
+
}
|
|
174
|
+
return requested;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function encryptCredentialBundle(key: Uint8Array, credential: Record<string, unknown>): string {
|
|
178
|
+
return encryptEnvironmentValue(key, JSON.stringify(credential));
|
|
179
|
+
}
|
package/src/routes/documents.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AddDocumentRequest,
|
|
3
|
+
CreateKnowledgeMemoryRequest,
|
|
3
4
|
CreateDocumentBaseRequest,
|
|
4
5
|
Document,
|
|
5
6
|
DocumentBase,
|
|
6
7
|
DocumentSearchRequest,
|
|
8
|
+
KnowledgeMemory,
|
|
9
|
+
KnowledgeMemorySearchRequest,
|
|
10
|
+
UpdateKnowledgeMemoryRequest,
|
|
7
11
|
} from "@opengeni/contracts";
|
|
12
|
+
import {
|
|
13
|
+
createKnowledgeMemory,
|
|
14
|
+
getKnowledgeMemory,
|
|
15
|
+
listKnowledgeMemories,
|
|
16
|
+
updateKnowledgeMemory,
|
|
17
|
+
} from "@opengeni/db";
|
|
8
18
|
import {
|
|
9
19
|
addDocumentToBase,
|
|
10
20
|
createDocumentBase,
|
|
@@ -59,7 +69,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
59
69
|
await requireLimit(deps, { accountId: grant.accountId, workspaceId, action: "document:index", quantity: 0 });
|
|
60
70
|
const payload = AddDocumentRequest.parse(await c.req.json());
|
|
61
71
|
try {
|
|
62
|
-
const document = await addDocumentToBase(db, { accountId: grant.accountId, workspaceId, baseId: c.req.param("baseId")
|
|
72
|
+
const document = await addDocumentToBase(db, { ...payload, accountId: grant.accountId, workspaceId, baseId: c.req.param("baseId") });
|
|
63
73
|
const wasCreated = document.status === "queued" && document.chunkCount === 0 && document.error === null;
|
|
64
74
|
const indexed = document.status === "ready" ? document : (await documentIndexer.indexDocument({ accountId: grant.accountId, workspaceId, documentId: document.id }) ?? document);
|
|
65
75
|
if (indexed.status === "ready") {
|
|
@@ -159,15 +169,89 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
159
169
|
baseIds: [base.id],
|
|
160
170
|
query: payload.query,
|
|
161
171
|
limit: payload.limit,
|
|
172
|
+
mode: payload.mode,
|
|
173
|
+
sourceKinds: payload.sourceKinds,
|
|
174
|
+
aclTags: payload.aclTags,
|
|
162
175
|
}, getDocumentServices()),
|
|
163
176
|
});
|
|
164
177
|
});
|
|
165
178
|
|
|
166
|
-
app.
|
|
179
|
+
app.post("/v1/workspaces/:workspaceId/knowledge/search", async (c) => {
|
|
167
180
|
const workspaceId = c.req.param("workspaceId");
|
|
168
181
|
await requireAccessGrant(c, deps, workspaceId, "documents:search");
|
|
182
|
+
const payload = DocumentSearchRequest.parse(await c.req.json());
|
|
183
|
+
return c.json({
|
|
184
|
+
results: await searchDocuments(db, {
|
|
185
|
+
workspaceId,
|
|
186
|
+
query: payload.query,
|
|
187
|
+
baseIds: payload.baseIds,
|
|
188
|
+
limit: payload.limit,
|
|
189
|
+
mode: payload.mode,
|
|
190
|
+
sourceKinds: payload.sourceKinds,
|
|
191
|
+
aclTags: payload.aclTags,
|
|
192
|
+
}, getDocumentServices()),
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
app.get("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
|
|
197
|
+
const workspaceId = c.req.param("workspaceId");
|
|
198
|
+
await requireAccessGrant(c, deps, workspaceId, "documents:search");
|
|
199
|
+
const parsed = KnowledgeMemorySearchRequest.safeParse({
|
|
200
|
+
query: c.req.query("query") || undefined,
|
|
201
|
+
status: c.req.query("status") || undefined,
|
|
202
|
+
kind: c.req.query("kind") || undefined,
|
|
203
|
+
scope: c.req.query("scope") || undefined,
|
|
204
|
+
limit: c.req.query("limit") ? Number(c.req.query("limit")) : undefined,
|
|
205
|
+
});
|
|
206
|
+
if (!parsed.success) {
|
|
207
|
+
throw new HTTPException(400, { message: "invalid knowledge memory query parameters" });
|
|
208
|
+
}
|
|
209
|
+
return c.json((await listKnowledgeMemories(db, workspaceId, parsed.data)).map((memory) => KnowledgeMemory.parse(memory)));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
app.get("/v1/workspaces/:workspaceId/knowledge/memories/:memoryId", async (c) => {
|
|
213
|
+
const workspaceId = c.req.param("workspaceId");
|
|
214
|
+
await requireAccessGrant(c, deps, workspaceId, "documents:search");
|
|
215
|
+
const memory = await getKnowledgeMemory(db, workspaceId, c.req.param("memoryId"));
|
|
216
|
+
if (!memory) {
|
|
217
|
+
throw new HTTPException(404, { message: "knowledge memory not found" });
|
|
218
|
+
}
|
|
219
|
+
return c.json(KnowledgeMemory.parse(memory));
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
app.post("/v1/workspaces/:workspaceId/knowledge/memories", async (c) => {
|
|
223
|
+
const workspaceId = c.req.param("workspaceId");
|
|
224
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
|
|
225
|
+
const payload = CreateKnowledgeMemoryRequest.parse(await c.req.json());
|
|
226
|
+
return c.json(KnowledgeMemory.parse(await createKnowledgeMemory(db, {
|
|
227
|
+
...payload,
|
|
228
|
+
accountId: grant.accountId,
|
|
229
|
+
workspaceId,
|
|
230
|
+
})), 201);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
app.patch("/v1/workspaces/:workspaceId/knowledge/memories/:memoryId", async (c) => {
|
|
234
|
+
const workspaceId = c.req.param("workspaceId");
|
|
235
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
|
|
236
|
+
const payload = UpdateKnowledgeMemoryRequest.parse(await c.req.json());
|
|
237
|
+
const reviewedBy = payload.reviewedBy
|
|
238
|
+
?? (payload.status === "approved" || payload.status === "rejected" ? grant.subjectLabel ?? grant.subjectId : undefined);
|
|
239
|
+
try {
|
|
240
|
+
return c.json(KnowledgeMemory.parse(await updateKnowledgeMemory(db, workspaceId, c.req.param("memoryId"), {
|
|
241
|
+
...payload,
|
|
242
|
+
...(reviewedBy ? { reviewedBy } : {}),
|
|
243
|
+
})));
|
|
244
|
+
} catch (error) {
|
|
245
|
+
throw documentHttpException(error);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
app.all("/v1/workspaces/:workspaceId/mcp/docs", async (c) => {
|
|
250
|
+
const workspaceId = c.req.param("workspaceId");
|
|
251
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
|
|
252
|
+
const sessionId = typeof grant.metadata?.sessionId === "string" ? grant.metadata.sessionId : undefined;
|
|
169
253
|
const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
170
|
-
const server = buildDocumentsMcpServer(db, workspaceId, getDocumentServices());
|
|
254
|
+
const server = buildDocumentsMcpServer(db, grant.accountId, workspaceId, getDocumentServices(), { createdBySessionId: sessionId });
|
|
171
255
|
await server.connect(transport);
|
|
172
256
|
return await transport.handleRequest(c.req.raw);
|
|
173
257
|
});
|
|
@@ -286,6 +286,7 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
286
286
|
pubkey: row.pubkey,
|
|
287
287
|
exposure: row.exposure,
|
|
288
288
|
hasDisplay: row.hasDisplay,
|
|
289
|
+
desktopUnavailableReason: row.desktopUnavailableReason,
|
|
289
290
|
allowScreenControl: row.allowScreenControl,
|
|
290
291
|
status: row.status,
|
|
291
292
|
os: row.os,
|
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -140,7 +140,7 @@ export async function withChannelA<T>(
|
|
|
140
140
|
const settingsForSession = session.sandboxBackend !== settings.sandboxBackend
|
|
141
141
|
? { ...settings, sandboxBackend: session.sandboxBackend }
|
|
142
142
|
: settings;
|
|
143
|
-
const environment = stableSandboxEnvironmentForRun(settingsForSession, workspaceEnvironment?.values ?? {});
|
|
143
|
+
const environment = stableSandboxEnvironmentForRun(settingsForSession, workspaceEnvironment?.values ?? {}, { workspaceId });
|
|
144
144
|
if (hasGitHubRepositorySelection(session.resources)) {
|
|
145
145
|
applyGitAuthPointerEnvironment(environment, githubAppBotIdentity(settings));
|
|
146
146
|
}
|
package/src/sandbox/machines.ts
CHANGED
|
@@ -199,6 +199,7 @@ export async function listMachines(
|
|
|
199
199
|
os: "linux",
|
|
200
200
|
arch: "x86_64",
|
|
201
201
|
hasDisplay: false,
|
|
202
|
+
desktopUnavailableReason: null,
|
|
202
203
|
allowScreenControl: false,
|
|
203
204
|
sharedSessionCount: 1,
|
|
204
205
|
lastSeenAt: null,
|
|
@@ -244,6 +245,7 @@ export async function listMachines(
|
|
|
244
245
|
os: enrollment.os,
|
|
245
246
|
arch: enrollment.arch,
|
|
246
247
|
hasDisplay: enrollment.hasDisplay,
|
|
248
|
+
desktopUnavailableReason: enrollment.desktopUnavailableReason,
|
|
247
249
|
allowScreenControl: enrollment.allowScreenControl,
|
|
248
250
|
sharedSessionCount,
|
|
249
251
|
lastSeenAt: enrollment.lastSeenAt,
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
import {
|
|
31
31
|
getEnrollment,
|
|
32
32
|
ingestMachineMetricsSample,
|
|
33
|
-
|
|
33
|
+
setEnrollmentDisplayState,
|
|
34
34
|
touchEnrollmentLastSeen,
|
|
35
35
|
type Database,
|
|
36
36
|
type MachineMetricsSample,
|
|
@@ -203,33 +203,63 @@ export function helloReportsDisplay(hello: Hello): boolean {
|
|
|
203
203
|
if (!caps) {
|
|
204
204
|
return false;
|
|
205
205
|
}
|
|
206
|
+
// A CAPTURE-BLOCKED display is NOT a usable display: a Mac reports a display but
|
|
207
|
+
// withholds `desktop` and sets `desktopUnavailableReason` when Screen Recording
|
|
208
|
+
// (TCC) is not granted. Treating it as "has display" is exactly how the 0.1.3
|
|
209
|
+
// incident hid — the machine claimed a desktop it could not capture, so it was
|
|
210
|
+
// offered for computer-use and the model saw a blank. Gate it out here (the single
|
|
211
|
+
// source of truth for `has_display`, consumed by both the machine state and the
|
|
212
|
+
// capability negotiation). The `display`-present fallback is preserved for every
|
|
213
|
+
// other case (e.g. a relay-less agent that reports a display but not `desktop`).
|
|
214
|
+
if (caps.desktopUnavailableReason) {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
206
217
|
return caps.desktop === true || caps.display != null;
|
|
207
218
|
}
|
|
208
219
|
|
|
209
220
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
221
|
+
* The human, actionable reason a display is present but UNUSABLE (macOS Screen
|
|
222
|
+
* Recording / TCC not granted), or null when capture is permitted / the machine is
|
|
223
|
+
* headless. Normalizes the proto's non-optional "" empty string to null so the DB
|
|
224
|
+
* carries a clean tri-state (a real reason vs. no reason) — the Machines dashboard
|
|
225
|
+
* shows "display: capture not granted" only when this is non-null.
|
|
226
|
+
*/
|
|
227
|
+
export function helloDesktopUnavailableReason(hello: Hello): string | null {
|
|
228
|
+
const reason = hello.capabilities?.desktopUnavailableReason;
|
|
229
|
+
return reason ? reason : null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Reconcile `enrollments.has_display` (+ the capture-blocked reason) to what a Hello
|
|
234
|
+
* reports. Resolves the enrollment (the accountId is the RLS principal + the
|
|
235
|
+
* existence check + the current values). A no-change Hello short-circuits BEFORE
|
|
236
|
+
* issuing any write (and the DB writer is itself change-guarded on BOTH fields as a
|
|
237
|
+
* backstop), so a steady state never churns. An unknown/cross-workspace agentId is a
|
|
238
|
+
* no-op.
|
|
215
239
|
*/
|
|
216
240
|
export async function refreshEnrollmentDisplay(
|
|
217
241
|
db: Database,
|
|
218
|
-
input: { workspaceId: string; agentId: string; hasDisplay: boolean },
|
|
242
|
+
input: { workspaceId: string; agentId: string; hasDisplay: boolean; desktopUnavailableReason?: string | null },
|
|
219
243
|
): Promise<{ updated: boolean }> {
|
|
244
|
+
const desktopUnavailableReason = input.desktopUnavailableReason ?? null;
|
|
220
245
|
const enrollment = await getEnrollment(db, input.workspaceId, input.agentId);
|
|
221
246
|
if (!enrollment) {
|
|
222
247
|
return { updated: false };
|
|
223
248
|
}
|
|
224
|
-
if (
|
|
225
|
-
|
|
249
|
+
if (
|
|
250
|
+
enrollment.hasDisplay === input.hasDisplay &&
|
|
251
|
+
(enrollment.desktopUnavailableReason ?? null) === desktopUnavailableReason
|
|
252
|
+
) {
|
|
253
|
+
// Both fields unchanged — do not even issue the UPDATE (no churn on a
|
|
254
|
+
// steady-state Hello).
|
|
226
255
|
return { updated: false };
|
|
227
256
|
}
|
|
228
|
-
return await
|
|
257
|
+
return await setEnrollmentDisplayState(db, {
|
|
229
258
|
accountId: enrollment.accountId,
|
|
230
259
|
workspaceId: input.workspaceId,
|
|
231
260
|
enrollmentId: input.agentId,
|
|
232
261
|
hasDisplay: input.hasDisplay,
|
|
262
|
+
desktopUnavailableReason,
|
|
233
263
|
});
|
|
234
264
|
}
|
|
235
265
|
|
|
@@ -263,6 +293,7 @@ export async function handleHelloPayload(
|
|
|
263
293
|
workspaceId: ids.workspaceId,
|
|
264
294
|
agentId: ids.agentId,
|
|
265
295
|
hasDisplay: helloReportsDisplay(hello),
|
|
296
|
+
desktopUnavailableReason: helloDesktopUnavailableReason(hello),
|
|
266
297
|
});
|
|
267
298
|
} catch (error) {
|
|
268
299
|
observability?.warn?.("Failed to refresh an enrollment's display from a Hello", {
|
package/src/sandbox/viewer.ts
CHANGED
|
@@ -136,7 +136,7 @@ export async function sessionAttachEnvironment(
|
|
|
136
136
|
const settingsForSession = session.sandboxBackend !== services.settings.sandboxBackend
|
|
137
137
|
? { ...services.settings, sandboxBackend: session.sandboxBackend }
|
|
138
138
|
: services.settings;
|
|
139
|
-
const environment = stableSandboxEnvironmentForRun(settingsForSession, workspaceEnvironment?.values ?? {});
|
|
139
|
+
const environment = stableSandboxEnvironmentForRun(settingsForSession, workspaceEnvironment?.values ?? {}, { workspaceId });
|
|
140
140
|
if (hasGitHubRepositorySelection(session.resources)) {
|
|
141
141
|
applyGitAuthPointerEnvironment(environment, githubAppBotIdentity(services.settings));
|
|
142
142
|
}
|