@opengeni/core 2.5.3 → 2.6.4-canary.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.
Files changed (46) hide show
  1. package/dist/access/index.d.ts +24 -0
  2. package/dist/billing/limits.d.ts +5 -0
  3. package/dist/canonical-human-identities.js +2 -2
  4. package/dist/{chunk-ZVZJTMSV.js → chunk-OF65T3PM.js} +2 -2
  5. package/dist/{chunk-YGOMUGYS.js → chunk-QO5GVFFO.js} +17 -8
  6. package/dist/{chunk-YGOMUGYS.js.map → chunk-QO5GVFFO.js.map} +1 -1
  7. package/dist/dependencies.d.ts +10 -1
  8. package/dist/domain/company-brain-governed-writes.d.ts +15 -6
  9. package/dist/domain/company-profile-agent-admin.d.ts +3 -2
  10. package/dist/domain/environments.d.ts +1 -1
  11. package/dist/domain/memory-slack-delivery.d.ts +4 -1
  12. package/dist/domain/personal-connection-delegations.d.ts +1 -0
  13. package/dist/domain/pr-review.d.ts +1 -1
  14. package/dist/domain/scheduled-tasks.d.ts +12 -0
  15. package/dist/domain/sessions.d.ts +37 -11
  16. package/dist/domain/workspace-members.d.ts +8 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.js +1706 -667
  19. package/dist/index.js.map +1 -1
  20. package/dist/managed-auth-session-sets.d.ts +18 -0
  21. package/dist/managed-auth-session-sets.js +3 -1
  22. package/dist/model-catalog.d.ts +89 -0
  23. package/dist/sandbox/fleet.d.ts +6 -4
  24. package/dist/sandbox/routing.d.ts +7 -2
  25. package/dist/sandbox/runtime-settings.d.ts +17 -1
  26. package/package.json +10 -10
  27. package/src/access/index.ts +140 -5
  28. package/src/application/user-resource-grants.ts +31 -2
  29. package/src/billing/limits.ts +57 -24
  30. package/src/dependencies.ts +15 -1
  31. package/src/domain/company-brain-governed-writes.ts +29 -13
  32. package/src/domain/company-profile-agent-admin.ts +3 -2
  33. package/src/domain/environments.ts +6 -34
  34. package/src/domain/memory-slack-delivery.ts +30 -0
  35. package/src/domain/personal-connection-delegations.ts +40 -7
  36. package/src/domain/remember.ts +5 -6
  37. package/src/domain/scheduled-tasks.ts +146 -1
  38. package/src/domain/sessions.ts +912 -358
  39. package/src/domain/workspace-members.ts +34 -2
  40. package/src/index.ts +1 -0
  41. package/src/managed-auth-session-sets.ts +38 -11
  42. package/src/model-catalog.ts +565 -0
  43. package/src/sandbox/fleet.ts +20 -17
  44. package/src/sandbox/routing.ts +18 -4
  45. package/src/sandbox/runtime-settings.ts +32 -0
  46. /package/dist/{chunk-ZVZJTMSV.js.map → chunk-OF65T3PM.js.map} +0 -0
@@ -19,6 +19,7 @@ import type { ManagedAuthSessionAdapter } from "./managed-auth-session-sets";
19
19
  import type { ApiSandboxClient, ResumeBoxByIdInput, ResumedSandboxSession } from "./sandbox-types";
20
20
  import type { TranscriptionSegmenter, TranscriptionService } from "./transcription";
21
21
  import type { EditableArtifactApplicationPort } from "./editable-artifact-live";
