@opengeni/api-router 4.0.0 → 4.1.0-canary.35855175602001
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/dist/app.js +1 -1
- package/dist/{chunk-E7TZFJ65.js → chunk-24C5LPNE.js} +808 -577
- package/dist/{chunk-E7TZFJ65.js.map → chunk-24C5LPNE.js.map} +1 -1
- package/dist/index.js +1 -1
- package/dist/integrations/github-lens-connect.d.ts +1 -1
- package/dist/integrations/oauth-client.d.ts +1 -1
- package/dist/integrations/slack-bot.d.ts +8 -0
- package/dist/integrations/slack-interactions.d.ts +1 -1
- package/dist/mcp/child-read-evidence.d.ts +9 -0
- package/dist/mcp/first-party-tool-permissions.d.ts +3 -0
- package/dist/mcp/session-wait.d.ts +2 -0
- package/dist/routes/codex.d.ts +2 -1
- package/package.json +19 -19
- package/src/integrations/oauth-client.ts +68 -5
- package/src/integrations/slack-bot.ts +32 -3
- package/src/integrations/slack-interactions.ts +79 -8
- package/src/mcp/child-read-evidence.ts +19 -0
- package/src/mcp/first-party-tool-permissions.ts +1 -0
- package/src/mcp/server.ts +175 -101
- package/src/mcp/session-wait.ts +17 -0
- package/src/routes/codex.ts +19 -12
- package/src/routes/skill-content.ts +21 -5
- package/src/sandbox/machines.ts +9 -8
- package/src/sandbox-file-artifacts.ts +15 -1
- package/src/site-uploads.ts +19 -4
package/src/mcp/server.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
setSessionChannel,
|
|
10
10
|
} from "@opengeni/db";
|
|
11
11
|
import { createHash, randomUUID } from "node:crypto";
|
|
12
|
+
import { mintEnrollToken } from "../sandbox/enrollment";
|
|
12
13
|
import { capabilityAccountReadiness } from "./capability-account-readiness";
|
|
13
14
|
import {
|
|
14
15
|
prepareWorkspaceArtifactUpload,
|
|
@@ -280,6 +281,8 @@ import {
|
|
|
280
281
|
boundRigDetailMcp,
|
|
281
282
|
SESSION_EVENT_MCP_MAX_BYTES,
|
|
282
283
|
} from "./session-view";
|
|
284
|
+
import { completeChildReadSequences } from "./child-read-evidence";
|
|
285
|
+
import { acknowledgeConsumedChildEvents } from "@opengeni/db";
|
|
283
286
|
import {
|
|
284
287
|
SESSION_WAIT_COMPLETION_EVENT_TYPES,
|
|
285
288
|
SESSION_WAIT_DEFAULT_SECONDS,
|
|
@@ -1709,7 +1712,7 @@ function registerSandboxFileArtifactTool(
|
|
|
1709
1712
|
"sandbox_file_publish",
|
|
1710
1713
|
{
|
|
1711
1714
|
description:
|
|
1712
|
-
"Publish one exact file from this session's /workspace into durable workspace storage. Use this before presenting a ZIP, CSV, JSON, Markdown, HTML, PDF, Office file, or other sandbox output as downloadable.
|
|
1715
|
+
"Publish one exact file from this session's /workspace into durable workspace storage. Use this before presenting a video, ZIP, CSV, JSON, Markdown, HTML, PDF, Office file, or other sandbox output as downloadable. Present the returned artifact.artifactId as [Open file](artifact:<artifactId>), or  for inline image/video/audio/PDF previews. Replace <artifactId> with the exact returned ID and use a descriptive label. Never expose a sandbox: URL as the durable result.",
|
|
1713
1716
|
inputSchema: {
|
|
1714
1717
|
path: z4.string().min(1).max(4_096),
|
|
1715
1718
|
},
|
|
@@ -3780,7 +3783,7 @@ function registerFleetTools(
|
|
|
3780
3783
|
"sandbox_provision",
|
|
3781
3784
|
{
|
|
3782
3785
|
description:
|
|
3783
|
-
"Provision a new sandbox for the fleet. kind=selfhosted returns device-flow
|
|
3786
|
+
"Provision a new sandbox for the fleet. kind=selfhosted returns interactive device-flow instructions for a human to approve. For authorized headless enrollment, use connected_machine_enroll_token when available instead. kind=modal creates a named Modal sandbox record, but it is NOT yet attachable as a swap target: routing a session onto a second Modal box is not supported yet, so sandbox_swap to its id is rejected. Use the session's own box (the default) or attach a Connected Machine instead.",
|
|
3784
3787
|
inputSchema: {
|
|
3785
3788
|
kind: z4.enum(["selfhosted", "modal"]),
|
|
3786
3789
|
name: z4.string().min(1).max(120).optional(),
|
|
@@ -3796,11 +3799,11 @@ function registerFleetTools(
|
|
|
3796
3799
|
);
|
|
3797
3800
|
}
|
|
3798
3801
|
|
|
3799
|
-
//
|
|
3802
|
+
// Enrollment-management surface for creating tokens and removing machines. This is
|
|
3800
3803
|
// deliberately separate from the session-scoped fleet tools: a worker can list,
|
|
3801
3804
|
// attach, or run on a machine only with session authority, while removal requires
|
|
3802
|
-
// the explicit
|
|
3803
|
-
// sandbox
|
|
3805
|
+
// the explicit enrollments:manage permission. Removal accepts enrollment ids,
|
|
3806
|
+
// never sandbox ids; a Modal record cannot be removed through this operation.
|
|
3804
3807
|
function registerConnectedMachineTools(
|
|
3805
3808
|
server: McpServer,
|
|
3806
3809
|
deps: ApiRouteDeps,
|
|
@@ -3808,11 +3811,40 @@ function registerConnectedMachineTools(
|
|
|
3808
3811
|
sessionId: string | null,
|
|
3809
3812
|
json: JsonResult,
|
|
3810
3813
|
): void {
|
|
3814
|
+
server.registerTool(
|
|
3815
|
+
"connected_machine_enroll_token",
|
|
3816
|
+
{
|
|
3817
|
+
description:
|
|
3818
|
+
"Create a short-lived Connected Machine enrollment token for this workspace using existing enrollments:manage authority. Returns the token, expiry and Unix/PowerShell install commands. Run the appropriate command on the intended machine through an already-authorized execution path, then verify it with sandboxes_list. No separate device approval is required. Screen control is optional and defaults off. The token grants whole-machine access on enrollment; do not put it in public code or unrelated logs. Existing machine connections are preserved.",
|
|
3819
|
+
inputSchema: { allowScreenControl: z4.boolean().optional() },
|
|
3820
|
+
},
|
|
3821
|
+
async ({ allowScreenControl }) => {
|
|
3822
|
+
if (sessionId) {
|
|
3823
|
+
await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
|
|
3824
|
+
}
|
|
3825
|
+
const minted = await mintEnrollToken(deps, {
|
|
3826
|
+
accountId: grant.accountId,
|
|
3827
|
+
workspaceId: grant.workspaceId,
|
|
3828
|
+
allowScreenControl: allowScreenControl ?? false,
|
|
3829
|
+
});
|
|
3830
|
+
if (!minted) throw new Error("enrollment credential plane is not configured");
|
|
3831
|
+
const base = (deps.settings.publicBaseUrl ?? "https://app.opengeni.ai").replace(/\/+$/, "");
|
|
3832
|
+
const unixQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
|
3833
|
+
const windowsQuote = (value: string) => `'${value.replaceAll("'", "''")}'`;
|
|
3834
|
+
return json({
|
|
3835
|
+
...minted,
|
|
3836
|
+
workspaceId: grant.workspaceId,
|
|
3837
|
+
allowScreenControl: allowScreenControl ?? false,
|
|
3838
|
+
installCommandUnix: `curl -fsSL ${unixQuote(`${base}/install.sh`)} | OPENGENI_API_URL=${unixQuote(base)} OPENGENI_ENROLL_TOKEN=${unixQuote(minted.token)} sh`,
|
|
3839
|
+
installCommandWindows: `$env:OPENGENI_API_URL=${windowsQuote(base)}; $env:OPENGENI_ENROLL_TOKEN=${windowsQuote(minted.token)}; irm ${windowsQuote(`${base}/install.ps1`)} | iex`,
|
|
3840
|
+
});
|
|
3841
|
+
},
|
|
3842
|
+
);
|
|
3811
3843
|
server.registerTool(
|
|
3812
3844
|
"connected_machine_remove",
|
|
3813
3845
|
{
|
|
3814
3846
|
description:
|
|
3815
|
-
"Remove one enrolled self-hosted machine while it is offline. Access is revoked, future heartbeat/reconnect credentials are rejected, and session, route, lease, archive, and audit history is retained. Idle dependent sessions are detached atomically; machine-home sessions become compute-less (backend none) until another sandbox is selected. Active turns, live leases, and recovery work remain fail-closed blockers whose typed outcome explains what must settle before retrying. Pass the enrollmentId from the Machines surface, never a Modal sandbox id. Reconnecting later requires
|
|
3847
|
+
"Remove one enrolled self-hosted machine while it is offline. Access is revoked, future heartbeat/reconnect credentials are rejected, and session, route, lease, archive, and audit history is retained. Idle dependent sessions are detached atomically; machine-home sessions become compute-less (backend none) until another sandbox is selected. Active turns, live leases, and recovery work remain fail-closed blockers whose typed outcome explains what must settle before retrying. Pass the enrollmentId from the Machines surface, never a Modal sandbox id. Reconnecting later requires fresh enrollment through device approval or an authorized enrollment token.",
|
|
3816
3848
|
inputSchema: {
|
|
3817
3849
|
enrollmentId: z4.string().uuid(),
|
|
3818
3850
|
expectedUpdatedAt: z4.string().datetime({ offset: true }).optional(),
|
|
@@ -4221,6 +4253,24 @@ function registerWorkspaceOrchestrationTools(
|
|
|
4221
4253
|
sessionCreateVisible: boolean,
|
|
4222
4254
|
json: JsonResult,
|
|
4223
4255
|
): void {
|
|
4256
|
+
const acknowledgeReads = async (children: { sessionId: string; sequences: number[] }[]) => {
|
|
4257
|
+
if (
|
|
4258
|
+
!callerSessionId ||
|
|
4259
|
+
!exactAgentAttemptClaims(grant) ||
|
|
4260
|
+
!children.some((child) => child.sequences.length > 0)
|
|
4261
|
+
)
|
|
4262
|
+
return;
|
|
4263
|
+
// Never use grant.subjectId, session creator, or the latest queue human.
|
|
4264
|
+
// Reauthorize after the bounded read, including after a blocking wait.
|
|
4265
|
+
const actor = await requireLiveAgentAttemptAuthorization(deps.db, grant, callerSessionId);
|
|
4266
|
+
if (!actor.initiatingHumanSubjectId) return;
|
|
4267
|
+
await acknowledgeConsumedChildEvents(deps.db, {
|
|
4268
|
+
workspaceId: grant.workspaceId,
|
|
4269
|
+
sessionId: callerSessionId,
|
|
4270
|
+
subjectId: actor.initiatingHumanSubjectId,
|
|
4271
|
+
children,
|
|
4272
|
+
});
|
|
4273
|
+
};
|
|
4224
4274
|
if (can("sessions:read")) {
|
|
4225
4275
|
server.registerTool(
|
|
4226
4276
|
"sessions_list",
|
|
@@ -4572,31 +4622,31 @@ function registerWorkspaceOrchestrationTools(
|
|
|
4572
4622
|
if (view !== "debug" && !auditRequested) {
|
|
4573
4623
|
const { readSessionEventView } = await import("./session-event-view");
|
|
4574
4624
|
const { listSessionEventSlices } = await import("@opengeni/db/session-event-slices");
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4625
|
+
const page = await readSessionEventView(
|
|
4626
|
+
{
|
|
4627
|
+
sessionId,
|
|
4628
|
+
view,
|
|
4629
|
+
cursor,
|
|
4630
|
+
callId,
|
|
4631
|
+
includeArguments,
|
|
4632
|
+
includeOutput,
|
|
4633
|
+
after,
|
|
4634
|
+
before,
|
|
4635
|
+
direction: requestedDirection,
|
|
4636
|
+
limit,
|
|
4637
|
+
},
|
|
4638
|
+
(options) =>
|
|
4639
|
+
listSessionEventSlices(
|
|
4640
|
+
deps.db,
|
|
4641
|
+
grant.workspaceId,
|
|
4578
4642
|
sessionId,
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
includeOutput,
|
|
4584
|
-
after,
|
|
4585
|
-
before,
|
|
4586
|
-
direction: requestedDirection,
|
|
4587
|
-
limit,
|
|
4588
|
-
},
|
|
4589
|
-
(options) =>
|
|
4590
|
-
listSessionEventSlices(
|
|
4591
|
-
deps.db,
|
|
4592
|
-
grant.workspaceId,
|
|
4593
|
-
sessionId,
|
|
4594
|
-
options,
|
|
4595
|
-
(legacyOptions) =>
|
|
4596
|
-
listSessionEventPage(deps.db, grant.workspaceId, sessionId, legacyOptions),
|
|
4597
|
-
),
|
|
4598
|
-
),
|
|
4643
|
+
options,
|
|
4644
|
+
(legacyOptions) =>
|
|
4645
|
+
listSessionEventPage(deps.db, grant.workspaceId, sessionId, legacyOptions),
|
|
4646
|
+
),
|
|
4599
4647
|
);
|
|
4648
|
+
await acknowledgeReads([{ sessionId, sequences: completeChildReadSequences(page) }]);
|
|
4649
|
+
return json(page);
|
|
4600
4650
|
}
|
|
4601
4651
|
if (
|
|
4602
4652
|
cursor !== undefined ||
|
|
@@ -4640,33 +4690,48 @@ function registerWorkspaceOrchestrationTools(
|
|
|
4640
4690
|
});
|
|
4641
4691
|
if (requestedResultMode === "compact") {
|
|
4642
4692
|
const event = dbPage.events[0];
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
|
|
4693
|
+
const result = event
|
|
4694
|
+
? boundSessionEventCompactResult(
|
|
4695
|
+
compactSessionEventResult(
|
|
4696
|
+
event,
|
|
4697
|
+
latestClass!,
|
|
4698
|
+
dbPage.coveredSequence ?? {
|
|
4699
|
+
first: event.sequence,
|
|
4700
|
+
last: event.sequence,
|
|
4701
|
+
},
|
|
4702
|
+
),
|
|
4703
|
+
)
|
|
4704
|
+
: null;
|
|
4705
|
+
if (
|
|
4706
|
+
result &&
|
|
4707
|
+
["turn.completed", "agent.message.completed"].includes(result.type) &&
|
|
4708
|
+
!result.truncation.truncated &&
|
|
4709
|
+
dbPage.fullPayloadsExact
|
|
4710
|
+
) {
|
|
4711
|
+
await acknowledgeReads([{ sessionId, sequences: [result.sequence] }]);
|
|
4712
|
+
}
|
|
4713
|
+
return json(result);
|
|
4657
4714
|
}
|
|
4658
|
-
|
|
4659
|
-
|
|
4660
|
-
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4715
|
+
const page = boundSessionEventMcpPage({
|
|
4716
|
+
events: dbPage.events,
|
|
4717
|
+
mode,
|
|
4718
|
+
payloadMode,
|
|
4719
|
+
direction,
|
|
4720
|
+
sourceHasMore: dbPage.hasMore,
|
|
4721
|
+
sourceTruncatedBy: dbPage.truncatedBy,
|
|
4722
|
+
after: after ?? 0,
|
|
4723
|
+
before: before ?? null,
|
|
4724
|
+
});
|
|
4725
|
+
if (
|
|
4726
|
+
payloadMode === "full" &&
|
|
4727
|
+
dbPage.fullPayloadsExact &&
|
|
4728
|
+
!page.truncation?.reasons.includes("model_payload")
|
|
4729
|
+
) {
|
|
4730
|
+
await acknowledgeReads([
|
|
4731
|
+
{ sessionId, sequences: page.events.map((event) => event.sequence) },
|
|
4732
|
+
]);
|
|
4733
|
+
}
|
|
4734
|
+
return json(page);
|
|
4670
4735
|
},
|
|
4671
4736
|
);
|
|
4672
4737
|
|
|
@@ -4721,56 +4786,65 @@ function registerWorkspaceOrchestrationTools(
|
|
|
4721
4786
|
// (tests) may pass no extra at all.
|
|
4722
4787
|
const signal: AbortSignal | undefined = extra?.signal;
|
|
4723
4788
|
const workspaceId = grant.workspaceId;
|
|
4789
|
+
const incompleteWaitEvents = new Set<string>();
|
|
4724
4790
|
// A NATS subscription is live fanout only; the durable session_events
|
|
4725
4791
|
// read below is the authority. Bun.serve idleTimeout (255 s) and the
|
|
4726
4792
|
// 60 s MCP client request timeout both exceed the 50 s cap.
|
|
4727
|
-
|
|
4728
|
-
|
|
4729
|
-
|
|
4730
|
-
|
|
4731
|
-
|
|
4732
|
-
|
|
4733
|
-
|
|
4734
|
-
|
|
4735
|
-
|
|
4736
|
-
|
|
4737
|
-
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
}
|
|
4746
|
-
},
|
|
4747
|
-
readTargetEvents: async (target) => {
|
|
4748
|
-
const page = await listSessionEventPage(deps.db, workspaceId, target.sessionId, {
|
|
4749
|
-
after: target.afterSequence,
|
|
4750
|
-
direction: "after",
|
|
4751
|
-
limit: SESSION_WAIT_EVENTS_PER_TARGET,
|
|
4752
|
-
payloadMode: "full",
|
|
4753
|
-
includeTypes: targetEventTypes,
|
|
4754
|
-
maxBytes: SESSION_EVENT_MCP_MAX_BYTES * 4,
|
|
4755
|
-
});
|
|
4756
|
-
return { events: page.events, hasMore: page.hasMore };
|
|
4757
|
-
},
|
|
4758
|
-
readOwnPendingUpdateKinds:
|
|
4759
|
-
ownSessionId === null
|
|
4760
|
-
? null
|
|
4761
|
-
: async () =>
|
|
4762
|
-
(
|
|
4763
|
-
await listOutstandingSessionSystemUpdates(
|
|
4764
|
-
deps.db,
|
|
4765
|
-
workspaceId,
|
|
4766
|
-
ownSessionId,
|
|
4767
|
-
)
|
|
4768
|
-
).map((update) => update.kind),
|
|
4769
|
-
subscribe: (targetSessionId, onEvents) =>
|
|
4770
|
-
deps.bus.subscribe(workspaceId, targetSessionId, onEvents),
|
|
4793
|
+
const result = await waitForSessionChanges({
|
|
4794
|
+
targets,
|
|
4795
|
+
ownSessionId,
|
|
4796
|
+
maxWaitMs: (maxWaitSeconds ?? SESSION_WAIT_DEFAULT_SECONDS) * 1_000,
|
|
4797
|
+
targetEventTypes,
|
|
4798
|
+
targetEventMatches:
|
|
4799
|
+
waitFor === "completion" ? sessionWaitCompletionEventMatches : undefined,
|
|
4800
|
+
signal,
|
|
4801
|
+
source: {
|
|
4802
|
+
reauthorizeTargets: async (sessionIds) => {
|
|
4803
|
+
for (const targetSessionId of sessionIds) {
|
|
4804
|
+
await authorizeFirstPartySession(
|
|
4805
|
+
deps,
|
|
4806
|
+
grant,
|
|
4807
|
+
targetSessionId,
|
|
4808
|
+
"session.events.read",
|
|
4809
|
+
);
|
|
4810
|
+
}
|
|
4771
4811
|
},
|
|
4772
|
-
|
|
4773
|
-
|
|
4812
|
+
readTargetEvents: async (target) => {
|
|
4813
|
+
const page = await listSessionEventPage(deps.db, workspaceId, target.sessionId, {
|
|
4814
|
+
after: target.afterSequence,
|
|
4815
|
+
direction: "after",
|
|
4816
|
+
limit: SESSION_WAIT_EVENTS_PER_TARGET,
|
|
4817
|
+
payloadMode: "full",
|
|
4818
|
+
includeTypes: targetEventTypes,
|
|
4819
|
+
maxBytes: SESSION_EVENT_MCP_MAX_BYTES * 4,
|
|
4820
|
+
});
|
|
4821
|
+
if (!page.fullPayloadsExact) {
|
|
4822
|
+
for (const event of page.events) incompleteWaitEvents.add(event.id);
|
|
4823
|
+
}
|
|
4824
|
+
return { events: page.events, hasMore: page.hasMore };
|
|
4825
|
+
},
|
|
4826
|
+
readOwnPendingUpdateKinds:
|
|
4827
|
+
ownSessionId === null
|
|
4828
|
+
? null
|
|
4829
|
+
: async () =>
|
|
4830
|
+
(
|
|
4831
|
+
await listOutstandingSessionSystemUpdates(deps.db, workspaceId, ownSessionId)
|
|
4832
|
+
).map((update) => update.kind),
|
|
4833
|
+
subscribe: (targetSessionId, onEvents) =>
|
|
4834
|
+
deps.bus.subscribe(workspaceId, targetSessionId, onEvents),
|
|
4835
|
+
},
|
|
4836
|
+
});
|
|
4837
|
+
if (!result.aborted && !result.truncated) {
|
|
4838
|
+
await acknowledgeReads(
|
|
4839
|
+
result.changed.map((target) => ({
|
|
4840
|
+
sessionId: target.sessionId,
|
|
4841
|
+
sequences: target.events
|
|
4842
|
+
.filter((event) => event.contentComplete && !incompleteWaitEvents.has(event.id))
|
|
4843
|
+
.map((event) => event.sequence),
|
|
4844
|
+
})),
|
|
4845
|
+
);
|
|
4846
|
+
}
|
|
4847
|
+
return json(result);
|
|
4774
4848
|
},
|
|
4775
4849
|
);
|
|
4776
4850
|
|
package/src/mcp/session-wait.ts
CHANGED
|
@@ -164,6 +164,8 @@ export function sessionWaitSemanticClass(type: SessionEventType): SessionEventSe
|
|
|
164
164
|
export type SessionWaitTarget = { sessionId: string; afterSequence: number };
|
|
165
165
|
|
|
166
166
|
export type SessionWaitEventSummary = {
|
|
167
|
+
/** True only when every semantic result field survived all projections. */
|
|
168
|
+
contentComplete?: boolean;
|
|
167
169
|
id: string;
|
|
168
170
|
sequence: number;
|
|
169
171
|
type: SessionEventType;
|
|
@@ -521,6 +523,20 @@ export function summarizeSessionWaitEvent(
|
|
|
521
523
|
} else if (typeof compact.result === "string" && compact.result !== compact.text) {
|
|
522
524
|
summary.result = clampSummaryString(compact.result, textChars);
|
|
523
525
|
}
|
|
526
|
+
// Other wait summaries may omit actionable fields (e.g. human-input
|
|
527
|
+
// questions); absence of a truncation marker is not proof of full content.
|
|
528
|
+
summary.contentComplete =
|
|
529
|
+
["turn.completed", "agent.message.completed"].includes(event.type) &&
|
|
530
|
+
!compact.truncation.truncated &&
|
|
531
|
+
text === compact.text &&
|
|
532
|
+
JSON.stringify(failure) === JSON.stringify(compact.failure) &&
|
|
533
|
+
(compact.output === null ||
|
|
534
|
+
compact.output === compact.text ||
|
|
535
|
+
JSON.stringify(compact.output) === JSON.stringify(summary.result)) &&
|
|
536
|
+
(compact.result === null ||
|
|
537
|
+
compact.result === undefined ||
|
|
538
|
+
compact.result === compact.text ||
|
|
539
|
+
JSON.stringify(summary.result) === JSON.stringify(compact.result));
|
|
524
540
|
return summary;
|
|
525
541
|
}
|
|
526
542
|
|
|
@@ -570,6 +586,7 @@ export function boundSessionWaitResult(
|
|
|
570
586
|
...target,
|
|
571
587
|
events: target.events.map((event) => ({
|
|
572
588
|
...event,
|
|
589
|
+
contentComplete: false,
|
|
573
590
|
text: event.text === null ? null : clampSummaryString(event.text, textChars),
|
|
574
591
|
...(typeof event.result === "string"
|
|
575
592
|
? { result: clampSummaryString(event.result, textChars) }
|
package/src/routes/codex.ts
CHANGED
|
@@ -7,14 +7,18 @@
|
|
|
7
7
|
// is added to isAuthExempt. Secrets never leave the server: status/usage read the
|
|
8
8
|
// decrypted token only to call the codex backend; the token is never returned.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
environmentsEncryptionKeyBytes,
|
|
12
|
+
configuredModels,
|
|
13
|
+
getSettings,
|
|
14
|
+
withCodexCatalogProvider,
|
|
15
|
+
type Settings,
|
|
16
|
+
} from "@opengeni/config";
|
|
11
17
|
import {
|
|
12
18
|
accessTokenExpiry,
|
|
13
19
|
buildCodexUsageWindowFromCache,
|
|
14
20
|
CODEX_CLIENT_VERSION,
|
|
15
|
-
CODEX_FALLBACK_MODEL_SLUGS,
|
|
16
21
|
CODEX_FIVE_HOUR_WINDOW_SECONDS,
|
|
17
|
-
CODEX_MODEL_ID_PREFIX,
|
|
18
22
|
CODEX_PROVIDER_ID,
|
|
19
23
|
CODEX_WEEKLY_WINDOW_SECONDS,
|
|
20
24
|
CodexDeviceError,
|
|
@@ -193,20 +197,22 @@ function codexUsageJson(payload: CodexUsagePayload): {
|
|
|
193
197
|
return { status: payload.status, usage: payload };
|
|
194
198
|
}
|
|
195
199
|
|
|
196
|
-
export function codexModelsForPicker(): Array<{
|
|
200
|
+
export function codexModelsForPicker(settings: Settings = getSettings()): Array<{
|
|
197
201
|
id: string;
|
|
198
202
|
label: string;
|
|
199
203
|
provider: string;
|
|
200
204
|
providerLabel: string;
|
|
201
205
|
api: "responses";
|
|
202
206
|
}> {
|
|
203
|
-
return
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
207
|
+
return configuredModels(withCodexCatalogProvider(settings))
|
|
208
|
+
.filter((model) => model.providerId === CODEX_PROVIDER_ID)
|
|
209
|
+
.map((model) => ({
|
|
210
|
+
id: model.id,
|
|
211
|
+
label: model.label,
|
|
212
|
+
provider: CODEX_PROVIDER_ID,
|
|
213
|
+
providerLabel: CODEX_PROVIDER_LABEL,
|
|
214
|
+
api: "responses" as const,
|
|
215
|
+
}));
|
|
210
216
|
}
|
|
211
217
|
import { createSignedState, readSignedState } from "@opengeni/github";
|
|
212
218
|
import {
|
|
@@ -214,6 +220,7 @@ import {
|
|
|
214
220
|
hasPermission,
|
|
215
221
|
requireAccessGrant,
|
|
216
222
|
requireCanonicalLocalAccountAdministrator,
|
|
223
|
+
resolveCatalogSettings,
|
|
217
224
|
type ApiRouteDeps,
|
|
218
225
|
} from "@opengeni/core";
|
|
219
226
|
import type { Context, Hono } from "hono";
|
|
@@ -1284,7 +1291,7 @@ export function registerCodexRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
1284
1291
|
now: new Date(),
|
|
1285
1292
|
});
|
|
1286
1293
|
let valid = false;
|
|
1287
|
-
const models = codexModelsForPicker();
|
|
1294
|
+
const models = codexModelsForPicker((await resolveCatalogSettings(db, settings)).settings);
|
|
1288
1295
|
let catalogError: string | null = null;
|
|
1289
1296
|
try {
|
|
1290
1297
|
const cred = status?.credentialId
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import type { Context, Hono } from "hono";
|
|
3
3
|
import { HTTPException } from "hono/http-exception";
|
|
4
|
-
import { SkillFile, type SkillWriteReceipt } from "@opengeni/contracts";
|
|
5
|
-
import { readSkillRemovalScope } from "@opengeni/db";
|
|
4
|
+
import { SkillFile, SKILL_MAX_FILES, type SkillWriteReceipt } from "@opengeni/contracts";
|
|
5
|
+
import { getSessionForSubject, readSkillRemovalScope } from "@opengeni/db";
|
|
6
6
|
import {
|
|
7
7
|
approveSkill,
|
|
8
8
|
rejectSkill,
|
|
@@ -27,8 +27,8 @@ const saveRequest = z
|
|
|
27
27
|
expectedScopeVersion: z.number().int().nonnegative(),
|
|
28
28
|
scope: scopeSchema.default("workspace"),
|
|
29
29
|
stableKey: z.string().min(1).max(96),
|
|
30
|
-
files: z.array(SkillFile).max(
|
|
31
|
-
deletions: z.array(z.string().min(1).max(512)).max(
|
|
30
|
+
files: z.array(SkillFile).max(SKILL_MAX_FILES),
|
|
31
|
+
deletions: z.array(z.string().min(1).max(512)).max(SKILL_MAX_FILES).default([]),
|
|
32
32
|
reason: z.string().min(1).max(2000),
|
|
33
33
|
})
|
|
34
34
|
.strict();
|
|
@@ -66,9 +66,20 @@ export function registerSkillContentRoutes(app: Hono, deps: ApiRouteDeps): void
|
|
|
66
66
|
.object({
|
|
67
67
|
limit: z.coerce.number().int().min(1).max(250).default(100),
|
|
68
68
|
cursor: z.string().max(2048).optional(),
|
|
69
|
+
sessionId: z.uuid().optional(),
|
|
69
70
|
})
|
|
70
71
|
.safeParse(c.req.query());
|
|
71
72
|
if (!query.success) throw new HTTPException(422, { message: "Invalid Skill list query" });
|
|
73
|
+
if (query.data.sessionId) {
|
|
74
|
+
await requireAccessGrant(c, deps, workspaceId, "sessions:read");
|
|
75
|
+
const session = await getSessionForSubject(
|
|
76
|
+
deps.db,
|
|
77
|
+
workspaceId,
|
|
78
|
+
query.data.sessionId,
|
|
79
|
+
grant.subjectId,
|
|
80
|
+
);
|
|
81
|
+
if (!session) throw new HTTPException(404, { message: "Session not found" });
|
|
82
|
+
}
|
|
72
83
|
let after: { stableKey: string; id: string } | undefined;
|
|
73
84
|
if (query.data.cursor !== undefined) {
|
|
74
85
|
try {
|
|
@@ -83,7 +94,12 @@ export function registerSkillContentRoutes(app: Hono, deps: ApiRouteDeps): void
|
|
|
83
94
|
const rows = await listSkills(
|
|
84
95
|
deps.db,
|
|
85
96
|
{ accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
|
|
86
|
-
{
|
|
97
|
+
{
|
|
98
|
+
limit: query.data.limit + 1,
|
|
99
|
+
metadataOnly: true,
|
|
100
|
+
...(after ? { after } : {}),
|
|
101
|
+
...(query.data.sessionId ? { sessionId: query.data.sessionId } : {}),
|
|
102
|
+
},
|
|
87
103
|
);
|
|
88
104
|
const page = rows.slice(0, query.data.limit);
|
|
89
105
|
const last = page.at(-1);
|
package/src/sandbox/machines.ts
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
type MachineMetricsRow,
|
|
34
34
|
} from "@opengeni/db";
|
|
35
35
|
import { MachineView, MetricSample, type MachinesResponse } from "@opengeni/contracts";
|
|
36
|
-
import { managedSessionGroupBackend } from "@opengeni/core";
|
|
36
|
+
import { managedSessionGroupBackend, sessionGroupMachinePresentation } from "@opengeni/core";
|
|
37
37
|
import { selfhostedHeartbeatLiveness } from "@opengeni/runtime/sandbox";
|
|
38
38
|
|
|
39
39
|
export type MachinesServices = {
|
|
@@ -258,23 +258,24 @@ export async function listMachines(
|
|
|
258
258
|
if (session && groupBackend) {
|
|
259
259
|
const groupActive = activeSandboxId === null;
|
|
260
260
|
const groupLease = await readLease(db, workspaceId, session.sandboxGroupId);
|
|
261
|
+
const presentation = sessionGroupMachinePresentation(groupBackend);
|
|
262
|
+
const localHost = groupBackend === "local";
|
|
261
263
|
machines.push(
|
|
262
264
|
MachineView.parse({
|
|
263
265
|
sandboxId: session.sandboxGroupId,
|
|
264
266
|
enrollmentId: null,
|
|
265
|
-
name:
|
|
266
|
-
kind:
|
|
267
|
+
name: presentation.name,
|
|
268
|
+
kind: presentation.kind,
|
|
267
269
|
state: "online",
|
|
268
270
|
active: groupActive,
|
|
269
271
|
isSessionGroup: true,
|
|
270
272
|
workspaceGeneration: groupLease?.workspaceGeneration ?? null,
|
|
271
273
|
archiveGeneration: groupLease?.archiveGeneration ?? null,
|
|
272
274
|
archiveComplete: groupLease?.archiveComplete ?? false,
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
hasDisplay: false,
|
|
275
|
+
// A local sandbox is this machine. Other managed providers run a Linux box.
|
|
276
|
+
os: localHost ? process.platform : "linux",
|
|
277
|
+
arch: localHost ? process.arch : "x86_64",
|
|
278
|
+
hasDisplay: localHost,
|
|
278
279
|
desktopUnavailableReason: null,
|
|
279
280
|
allowScreenControl: false,
|
|
280
281
|
sharedSessionCount: 1,
|
|
@@ -295,6 +295,14 @@ export function sandboxFileContentType(filename: string): string {
|
|
|
295
295
|
".jpg": "image/jpeg",
|
|
296
296
|
".json": "application/json",
|
|
297
297
|
".md": "text/markdown",
|
|
298
|
+
".mp4": "video/mp4",
|
|
299
|
+
".webm": "video/webm",
|
|
300
|
+
".ogv": "video/ogg",
|
|
301
|
+
".mp3": "audio/mpeg",
|
|
302
|
+
".m4a": "audio/mp4",
|
|
303
|
+
".ogg": "audio/ogg",
|
|
304
|
+
".wav": "audio/wav",
|
|
305
|
+
".flac": "audio/flac",
|
|
298
306
|
".pdf": "application/pdf",
|
|
299
307
|
".png": "image/png",
|
|
300
308
|
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
@@ -314,7 +322,13 @@ export function sandboxArtifactIdentity(input: {
|
|
|
314
322
|
sha256: string;
|
|
315
323
|
}): { fileId: string; uploadId: string } {
|
|
316
324
|
const digest = createHash("sha256")
|
|
317
|
-
.
|
|
325
|
+
// Newly classified media must not collide with old binary-typed publications.
|
|
326
|
+
// Preserve the v1 identity for every previously supported format.
|
|
327
|
+
.update(
|
|
328
|
+
/^(audio|video)\//.test(sandboxFileContentType(input.path))
|
|
329
|
+
? "opengeni-sandbox-media-artifact-v1\0"
|
|
330
|
+
: "opengeni-sandbox-file-artifact-v1\0",
|
|
331
|
+
)
|
|
318
332
|
.update(input.workspaceId)
|
|
319
333
|
.update("\0")
|
|
320
334
|
.update(input.sessionId)
|
package/src/site-uploads.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { WorkspaceArtifactSourceBundle, type AccessGrant } from "@opengeni/contracts";
|
|
2
2
|
import type { ApiRouteDeps, ObjectStorageDependency } from "@opengeni/core";
|
|
3
|
+
import { retryWhileMissing } from "@opengeni/storage";
|
|
3
4
|
import {
|
|
4
5
|
createWorkspaceArtifactUpload,
|
|
5
6
|
getWorkspaceArtifactUpload,
|
|
@@ -19,10 +20,15 @@ export async function validateSiteSource(storage: Storage, key: string, sizeByte
|
|
|
19
20
|
"Editable source JSON must be at most 64 MiB. Exclude dependencies and build output; HTML has a separate storage limit.",
|
|
20
21
|
);
|
|
21
22
|
}
|
|
22
|
-
const content = await storage.getObjectBytes(key);
|
|
23
|
+
const content = await retryWhileMissing(() => storage.getObjectBytes(key));
|
|
24
|
+
if (!content) {
|
|
25
|
+
throw new WorkspaceArtifactOperationError(
|
|
26
|
+
"Site source is not available yet. Retry publication with the same upload.",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
23
29
|
try {
|
|
24
30
|
WorkspaceArtifactSourceBundle.parse(
|
|
25
|
-
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(content
|
|
31
|
+
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(content.bytes)),
|
|
26
32
|
);
|
|
27
33
|
} catch {
|
|
28
34
|
throw new WorkspaceArtifactOperationError(
|
|
@@ -129,13 +135,22 @@ async function freeze(
|
|
|
129
135
|
}
|
|
130
136
|
decoder.decode();
|
|
131
137
|
}
|
|
132
|
-
await storage.putObjectStreamIfAbsent({
|
|
138
|
+
const created = await storage.putObjectStreamIfAbsent({
|
|
133
139
|
key: to,
|
|
134
140
|
contentType,
|
|
135
141
|
byteSize: head.ContentLength,
|
|
136
142
|
chunks: chunks(),
|
|
137
143
|
});
|
|
138
|
-
|
|
144
|
+
// A successful conditional PUT is the receipt. A following visibility miss
|
|
145
|
+
// cannot invalidate it. Only a conflict needs the existing winner's metadata.
|
|
146
|
+
if (created) return head.ContentLength;
|
|
147
|
+
const stored = await retryWhileMissing(() => storage.headObject!(to));
|
|
148
|
+
if (!stored || stored.ContentLength === undefined) {
|
|
149
|
+
throw new WorkspaceArtifactOperationError(
|
|
150
|
+
"Frozen Site upload is unavailable. Retry publication with the same upload.",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return stored.ContentLength;
|
|
139
154
|
}
|
|
140
155
|
type Input = Partial<Parameters<typeof prepareWorkspaceArtifactContent>[2]> & { uploadId?: string };
|
|
141
156
|
export async function prepareWorkspaceArtifactPublication(
|