22
+ import type { ResolvedCatalogSettings } from "./model-catalog";
22
23
  import type {
23
24
  EditableArtifactAgentApplication,
24
25
  EditableArtifactDurableExportService,
@@ -129,6 +130,13 @@ export type ManagedEmailTransport = {
129
130
 
130
131
  export type AppDependencies = {
131
132
  settings: Settings;
133
+ /**
134
+ * Original deployment settings when `settings` is already overlaid with a
135
+ * deployment/workspace catalog snapshot. Model-bearing request adapters set
136
+ * this marker so core admission never feeds a synthetic reviewed provider
137
+ * back through deployment validation.
138
+ */
139
+ catalogSourceSettings?: Settings;
132
140
  db: Database;
133
141
  /**
134
142
  * Host-composed editable artifact engine. Standalone startup binds the same
@@ -225,6 +233,7 @@ export type AppDependencies = {
225
233
  export type ObjectStorageDependency = ReturnType<typeof createObjectStorage>;
226
234
 
227
235
  export type ApiRouteDeps = AppDependencies & {
236
+ resolveCatalogSettings: () => Promise<ResolvedCatalogSettings>;
228
237
  managedEmailTransport: ManagedEmailTransport;
229
238
  objectStorage: ObjectStorageDependency;
230
239
  githubStateSecret: string;
@@ -244,7 +253,12 @@ export type ApiRouteDeps = AppDependencies & {
244
253
  */
245
254
  export type AcceptSessionUserMessageDependencies = Pick<
246
255
  AppDependencies,
247
- "settings" | "db" | "bus" | "sessionAuthorization" | "schedulePromptPostCommit"
256
+ | "settings"
257
+ | "catalogSourceSettings"
258
+ | "db"
259
+ | "bus"
260
+ | "sessionAuthorization"
261
+ | "schedulePromptPostCommit"
248
262
  > & {
249
263
  workflowClient: Pick<SessionWorkflowClient, "wakeSessionWorkflow">;
250
264
  objectStorage: ObjectStorageDependency;
@@ -45,9 +45,8 @@ export type CompanyBrainGovernedWriteRouterOptions = {
45
45
  activate?: typeof activateGovernedLearningDecision;
46
46
  /**
47
47
  * Destinations the router may activate automatically from a final eligible
48
- * decision. Defaults to preferences only: mandatory instruction policy keeps
49
- * requiring human-authoritative activation even under `automatic`, so its
50
- * decision receipt is recorded but the inactive draft stays for review.
48
+ * decision. Defaults to Skills and instruction policy. Both still pass
49
+ * through the evaluator and destination-owned activation lifecycle.
51
50
  */
52
51
  automaticDestinations?: ReadonlyArray<GovernedLearningActivationDestination>;
53
52
  /**
@@ -64,7 +63,27 @@ export type CompanyBrainGovernedWriteRouterOptions = {
64
63
  };
65
64
 
66
65
  export const DEFAULT_AUTOMATIC_LEARNING_DESTINATIONS: ReadonlyArray<GovernedLearningActivationDestination> =
67
- ["preference"];
66
+ ["preference", "instruction_policy"];
67
+
68
+ /**
69
+ * Start a non-authoritative post-activation notification without making the
70
+ * durable activation receipt wait for its settlement. The callback is invoked
71
+ * synchronously so immediate enqueue work begins before the router returns;
72
+ * both synchronous throws and later promise rejections are contained.
73
+ *
74
+ * Exact retries intentionally dispatch again: the publication sink owns an
75
+ * activation-receipt idempotency key, while the activation receipt remains the
76
+ * caller-facing source of truth even if notification delivery stalls forever.
77
+ */
78
+ export function dispatchBestEffortGovernedLearningNotification(
79
+ notify: () => Promise<unknown>,
80
+ ): void {
81
+ try {
82
+ void notify().catch(() => undefined);
83
+ } catch {
84
+ // Notification is best-effort; the durable activation receipt already exists.
85
+ }
86
+ }
68
87
 
69
88
  /**
70
89
  * Transport-neutral facade for explicit governed Company Brain proposals.
@@ -143,9 +162,8 @@ function classifyLearningFailure(
143
162
  * snapshot. `suggest` records the content-free decision receipt only. Under
144
163
  * `automatic`, a final `automaticEligible` receipt is handed to the activation
145
164
  * controller, which revalidates current authority and applies the change only
146
- * through the destination-owned lifecycle. By default only preferences may
147
- * activate automatically; mandatory instruction policy always keeps a human
148
- * activation boundary. Evaluation or activation failure
165
+ * through the destination-owned lifecycle. Eligible Skills and instruction
166
+ * policies may activate automatically. Evaluation or activation failure
149
167
  * never rolls back the durable proposal; it is reported as a bounded
150
168
  * `learningFailure` and the proposal remains for human review.
151
169
  */
@@ -297,16 +315,14 @@ export function createCompanyBrainLearningPolicyRouter(
297
315
  learningFailure: classifyLearningFailure("activation", error),
298
316
  });
299
317
  }
300
- try {
301
- await notifyActivation({
318
+ dispatchBestEffortGovernedLearningNotification(() =>
319
+ notifyActivation({
302
320
  db: options.db,
303
321
  receipt: activation,
304
322
  sessionId: attempt.sessionId,
305
323
  attemptId: attempt.attemptId,
306
- });
307
- } catch {
308
- // Notification is best-effort; the durable receipts already exist.
309
- }
324
+ }),
325
+ );
310
326
  return CompanyBrainLearningPolicyRouteReceipt.parse({
311
327
  operationId: request.operationId,
312
328
  workspaceId: attempt.workspaceId,
@@ -21,8 +21,9 @@ export type CompanyProfileAgentAdminRouterOptions = {
21
21
  /**
22
22
  * Explicit organization administration is intentionally separate from derived
23
23
  * workspace learning. The database capabilities own exact-attempt admission,
24
- * current organization-owner authority, canonical human confirmation,
25
- * tenant isolation, CAS, and immutable receipts.
24
+ * current organization-owner authority, the separate organization policy,
25
+ * canonical review when required, tenant isolation, CAS, and immutable
26
+ * receipts.
26
27
  */
27
28
  export function createCompanyProfileAgentAdminRouter(
28
29
  options: CompanyProfileAgentAdminRouterOptions,
@@ -1,5 +1,9 @@
1
1
  import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
2
- import type { AccessGrant, VariableSet } from "@opengeni/contracts";
2
+ import {
3
+ variableSetVariableNameReservation,
4
+ type AccessGrant,
5
+ type VariableSet,
6
+ } from "@opengeni/contracts";
3
7
  import { getVariableSet, recordAuditEvent, type Database } from "@opengeni/db";
4
8
  import { HTTPException } from "hono/http-exception";
5
9
  import { requirePermission } from "../access";
@@ -11,40 +15,8 @@ export const MAX_VARIABLES_PER_ENVIRONMENT = 100;
11
15
  // collectGitIdentityEnvironment) plus loader/startup-injection vectors. These
12
16
  // can never be set as variable set variables, so the run-scoped
13
17
  // git auth block and git identity always win without silent collisions.
14
- const reservedExactNames = new Set([
15
- "HOME",
16
- "PATH",
17
- "SHELL",
18
- "USER",
19
- "LOGNAME",
20
- "TMPDIR",
21
- "IFS",
22
- "ENV",
23
- "BASH_ENV",
24
- "NODE_OPTIONS",
25
- "PYTHONPATH",
26
- "PYTHONSTARTUP",
27
- "PERL5OPT",
28
- "PERL5LIB",
29
- "GH_TOKEN",
30
- "GITHUB_TOKEN",
31
- "GITLAB_TOKEN",
32
- "AZURE_DEVOPS_EXT_PAT",
33
- "GIT_ASKPASS",
34
- "GIT_TERMINAL_PROMPT",
35
- ]);
36
-
37
- const reservedPrefixes = [
38
- "OPENGENI_",
39
- "GIT_CONFIG_",
40
- "GIT_AUTHOR_",
41
- "GIT_COMMITTER_",
42
- "LD_",
43
- "DYLD_",
44
- ];
45
-
46
18
  export function assertAllowedVariableSetVariableName(name: string): void {
47
- if (reservedExactNames.has(name) || reservedPrefixes.some((prefix) => name.startsWith(prefix))) {
19
+ if (variableSetVariableNameReservation(name)) {
48
20
  throw new HTTPException(422, {
49
21
  message: `reserved variable set variable name / reserved environment variable name: ${name}`,
50
22
  });
@@ -1,10 +1,13 @@
1
1
  import type { MemorySlackPublicationDistribution } from "@opengeni/contracts";
2
2
  import {
3
+ correctWorkspaceMemory,
3
4
  enqueueMemorySlackPublication,
4
5
  getCurrentMemorySlackPublicationConfiguration,
5
6
  getWorkspaceMemorySlackPublicationSnapshot,
6
7
  saveWorkspaceMemory,
7
8
  withWorkspaceRls,
9
+ type CorrectWorkspaceMemoryInput,
10
+ type CorrectWorkspaceMemoryResult,
8
11
  type Database,
9
12
  type EnqueueMemorySlackPublicationResult,
10
13
  type MemoryEmbedder,
@@ -46,6 +49,33 @@ export async function saveWorkspaceMemoryWithSlackPublication(
46
49
  });
47
50
  }
48
51
 
52
+ export async function correctWorkspaceMemoryWithSlackPublication(
53
+ db: Database,
54
+ input: CorrectWorkspaceMemoryInput,
55
+ publication: MemorySlackPublicationCommitRequest | null,
56
+ embedder?: MemoryEmbedder,
57
+ ): Promise<
58
+ CorrectWorkspaceMemoryResult & { slackPublication: MemorySlackPublicationCommitResult }
59
+ > {
60
+ return await withWorkspaceRls(db, input.workspaceId, async (scopedDb) => {
61
+ const result = await correctWorkspaceMemory(scopedDb, input, embedder);
62
+ if (!publication || result.action !== "superseded" || !result.replacement) {
63
+ return { ...result, slackPublication: { decision: null, enqueue: null } };
64
+ }
65
+ const replacement = await requiredSnapshot(scopedDb, input.workspaceId, result.replacement.id);
66
+ const slackPublication = await evaluateAndEnqueue(scopedDb, {
67
+ accountId: input.accountId,
68
+ workspaceId: input.workspaceId,
69
+ snapshot: replacement,
70
+ changeKind: "corrected",
71
+ relatedMemoryId: result.memory.id,
72
+ occurredAt: result.replacement.updatedAt,
73
+ publication,
74
+ });
75
+ return { ...result, slackPublication };
76
+ });
77
+ }
78
+
49
79
  async function publishSavedMemoryMutation(
50
80
  db: Database,
51
81
  input: SaveWorkspaceMemoryInput,
@@ -384,6 +384,7 @@ export function personalConnectionDelegationsFromVisibleConnections(input: {
384
384
  export function personalConnectionDelegationsFromParent(input: {
385
385
  servers: McpServerConfig[];
386
386
  parentDelegations: McpPersonalConnectionDelegation[];
387
+ personalGitHubResources?: ResourceRef[];
387
388
  targetSessionId?: string;
388
389
  rejectActivatedConnections?: boolean;
389
390
  }): McpPersonalConnectionDelegation[] {
@@ -416,19 +417,55 @@ export function personalConnectionDelegationsFromParent(input: {
416
417
  childEligible(item) &&
417
418
  (item.connectionType === "social" ||
418
419
  item.connectionType === "atlassian" ||
420
+ item.connectionType === "github_personal" ||
419
421
  item.serverId === GOOGLE_DRIVE_PUBLICATION_SERVER_ID),
420
422
  )
421
423
  .map((item) => ({ ...item })),
422
424
  ];
425
+ const requestedGitHub = personalGitHubRepositoryResources(input.personalGitHubResources ?? []);
426
+ const inherited = projected.flatMap((delegation) => {
427
+ if (delegation.connectionType !== "github_personal") return [delegation];
428
+ if (requestedGitHub.length === 0) return [];
429
+ const snapshot = delegation.personalGitHubRepositorySelection;
430
+ if (!snapshot) return [];
431
+ const repositories = requestedGitHub.map((resource) => {
432
+ const parent = snapshot.repositories.find(
433
+ (candidate) =>
434
+ candidate.repositoryId === resource.repositoryId &&
435
+ candidate.canonicalUrl === resource.uri &&
436
+ candidate.ref === resource.ref,
437
+ );
438
+ if (
439
+ !parent ||
440
+ resource.credentialBindingId !== snapshot.credentialBindingId ||
441
+ (resource.access === "write" && parent.access !== "write")
442
+ ) {
443
+ throw new Error("agent-created personal GitHub repository exceeds parent authority");
444
+ }
445
+ return { ...parent, access: resource.access };
446
+ });
447
+ return [
448
+ {
449
+ ...delegation,
450
+ personalGitHubRepositorySelection: { ...snapshot, repositories },
451
+ },
452
+ ];
453
+ });
454
+ if (
455
+ requestedGitHub.length > 0 &&
456
+ !inherited.some((delegation) => delegation.connectionType === "github_personal")
457
+ ) {
458
+ throw new Error("agent-created personal GitHub repository authority is unavailable");
459
+ }
423
460
  if (
424
461
  input.rejectActivatedConnections &&
425
- projected.some((delegation) => delegation.userDelegation)
462
+ inherited.some((delegation) => delegation.userDelegation)
426
463
  ) {
427
464
  throw new Error(
428
465
  "scheduled connection authority is not available until task occurrence authority is activated",
429
466
  );
430
467
  }
431
- return projected;
468
+ return inherited;
432
469
  }
433
470
 
434
471
  /**
@@ -798,11 +835,6 @@ export async function freezePersonalConnectionDelegations(input: {
798
835
  "agent-created work inherits connection authority from its exact parent turn",
799
836
  );
800
837
  }
801
- if (personalGitHubResources.length > 0) {
802
- throw new Error(
803
- "agent-created personal GitHub repository authority is not activated in this delivery phase",
804
- );
805
- }
806
838
  const inherited = personalConnectionDelegationsFromParent({
807
839
  servers,
808
840
  parentDelegations: await getSessionTurnPersonalConnectionDelegations(
@@ -811,6 +843,7 @@ export async function freezePersonalConnectionDelegations(input: {
811
843
  input.source.sessionId,
812
844
  input.source.turnId,
813
845
  ),
846
+ personalGitHubResources,
814
847
  ...(input.targetSessionId ? { targetSessionId: input.targetSessionId } : {}),
815
848
  ...(input.rejectUnselectedActivatedConnections !== undefined
816
849
  ? { rejectActivatedConnections: input.rejectUnselectedActivatedConnections }
@@ -35,6 +35,7 @@ import { createHash } from "node:crypto";
35
35
  import {
36
36
  createCompanyBrainLearningPolicyRouter,
37
37
  derivedGovernedLearningOperationId,
38
+ dispatchBestEffortGovernedLearningNotification,
38
39
  } from "./company-brain-governed-writes";
39
40
  import { publishGovernedLearningEventToSlack } from "./governed-learning-slack-publication";
40
41
 
@@ -548,16 +549,14 @@ export function createRememberRouter(options: RememberRouterOptions): {
548
549
  }
549
550
  }
550
551
  if (!activation) throw asRememberFailure(lastFailure);
551
- try {
552
- await notifyActivation({
552
+ dispatchBestEffortGovernedLearningNotification(() =>
553
+ notifyActivation({
553
554
  db: options.db,
554
555
  receipt: activation,
555
556
  sessionId: attempt.sessionId,
556
557
  attemptId: attempt.attemptId,
557
- });
558
- } catch {
559
- // Notification is best-effort; the durable receipts already exist.
560
- }
558
+ }),
559
+ );
561
560
  return RememberConfirmReceipt.parse({
562
561
  status: "activated",
563
562
  operationId: request.operationId,
@@ -22,6 +22,8 @@ import {
22
22
  createScheduledTask,
23
23
  deleteScheduledTask,
24
24
  getConnectionMetadata,
25
+ getEnrollment,
26
+ getLiveEnrollmentConnection,
25
27
  getKnowledgeSourceForSyncAuthority,
26
28
  getNestedAgentDepthDeploymentPolicy,
27
29
  getRig,
@@ -29,8 +31,11 @@ import {
29
31
  getScheduledTaskIncludingDeletedForUpdate,
30
32
  getScheduledTaskPersonalConnectionDelegations,
31
33
  getScheduledTaskXaiProviderAccountAuthoritySnapshot,
34
+ getSandbox,
32
35
  getSessionTurnXaiProviderAccountAuthoritySnapshot,
33
36
  getSession,
37
+ lockActiveWorkspaceGatewayCustomModelForAdmission,
38
+ lockActiveWorkspaceOpenRouterCustomModelForAdmission,
34
39
  nestedPostgresSqlState,
35
40
  requireWorkspace,
36
41
  scopedKnowledgeScopeKey,
@@ -51,6 +56,7 @@ import {
51
56
  } from "../session-authorization";
52
57
  import type { SessionWorkflowClient } from "../dependencies";
53
58
  import type { ObjectStorageDependency } from "../dependencies";
59
+ import { workspaceCustomModelReference } from "../model-catalog";
54
60
  import { settingsWithEnabledCapabilityMcpServers } from "./capabilities";
55
61
  import { validateVariableSetAttachment } from "./environments";
56
62
  import {
@@ -96,6 +102,35 @@ export function scheduledTaskToolsProvided(rawPayload: unknown): boolean {
96
102
  );
97
103
  }
98
104
 
105
+ function workspaceCustomModelCommitGuard(input: {
106
+ settings: Settings;
107
+ accountId: string;
108
+ workspaceId: string;
109
+ modelId: string;
110
+ }): ((tx: Database) => Promise<void>) | undefined {
111
+ const reference = workspaceCustomModelReference(input.settings, input.modelId);
112
+ if (!reference) return undefined;
113
+ return async (tx: Database): Promise<void> => {
114
+ const active =
115
+ reference.providerKind === "openrouter"
116
+ ? await lockActiveWorkspaceOpenRouterCustomModelForAdmission(tx, {
117
+ accountId: input.accountId,
118
+ workspaceId: input.workspaceId,
119
+ upstreamModelId: reference.upstreamModelId,
120
+ })
121
+ : await lockActiveWorkspaceGatewayCustomModelForAdmission(tx, {
122
+ accountId: input.accountId,
123
+ workspaceId: input.workspaceId,
124
+ upstreamModelId: reference.upstreamModelId,
125
+ });
126
+ if (!active) {
127
+ throw new HTTPException(422, {
128
+ message: `model is not available: ${input.modelId}`,
129
+ });
130
+ }
131
+ };
132
+ }
133
+
99
134
  export function scheduledConnectionSurfaceEligibility(
100
135
  settings: Settings,
101
136
  target: Pick<Session, "firstPartyMcpTools" | "firstPartyMcpPermissions"> | null,
@@ -143,7 +178,7 @@ export async function createValidatedScheduledTask(input: {
143
178
  action: knowledgeAction,
144
179
  });
145
180
  }
146
- const agentConfig = knowledgeAction
181
+ const agentConfig: ScheduledTaskAgentConfig = knowledgeAction
147
182
  ? input.payload.agentConfig
148
183
  : await validateScheduledTaskAgentConfig({
149
184
  ...input,
@@ -164,6 +199,15 @@ export async function createValidatedScheduledTask(input: {
164
199
  rigId: input.payload.rigId,
165
200
  agentConfig,
166
201
  });
202
+ if (!knowledgeAction) {
203
+ await validateScheduledTaskMachineTarget({
204
+ settings: input.settings,
205
+ db: input.db,
206
+ grant: input.grant,
207
+ runMode: input.payload.runMode,
208
+ agentConfig,
209
+ });
210
+ }
167
211
  if (!knowledgeAction && input.payload.variableSetId) {
168
212
  await validateVariableSetAttachment(
169
213
  { settings: input.settings, db: input.db },
@@ -223,6 +267,15 @@ export async function createValidatedScheduledTask(input: {
223
267
  workspaceId: input.grant.workspaceId,
224
268
  subjectId: input.grant.subjectId,
225
269
  });
270
+ const beforeCreateCommit =
271
+ !knowledgeAction && input.payload.runMode !== "existing_session"
272
+ ? workspaceCustomModelCommitGuard({
273
+ settings: input.settings,
274
+ accountId: input.grant.accountId,
275
+ workspaceId: input.grant.workspaceId,
276
+ modelId: agentConfig.model ?? input.settings.openaiModel,
277
+ })
278
+ : undefined;
226
279
  return await withScheduledTaskAuthorityWriteErrors(() =>
227
280
  createScheduledTask(input.db, {
228
281
  id,
@@ -245,6 +298,7 @@ export async function createValidatedScheduledTask(input: {
245
298
  variableSetId: input.payload.variableSetId ?? null,
246
299
  rigId: input.payload.rigId ?? null,
247
300
  metadata: input.payload.metadata,
301
+ ...(beforeCreateCommit ? { beforeCreateCommit } : {}),
248
302
  }),
249
303
  );
250
304
  }
@@ -399,6 +453,81 @@ export async function validateScheduledTaskTarget(input: {
399
453
  return session;
400
454
  }
401
455
 
456
+ export async function validateScheduledTaskMachineTarget(input: {
457
+ settings: Settings;
458
+ db: Database;
459
+ grant: AccessGrant;
460
+ runMode: ScheduledTask["runMode"];
461
+ agentConfig: ScheduledTaskAgentConfig;
462
+ requireOnline?: boolean;
463
+ }): Promise<{
464
+ sandboxId: string;
465
+ enrollmentId: string;
466
+ sandboxOs: Session["sandboxOs"];
467
+ } | null> {
468
+ const machineTarget = input.agentConfig.machineTarget;
469
+ if (!machineTarget) {
470
+ if (
471
+ input.runMode !== "existing_session" &&
472
+ (input.agentConfig.sandboxBackend ?? input.settings.sandboxBackend) === "selfhosted"
473
+ ) {
474
+ throw new HTTPException(422, {
475
+ message:
476
+ "self-hosted scheduled tasks require a Connected Machine; select a machine before saving",
477
+ });
478
+ }
479
+ return null;
480
+ }
481
+ if (input.runMode === "existing_session") {
482
+ throw new HTTPException(422, {
483
+ message: "machineTarget cannot be used with an existing-session target",
484
+ });
485
+ }
486
+ if (!input.settings.sandboxOwnershipEnabled || !input.settings.sandboxSelfhostedEnabled) {
487
+ throw new HTTPException(422, {
488
+ message: "Connected Machines are not enabled for scheduled tasks in this deployment",
489
+ });
490
+ }
491
+ const access = {
492
+ accountId: input.grant.accountId,
493
+ workspaceId: input.grant.workspaceId,
494
+ subjectId: input.grant.subjectId,
495
+ };
496
+ const sandbox = await getSandbox(input.db, access, machineTarget.targetSandboxId);
497
+ if (!sandbox || sandbox.kind !== "selfhosted" || !sandbox.enrollmentId) {
498
+ throw new HTTPException(422, {
499
+ message: "the selected Connected Machine is unavailable",
500
+ });
501
+ }
502
+ if (sandbox.scope === "user") {
503
+ throw new HTTPException(422, {
504
+ message:
505
+ "personal Connected Machines cannot run unattended schedules; select a workspace or organization machine",
506
+ });
507
+ }
508
+ const enrollment = input.requireOnline
509
+ ? await getLiveEnrollmentConnection(input.db, access, sandbox.enrollmentId)
510
+ : await getEnrollment(input.db, access, sandbox.enrollmentId);
511
+ if (!enrollment || enrollment.status !== "active") {
512
+ throw new HTTPException(422, {
513
+ message: input.requireOnline
514
+ ? "the selected Connected Machine is offline"
515
+ : "the selected Connected Machine is unavailable",
516
+ });
517
+ }
518
+ if (input.requireOnline && !enrollment.workspaceRoot) {
519
+ throw new HTTPException(422, {
520
+ message:
521
+ "the selected Connected Machine has not reported a workspace root; reconnect it with a current agent",
522
+ });
523
+ }
524
+ return {
525
+ sandboxId: sandbox.id,
526
+ enrollmentId: sandbox.enrollmentId,
527
+ sandboxOs: enrollment.os,
528
+ };
529
+ }
530
+
402
531
  export function scheduledTaskForGrant(task: ScheduledTask, grant: AccessGrant): ScheduledTask {
403
532
  if (hasPermission(grant.permissions, "sessions:control") || task.targetSessionId === null) {
404
533
  return task;
@@ -647,6 +776,15 @@ export async function validatedScheduledTaskUpdate(input: {
647
776
  (input.payload.metadata !== undefined &&
648
777
  !isDeepStrictEqual(input.payload.metadata, input.existing.metadata)) ||
649
778
  (input.existing.status === "paused" && input.payload.status === "active");
779
+ if (materialExecutionChange && nextRunMode !== "existing_session") {
780
+ const beforeUpdateCommit = workspaceCustomModelCommitGuard({
781
+ settings: input.settings,
782
+ accountId: input.existing.accountId,
783
+ workspaceId: input.existing.workspaceId,
784
+ modelId: nextAgentConfig.model ?? input.settings.openaiModel,
785
+ });
786
+ if (beforeUpdateCommit) update.beforeUpdateCommit = beforeUpdateCommit;
787
+ }
650
788
  const existingXaiAuthority = await getScheduledTaskXaiProviderAccountAuthoritySnapshot(
651
789
  input.db,
652
790
  input.existing.workspaceId,
@@ -793,6 +931,13 @@ export async function validatedScheduledTaskUpdate(input: {
793
931
  rigId: input.payload.rigId !== undefined ? input.payload.rigId : input.existing.rigId,
794
932
  agentConfig: update.agentConfig ?? input.existing.agentConfig,
795
933
  });
934
+ await validateScheduledTaskMachineTarget({
935
+ settings: input.settings,
936
+ db: input.db,
937
+ grant: input.grant,
938
+ runMode: nextRunMode,
939
+ agentConfig: update.agentConfig ?? input.existing.agentConfig,
940
+ });
796
941
  if (
797
942
  input.payload.targetSessionId !== undefined ||
798
943
  input.existing.runMode === "existing_session" ||