spawnfile 0.1.34 → 0.1.35

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 (87) hide show
  1. package/dist/audit/readAudit.d.ts +3 -3
  2. package/dist/auth/profileStore.d.ts +3 -1
  3. package/dist/auth/profileStore.js +31 -30
  4. package/dist/auth/types.d.ts +16 -0
  5. package/dist/cli/authCommands.d.ts +1 -1
  6. package/dist/cli/authCommands.js +21 -1
  7. package/dist/cli/runCli.d.ts +2 -0
  8. package/dist/cli/runCli.js +2 -1
  9. package/dist/cli/trainingBooted.d.ts +2 -0
  10. package/dist/cli/trainingBooted.js +5 -1
  11. package/dist/compiler/containerDaimonBrokerRender.js +12 -2
  12. package/dist/compiler/containerDaimonGatewayRender.js +4 -0
  13. package/dist/compiler/containerDaimonUidEntrypointRender.js +3 -1
  14. package/dist/compiler/runProject.d.ts +2 -0
  15. package/dist/compiler/runProject.js +6 -5
  16. package/dist/compiler/runProjectAuth.js +4 -1
  17. package/dist/compiler/training/rewind/contract.d.ts +6 -6
  18. package/dist/compiler/training/trial/boot.d.ts +20 -1
  19. package/dist/compiler/training/trial/boot.js +102 -24
  20. package/dist/compiler/training/trial/docker.d.ts +2 -0
  21. package/dist/compiler/training/trial/docker.js +1 -1
  22. package/dist/compiler/training/trial/gateway.d.ts +5 -0
  23. package/dist/compiler/training/trial/gateway.js +18 -3
  24. package/dist/compiler/training/trial/homeLogin.d.ts +31 -0
  25. package/dist/compiler/training/trial/homeLogin.js +69 -0
  26. package/dist/compiler/training/trial/index.d.ts +5 -1
  27. package/dist/compiler/training/trial/index.js +5 -1
  28. package/dist/compiler/training/trial/judge.d.ts +15 -0
  29. package/dist/compiler/training/trial/judge.js +42 -0
  30. package/dist/compiler/training/trial/launcher.d.ts +28 -13
  31. package/dist/compiler/training/trial/launcher.js +168 -24
  32. package/dist/compiler/training/trial/login.d.ts +35 -0
  33. package/dist/compiler/training/trial/login.js +85 -0
  34. package/dist/compiler/training/trial/realmTurns.d.ts +20 -0
  35. package/dist/compiler/training/trial/realmTurns.js +69 -0
  36. package/dist/manifest/renderSpawnfile.js +2 -1
  37. package/dist/runtime/daimon/config.js +3 -3
  38. package/dist/runtime/daimon/engineDescriptors.generated.d.ts +1 -1
  39. package/dist/runtime/daimon/engineDescriptors.generated.js +1 -1
  40. package/dist/runtime/daimon/index.d.ts +1 -1
  41. package/dist/runtime/daimon/index.js +1 -1
  42. package/dist/runtime/daimon/runAuth.js +8 -0
  43. package/dist/runtime/daimon/subscriptionRealms.d.ts +18 -0
  44. package/dist/runtime/daimon/subscriptionRealms.js +14 -1
  45. package/dist/runtime/daimon/training/booted/adapter.d.ts +5 -1
  46. package/dist/runtime/daimon/training/booted/adapter.js +3 -2
  47. package/dist/runtime/daimon/training/booted/adapter.js.map +1 -1
  48. package/dist/runtime/daimon/training/booted/index.d.ts +3 -1
  49. package/dist/runtime/daimon/training/booted/index.js +2 -1
  50. package/dist/runtime/daimon/training/booted/index.js.map +1 -1
  51. package/dist/runtime/daimon/training/booted/judge.d.ts +39 -0
  52. package/dist/runtime/daimon/training/booted/judge.js +122 -0
  53. package/dist/runtime/daimon/training/booted/judge.js.map +1 -0
  54. package/dist/runtime/trainingGraderProviders.js +10 -4
  55. package/dist/runtime/trainingProviders.d.ts +4 -1
  56. package/dist/runtime/trainingProviders.js +13 -6
  57. package/dist/runtime/usageRequestLedger.d.ts +2 -0
  58. package/dist/runtime/usageRequestLedger.js +3 -1
  59. package/dist/shared/trainingLegacy/trainingLegacyHashDomains.d.ts +1 -0
  60. package/dist/shared/trainingLegacy/trainingLegacyHashDomains.js +1 -0
  61. package/dist/shared/trainingLegacy/trainingLegacyHashDomains.js.map +1 -1
  62. package/package.json +1 -1
  63. package/runtimes.yaml +5 -5
  64. package/dist/runtime/daimon/training/providers/codex-subscription/index.d.ts +0 -1
  65. package/dist/runtime/daimon/training/providers/codex-subscription/index.js +0 -2
  66. package/dist/runtime/daimon/training/providers/codex-subscription/index.js.map +0 -1
  67. package/dist/runtime/daimon/training/providers/codex-subscription/protocol.d.ts +0 -17
  68. package/dist/runtime/daimon/training/providers/codex-subscription/protocol.js +0 -80
  69. package/dist/runtime/daimon/training/providers/codex-subscription/protocol.js.map +0 -1
  70. package/dist/runtime/daimon/training/providers/codex-subscription/provider.d.ts +0 -12
  71. package/dist/runtime/daimon/training/providers/codex-subscription/provider.js +0 -125
  72. package/dist/runtime/daimon/training/providers/codex-subscription/provider.js.map +0 -1
  73. package/dist/runtime/daimon/training/providers/codex-subscription/strict-schema.d.ts +0 -6
  74. package/dist/runtime/daimon/training/providers/codex-subscription/strict-schema.js +0 -219
  75. package/dist/runtime/daimon/training/providers/codex-subscription/strict-schema.js.map +0 -1
  76. package/dist/runtime/daimon/training/providers/grok-subscription/cli.d.ts +0 -19
  77. package/dist/runtime/daimon/training/providers/grok-subscription/cli.js +0 -81
  78. package/dist/runtime/daimon/training/providers/grok-subscription/cli.js.map +0 -1
  79. package/dist/runtime/daimon/training/providers/grok-subscription/index.d.ts +0 -3
  80. package/dist/runtime/daimon/training/providers/grok-subscription/index.js +0 -4
  81. package/dist/runtime/daimon/training/providers/grok-subscription/index.js.map +0 -1
  82. package/dist/runtime/daimon/training/providers/grok-subscription/protocol.d.ts +0 -14
  83. package/dist/runtime/daimon/training/providers/grok-subscription/protocol.js +0 -89
  84. package/dist/runtime/daimon/training/providers/grok-subscription/protocol.js.map +0 -1
  85. package/dist/runtime/daimon/training/providers/grok-subscription/provider.d.ts +0 -9
  86. package/dist/runtime/daimon/training/providers/grok-subscription/provider.js +0 -37
  87. package/dist/runtime/daimon/training/providers/grok-subscription/provider.js.map +0 -1
@@ -1,10 +1,11 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { mkdir, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
- import { DAIMON_GATEWAY_KEY_ENV, DAIMON_GATEWAY_URL_ENV, getDaimonEngineDescriptor, isDaimonGatewayRealmMountId } from "../../../runtime/daimon/index.js";
4
+ import { DAIMON_GATEWAY_KEY_ENV, DAIMON_GATEWAY_URL_ENV, daimonProfileRealmVolume, getDaimonEngineDescriptor, isDaimonGatewayRealmMountId } from "../../../runtime/daimon/index.js";
5
5
  import { daimonHelperControlCall } from "../../../runtime/daimon/control.js";
6
6
  import { RUNTIME_INSTALL_ROOT } from "../../../runtime/container.js";
7
7
  import { parseUsageLedger } from "../../../runtime/usageLedger.js";
8
+ import { parseUsageRequestLedger } from "../../../runtime/usageRequestLedger.js";
8
9
  import { DAIMON_GROK_TURN_USAGE_LEDGER } from "../../../runtime/daimon/contractManifest.js";
9
10
  import { SpawnfileError } from "../../../shared/index.js";
10
11
  import { buildCompiledProject } from "../../buildProject.js";
@@ -14,6 +15,7 @@ import { slugify } from "../../helpers.js";
14
15
  import { createDockerRunInvocation, runDockerContainer } from "../../runProject.js";
15
16
  import { executeDockerRunWithSupportCleanup } from "../../runProjectLifecycle.js";
16
17
  import { createTrialDocker, waitFor } from "./docker.js";
18
+ import { HOME_LOGIN_FILES, saveHomeLogin, seedHomeLogin } from "./homeLogin.js";
17
19
  import { writeTrialProject } from "./project.js";
18
20
  /**
19
21
  * Lineage namespace for training trials: every trial derives its own durable
@@ -25,27 +27,61 @@ const CONTROL_TOKEN_ENV = "SPAWNFILE_DAIMON_CONTROL_TOKEN";
25
27
  const WAKE_VERSION = "noopolis.daimon.wake.v2";
26
28
  const TERMINAL = new Set(["completed", "failed", "stopped"]);
27
29
  const MAX_TRANSCRIPT_BYTES = 1024 * 1024;
30
+ /**
31
+ * Readable and collision-free: trial ids may share long prefixes. The cut can
32
+ * land just after a hyphen, so it is trimmed: the slug names a kebab-case
33
+ * deployment, which refuses a doubled hyphen.
34
+ */
35
+ export const trialSlug = (trialId) => `${slugify(trialId).slice(0, 32).replace(/-+$/u, "") || "trial"}-${createHash("sha256").update(trialId).digest("hex").slice(0, 8)}`;
28
36
  /** Compiles and builds the trial's one-agent organization under its own lineage, as production does. */
29
37
  export const prepareTrial = async (options) => {
30
38
  const docker = options.docker ?? createTrialDocker();
31
- // Readable and collision-free: trial ids may share long prefixes.
32
- const slug = `${slugify(options.trialId).slice(0, 32) || "trial"}-${createHash("sha256").update(options.trialId).digest("hex").slice(0, 8)}`;
33
- const project = await writeTrialProject(options.agentManifestPath, options.instructions, path.join(options.directory, "project"));
34
- const compiled = await compileProject(project, {
35
- deploymentLineage: resolveDeploymentLineage(slug, TRAINING_TRIAL_LINEAGE_NAMESPACE),
36
- outputDirectory: path.join(options.directory, "out")
37
- });
39
+ const slug = trialSlug(options.trialId);
38
40
  const image = `spawnfile-training-trial:${slug}`;
39
- await buildCompiledProject(project, compiled, { imageTag: image });
41
+ const compiled = await (async () => {
42
+ const project = await writeTrialProject(options.agentManifestPath, options.instructions, path.join(options.directory, "project"));
43
+ const compiled = await compileProject(project, {
44
+ deploymentLineage: resolveDeploymentLineage(slug, TRAINING_TRIAL_LINEAGE_NAMESPACE),
45
+ outputDirectory: path.join(options.directory, "out")
46
+ });
47
+ await buildCompiledProject(project, compiled, { imageTag: image });
48
+ return compiled;
49
+ })().catch(async (error) => {
50
+ await rm(options.directory, { recursive: true, force: true });
51
+ throw error;
52
+ });
40
53
  const instance = compiled.report.container?.runtime_instances.find((entry) => entry.runtime === "daimon");
41
54
  const agents = Object.entries(instance?.engine_by_node_id ?? {});
42
55
  if (!instance || agents.length !== 1)
43
56
  throw new SpawnfileError("validation_error", "A production-booted trial compiles exactly one Daimon agent");
44
- const [agentId] = agents[0];
57
+ const [agentId, engine] = agents[0];
45
58
  const home = instance.agent_paths?.[agentId]?.home_path;
46
59
  // The engine writes its own session records under its home inside the agent's runtime home.
47
- const engineHome = getDaimonEngineDescriptor(agents[0][1])?.homeDirectory;
60
+ const engineHome = getDaimonEngineDescriptor(engine)?.homeDirectory;
48
61
  const sessionsPath = home && engineHome !== undefined ? path.posix.join(home, engineHome, "sessions") : null;
62
+ const lineageVolumes = (realms) => (compiled.report.container?.persistent_mounts ?? [])
63
+ .filter((mount) => !isDaimonGatewayRealmMountId(mount.id) && daimonProfileRealmVolume(mount.id, realms) === undefined)
64
+ .map((mount) => mount.volume_name);
65
+ /**
66
+ * The profile's home login (`spawnfile auth login`) for this organization's
67
+ * fresh runtime home: seeded into it before the container starts, written
68
+ * back after it stops. Only the engine's login files cross; the home itself
69
+ * is the trial's own and is removed with it.
70
+ */
71
+ const homeLoginFor = (realms) => {
72
+ const files = HOME_LOGIN_FILES[engine];
73
+ const loginVolume = realms?.realms?.[engine]?.homeVolume;
74
+ if (!files || !loginVolume)
75
+ return undefined;
76
+ const mount = (compiled.report.container?.persistent_mounts ?? []).find((entry) => entry.mount_path === home);
77
+ if (!mount)
78
+ throw new SpawnfileError("validation_error", `The ${engine} runtime home of ${agentId} is not a persistent mount; its login cannot be seeded`);
79
+ return { files, homeMountPath: mount.mount_path, homeVolume: mount.volume_name, loginVolume };
80
+ };
81
+ const removeVolumes = async (volumes) => {
82
+ for (const volume of volumes)
83
+ await docker(["volume", "rm", "-f", volume]).catch(() => undefined);
84
+ };
49
85
  const dispose = async () => {
50
86
  await docker(["image", "rm", "-f", image]).catch(() => undefined);
51
87
  await rm(options.directory, { recursive: true, force: true });
@@ -54,33 +90,67 @@ export const prepareTrial = async (options) => {
54
90
  agentId, dispose, image,
55
91
  engines: agents.map(([, engine]) => engine),
56
92
  runtimeRoot: `${RUNTIME_INSTALL_ROOT}/daimon`,
93
+ login: async (realms, loginOptions = {}) => {
94
+ const homeLogin = homeLoginFor(realms);
95
+ const invocation = await createDockerRunInvocation(compiled, image, {
96
+ authProfile: realms, command: ["auth", engine, "login", "--config", instance.config_path],
97
+ containerName: `spawnfile-login-${slug}`, interactive: true
98
+ });
99
+ if (loginOptions.dryRun) {
100
+ await rm(invocation.supportDirectory, { recursive: true, force: true });
101
+ return [invocation.command, ...invocation.args];
102
+ }
103
+ try {
104
+ await executeDockerRunWithSupportCleanup(invocation, runDockerContainer);
105
+ if (homeLogin)
106
+ await saveHomeLogin(docker, image, homeLogin);
107
+ }
108
+ finally {
109
+ await removeVolumes(lineageVolumes(realms));
110
+ }
111
+ return [invocation.command, ...invocation.args];
112
+ },
57
113
  boot: async (gateway, bootOptions = {}) => {
58
114
  const token = randomBytes(32).toString("base64url");
59
115
  const container = `spawnfile-trial-${slug}`;
60
116
  await mkdir(options.directory, { recursive: true });
61
117
  const envFilePath = path.join(options.directory, "boot.env");
62
- // The boot inputs: control token, gateway URL and key. No login, no realm.
63
- await writeFile(envFilePath, `${CONTROL_TOKEN_ENV}=${token}\n${DAIMON_GATEWAY_URL_ENV}=${gateway.url}\n${DAIMON_GATEWAY_KEY_ENV}=${gateway.key}\n`, { mode: 0o600 });
118
+ // The boot inputs: control token, plus the gateway URL and key. No gatewayed login, no
119
+ // gatewayed realm; only a volume-held login of the selected profile is mounted.
120
+ const gatewayEnv = gateway ? `${DAIMON_GATEWAY_URL_ENV}=${gateway.url}\n${DAIMON_GATEWAY_KEY_ENV}=${gateway.key}\n` : "";
121
+ await writeFile(envFilePath, `${CONTROL_TOKEN_ENV}=${token}\n${gatewayEnv}`, { mode: 0o600 });
64
122
  const invocation = await createDockerRunInvocation(compiled, image, {
65
- containerName: container, deploymentName: slug, detach: true, envFilePath, network: gateway.network
123
+ authProfile: bootOptions.realms ?? null, containerName: container, deploymentName: slug, detach: true, envFilePath,
124
+ ...(gateway ? { network: gateway.network } : {})
66
125
  });
67
- const volumes = (compiled.report.container?.persistent_mounts ?? [])
68
- .filter((mount) => !isDaimonGatewayRealmMountId(mount.id)).map((mount) => mount.volume_name);
126
+ const volumes = lineageVolumes(bootOptions.realms);
127
+ const homeLogin = homeLoginFor(bootOptions.realms);
69
128
  const teardown = async () => {
70
- await docker(["rm", "-f", container]).catch(() => undefined);
71
- for (const volume of volumes)
72
- await docker(["volume", "rm", "-f", volume]).catch(() => undefined);
73
- await rm(invocation.supportDirectory, { recursive: true, force: true });
74
- await rm(envFilePath, { force: true });
129
+ try {
130
+ // Stopped, not killed, so the engine finishes any login rewrite before it is saved.
131
+ if (homeLogin)
132
+ await docker(["stop", "--time", "10", container]).catch(() => undefined);
133
+ await docker(["rm", "-f", container]).catch(() => undefined);
134
+ if (homeLogin)
135
+ await saveHomeLogin(docker, image, homeLogin);
136
+ }
137
+ finally {
138
+ await removeVolumes(volumes);
139
+ await rm(invocation.supportDirectory, { recursive: true, force: true });
140
+ await rm(envFilePath, { force: true });
141
+ }
75
142
  };
76
143
  try {
144
+ if (homeLogin)
145
+ await seedHomeLogin(docker, image, homeLogin);
77
146
  await executeDockerRunWithSupportCleanup(invocation, runDockerContainer);
78
147
  await rm(envFilePath, { force: true });
79
148
  const target = { containerRef: container, dockerArgs: [], dockerCommand: "docker", imageRef: image, token };
80
149
  const call = options.control ?? daimonHelperControlCall;
81
150
  await waitFor(async () => {
82
- if (await docker(["inspect", "--format", "{{.State.Running}}", container]) !== "true") {
83
- const logs = await docker(["logs", "--tail", "30", container]).catch(() => "");
151
+ // A restart means the organization already exited once (`--restart unless-stopped` hides it).
152
+ if (await docker(["inspect", "--format", "{{.State.Running}} {{.RestartCount}}", container]) !== "true 0") {
153
+ const logs = await docker(["logs", "--tail", "30", container], { combined: true }).catch(() => "");
84
154
  throw new SpawnfileError("runtime_error", `trial organization exited before readiness: ${logs.replace(/\s+/gu, " ").slice(-600)}`);
85
155
  }
86
156
  return call(target, "GET", "/v2/availability").then((response) => response.status === 200, () => false);
@@ -88,12 +158,19 @@ export const prepareTrial = async (options) => {
88
158
  return createBootedTrial({ agentId, call, container, docker, sessionsPath, target, teardown });
89
159
  }
90
160
  catch (error) {
91
- await teardown();
161
+ // The boot failure is the error to report, not a teardown that follows it.
162
+ await teardown().catch(() => undefined);
92
163
  throw error;
93
164
  }
94
165
  }
95
166
  };
96
167
  };
168
+ /** Daimon's per-request stream sits beside the usage ledger. */
169
+ const REQUEST_LEDGER = path.posix.join(path.posix.dirname(DAIMON_GROK_TURN_USAGE_LEDGER.filePath), "requests.jsonl");
170
+ const readRequestToolCalls = async (docker, container, agentId, deliveryId) => {
171
+ const text = await docker(["exec", container, "cat", REQUEST_LEDGER]).catch(() => "");
172
+ return parseUsageRequestLedger(text).filter((row) => row.agent === agentId && row.wake === deliveryId).map((row) => row.tool_calls ?? null);
173
+ };
97
174
  const readLedger = async (docker, container) => {
98
175
  const text = await docker(["exec", container, "cat", DAIMON_GROK_TURN_USAGE_LEDGER.filePath]).catch(() => "");
99
176
  return parseUsageLedger(text);
@@ -132,6 +209,7 @@ const createBootedTrial = (input) => {
132
209
  acceptanceId: id, code: receipt.code ?? null, executionId: receipt.execution_id ?? null, usage,
133
210
  state: TERMINAL.has(receipt.state ?? "") ? receipt.state : "timeout",
134
211
  text: receipt.text ?? "",
212
+ requestToolCalls: await readRequestToolCalls(input.docker, input.container, input.agentId, options.deliveryId),
135
213
  transcript: input.sessionsPath === null ? null : await readTranscript(input.docker, input.container, input.sessionsPath)
136
214
  };
137
215
  }
@@ -1,7 +1,9 @@
1
1
  /** Bounded Docker CLI call for trial lifecycle; stdout trimmed, stderr bounded into the error. */
2
+ /** `combined` returns stderr after stdout (container logs write both). */
2
3
  export type TrialDocker = (args: readonly string[], options?: {
3
4
  timeoutMs?: number;
4
5
  stdin?: string;
6
+ combined?: boolean;
5
7
  }) => Promise<string>;
6
8
  export declare const createTrialDocker: (dockerCommand?: string) => TrialDocker;
7
9
  /** Poll `probe` until it resolves true, or fail after `timeoutMs`. */
@@ -3,7 +3,7 @@ import { SpawnfileError } from "../../../shared/index.js";
3
3
  export const createTrialDocker = (dockerCommand = "docker") => (args, options = {}) => new Promise((resolve, reject) => {
4
4
  const child = execFile(dockerCommand, [...args], { maxBuffer: 16 * 1024 * 1024, timeout: options.timeoutMs ?? 120_000 }, (error, stdout, stderr) => error
5
5
  ? reject(new SpawnfileError("runtime_error", `docker ${args[0]} ${args[1] ?? ""} failed: ${String(stderr).replace(/\s+/gu, " ").slice(-400)}`))
6
- : resolve(String(stdout).trim()));
6
+ : resolve(`${stdout}${options.combined ? stderr : ""}`.trim()));
7
7
  if (options.stdin !== undefined)
8
8
  child.stdin?.end(options.stdin);
9
9
  else
@@ -28,6 +28,11 @@ export interface TrainingGateway extends TrainingGatewayAccess {
28
28
  /** Replaces a dead or stuck sidecar in place: same key, networks and realm, so booted trials keep working. */
29
29
  restart(script?: (script: string) => string): Promise<void>;
30
30
  }
31
+ /**
32
+ * The gatewayed engines whose login the auth profile holds: the run's gateway
33
+ * serves all of them, so subject and judges may run on any of these engines.
34
+ */
35
+ export declare const trainingGatewayLogins: (profile: ResolvedAuthProfile) => string[];
31
36
  /** One durable realm volume per auth profile and engine: the gateway is its sole holder (exclusive flock). */
32
37
  export declare const trainingGatewayRealmVolume: (profile: string, engine: string) => string;
33
38
  /**
@@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { DAIMON_GATEWAY_PORT, daimonGatewayBootstrapMountPath, daimonGatewayedEngines, daimonGatewayRealmPath } from "../../../runtime/daimon/gateway.js";
6
- import { assertSafeDaimonSourceFile, daimonSourcePathForEngine } from "../../../runtime/daimon/runAuth.js";
6
+ import { assertSafeDaimonSourceFile } from "../../../runtime/daimon/runAuth.js";
7
7
  import { DAIMON_DOCKER_RUNTIME_SECURITY_ARGS, SpawnfileError } from "../../../shared/index.js";
8
8
  import { createDockerTargetExecutors } from "../../../target/dockerCommandExecutor.js";
9
9
  import { removeExactDockerResource } from "../../../target/dockerResources.js";
@@ -15,6 +15,21 @@ import { createTrialDocker, waitFor } from "./docker.js";
15
15
  const hex = (value) => createHash("sha256").update(value).digest("hex");
16
16
  const opaque = (value) => parseOpaqueTargetHandle(`opaque_${hex(value)}`);
17
17
  const LABEL = "spawnfile.training-gateway";
18
+ /**
19
+ * The gatewayed engines whose login the auth profile holds: the run's gateway
20
+ * serves all of them, so subject and judges may run on any of these engines.
21
+ */
22
+ export const trainingGatewayLogins = (profile) => daimonGatewayedEngines(Object.keys(profile.imports)).map(({ id }) => id);
23
+ /**
24
+ * The bootstrap leaf comes only from the profile's own import: a training
25
+ * gateway never falls back to the operator's personal engine home.
26
+ */
27
+ const trainingGatewaySource = (profile, engine, fileName) => {
28
+ const imported = profile.imports[engine];
29
+ if (!imported)
30
+ throw new SpawnfileError("validation_error", `Auth profile ${profile.name} has no imported ${engine} login`);
31
+ return path.join(imported.path, fileName);
32
+ };
18
33
  /** One durable realm volume per auth profile and engine: the gateway is its sole holder (exclusive flock). */
19
34
  export const trainingGatewayRealmVolume = (profile, engine) => `spawnfile-training-${slugify(profile) || "profile"}-${engine}-realm`;
20
35
  /**
@@ -44,7 +59,7 @@ export const startTrainingGateway = async (options) => {
44
59
  const mounts = [];
45
60
  const realmVolumes = [];
46
61
  for (const descriptor of descriptors) {
47
- const source = daimonSourcePathForEngine(descriptor.id, process.env, options.authProfile);
62
+ const source = trainingGatewaySource(options.authProfile, descriptor.id, descriptor.auth.fileName);
48
63
  await assertSafeDaimonSourceFile(source, descriptor.id, descriptor.auth.maxCredentialBytes, descriptor.id);
49
64
  const volume = trainingGatewayRealmVolume(options.authProfile.name, descriptor.id);
50
65
  realmVolumes.push(volume);
@@ -73,7 +88,7 @@ export const startTrainingGateway = async (options) => {
73
88
  await waitFor(async () => {
74
89
  const state = await docker(["inspect", "--format", "{{.State.Running}}", container]);
75
90
  if (state !== "true") {
76
- const logs = await docker(["logs", "--tail", "20", container]).catch(() => "");
91
+ const logs = await docker(["logs", "--tail", "20", container], { combined: true }).catch(() => "");
77
92
  throw new SpawnfileError("runtime_error", `training auth gateway exited before readiness: ${logs.replace(/\s+/gu, " ").slice(-400)}`);
78
93
  }
79
94
  return docker(["exec", container, "bash", "-c", `(exec 3<>/dev/tcp/127.0.0.1/${DAIMON_GATEWAY_PORT})`]).then(() => true, () => false);
@@ -0,0 +1,31 @@
1
+ import type { TrialDocker } from "./docker.js";
2
+ /**
3
+ * The login files an engine with a volume-held login keeps in its runtime home,
4
+ * relative to that home. AGY's OAuth token (which AGY rewrites when it
5
+ * refreshes), its installation id and its onboarded project: nothing else, so
6
+ * no conversation, history or knowledge ever passes between trials.
7
+ */
8
+ export declare const HOME_LOGIN_FILES: Readonly<Record<string, readonly string[]>>;
9
+ /** One organization's runtime home and the profile volume its login files persist in. */
10
+ export interface HomeLogin {
11
+ /** The organization's own runtime home volume and where it is mounted. */
12
+ homeVolume: string;
13
+ homeMountPath: string;
14
+ /** The profile's volume holding only the login files. */
15
+ loginVolume: string;
16
+ files: readonly string[];
17
+ }
18
+ /**
19
+ * Copies each listed file that exists from the profile's login root into the
20
+ * runtime home (`seed`) or back (`save`), as the organization uid: regular
21
+ * single-link files only, no link anywhere on either path, written beside the
22
+ * target and renamed over it, identical files left alone. Started as root only
23
+ * to hand the destination root to the uid and to enter the home, whose image
24
+ * ancestors the uid cannot traverse until the organization's entrypoint opens
25
+ * them; the bytes are never printed.
26
+ */
27
+ export declare const HOME_LOGIN_PROGRAM: string;
28
+ /** Seeds the profile's login files into the organization's fresh runtime home, before it boots. */
29
+ export declare const seedHomeLogin: (docker: TrialDocker, image: string, login: HomeLogin) => Promise<string>;
30
+ /** Writes the login files the engine wrote or rewrote (a refreshed token) back to the profile, after the container stops. */
31
+ export declare const saveHomeLogin: (docker: TrialDocker, image: string, login: HomeLogin) => Promise<string>;
@@ -0,0 +1,69 @@
1
+ import { DAIMON_ORGANIZATION_UID } from "../../../runtime/daimon/runtimeIdentity.js";
2
+ /**
3
+ * The login files an engine with a volume-held login keeps in its runtime home,
4
+ * relative to that home. AGY's OAuth token (which AGY rewrites when it
5
+ * refreshes), its installation id and its onboarded project: nothing else, so
6
+ * no conversation, history or knowledge ever passes between trials.
7
+ */
8
+ export const HOME_LOGIN_FILES = {
9
+ agy: [
10
+ ".gemini/antigravity-cli/antigravity-oauth-token",
11
+ ".gemini/antigravity-cli/installation_id",
12
+ ".gemini/antigravity-cli/cache/default_project_id.txt",
13
+ ".gemini/antigravity-cli/cache/onboarding.json"
14
+ ]
15
+ };
16
+ const LOGIN_ROOT = "/spawnfile-home-login";
17
+ /**
18
+ * Copies each listed file that exists from the profile's login root into the
19
+ * runtime home (`seed`) or back (`save`), as the organization uid: regular
20
+ * single-link files only, no link anywhere on either path, written beside the
21
+ * target and renamed over it, identical files left alone. Started as root only
22
+ * to hand the destination root to the uid and to enter the home, whose image
23
+ * ancestors the uid cannot traverse until the organization's entrypoint opens
24
+ * them; the bytes are never printed.
25
+ */
26
+ export const HOME_LOGIN_PROGRAM = [
27
+ "const fs = require('node:fs'), path = require('node:path'), { constants } = fs;",
28
+ "const [uidText, direction, home, login, ...files] = process.argv.slice(1); const uid = Number(uidText);",
29
+ "const fail = (message) => { console.error(message); process.exit(3); };",
30
+ "if (direction !== 'seed' && direction !== 'save') fail('invalid direction');",
31
+ "process.chdir(home); const [from, to] = direction === 'seed' ? [login, '.'] : ['.', login];",
32
+ "if (process.getuid() === 0) {",
33
+ " const root = fs.lstatSync(to); if (!root.isDirectory()) fail('login destination is not a directory');",
34
+ " if (root.uid !== uid || (root.mode & 0o777) !== 0o700) { fs.chmodSync(to, 0o700); fs.chownSync(to, uid, uid); }",
35
+ " process.setgroups([]); process.setgid(uid); process.setuid(uid);",
36
+ "}",
37
+ "const walk = (root, segments, create) => { let current = root; for (const segment of segments) { current = path.join(current, segment);",
38
+ " let entry; try { entry = fs.lstatSync(current); } catch (error) { if (error.code !== 'ENOENT') throw error; if (!create) return null; fs.mkdirSync(current, { mode: 0o700 }); entry = fs.lstatSync(current); }",
39
+ " if (!entry.isDirectory()) fail('login directory is not a directory'); } return current; };",
40
+ "for (const file of files) {",
41
+ " const segments = file.split('/'); if (path.isAbsolute(file) || segments.some((segment) => segment === '' || segment === '.' || segment === '..')) fail('invalid login file path');",
42
+ " if (walk(from, segments.slice(0, -1), false) === null) continue;",
43
+ " let fd; try { fd = fs.openSync(path.join(from, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (error) { if (error.code === 'ENOENT') continue; fail('login file is unreadable or a link'); }",
44
+ " const entry = fs.fstatSync(fd); if (!entry.isFile() || entry.nlink !== 1) fail('login file is not a regular file');",
45
+ " const bytes = fs.readFileSync(fd); fs.closeSync(fd); const mode = entry.mode & 0o777;",
46
+ " const directory = walk(to, segments.slice(0, -1), true), target = path.join(directory, segments.at(-1));",
47
+ " let current = null; try { const existing = fs.lstatSync(target); if (!existing.isFile()) fail('login target is not a regular file'); current = fs.readFileSync(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }",
48
+ " if (current !== null && current.equals(bytes) && (fs.lstatSync(target).mode & 0o777) === mode) { bytes.fill(0); current.fill(0); continue; }",
49
+ " current?.fill(0); const temporary = `${target}.spawnfile-${process.pid}`;",
50
+ " const out = fs.openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, mode);",
51
+ " let offset = 0; while (offset < bytes.length) offset += fs.writeSync(out, bytes, offset, bytes.length - offset, offset);",
52
+ " bytes.fill(0); fs.fchmodSync(out, mode); fs.fsyncSync(out); fs.closeSync(out); fs.renameSync(temporary, target);",
53
+ " const parent = fs.openSync(directory, constants.O_RDONLY); fs.fsyncSync(parent); fs.closeSync(parent);",
54
+ "}"
55
+ ].join("\n");
56
+ const copy = (docker, image, login, direction) => docker([
57
+ "run", "--rm", "--network", "none", "--user", "0:0", "--cap-drop", "ALL",
58
+ // DAC_READ_SEARCH: root enters a home the organization already made its own (0700) to save from it.
59
+ "--cap-add", "CHOWN", "--cap-add", "DAC_READ_SEARCH", "--cap-add", "FOWNER", "--cap-add", "SETUID", "--cap-add", "SETGID",
60
+ "--security-opt", "no-new-privileges", "--entrypoint", "node",
61
+ "--mount", `type=volume,source=${login.loginVolume},target=${LOGIN_ROOT}`,
62
+ // Mounted where the organization mounts it, so a fresh home gets the image's own preimage first.
63
+ "--mount", `type=volume,source=${login.homeVolume},target=${login.homeMountPath}`,
64
+ image, "-e", HOME_LOGIN_PROGRAM, String(DAIMON_ORGANIZATION_UID), direction, login.homeMountPath, LOGIN_ROOT, ...login.files
65
+ ]);
66
+ /** Seeds the profile's login files into the organization's fresh runtime home, before it boots. */
67
+ export const seedHomeLogin = (docker, image, login) => copy(docker, image, login, "seed");
68
+ /** Writes the login files the engine wrote or rewrote (a refreshed token) back to the profile, after the container stops. */
69
+ export const saveHomeLogin = (docker, image, login) => copy(docker, image, login, "save");
@@ -1,4 +1,8 @@
1
1
  export { TRAINING_TRIAL_LINEAGE_NAMESPACE, prepareTrial, type BootedTrial, type PreparedTrial, type TrialOptions, type TrialWake } from "./boot.js";
2
- export { startTrainingGateway, trainingGatewayRealmVolume, type TrainingGateway, type TrainingGatewayAccess, type TrainingGatewayOptions } from "./gateway.js";
2
+ export { startTrainingGateway, trainingGatewayLogins, trainingGatewayRealmVolume, type TrainingGateway, type TrainingGatewayAccess, type TrainingGatewayOptions } from "./gateway.js";
3
+ export { HOME_LOGIN_FILES, saveHomeLogin, seedHomeLogin, type HomeLogin } from "./homeLogin.js";
4
+ export { JUDGE_INSTRUCTIONS, writeJudgeAgent, type JudgeSpec } from "./judge.js";
3
5
  export { createBootedTrialRun, type BootedTrialLauncherOptions, type BootedTrialRun } from "./launcher.js";
4
6
  export { TRIAL_INSTRUCTIONS_FILE, writeTrialProject } from "./project.js";
7
+ export { ensureSubscriptionRealmVolumes, loginSubscriptionRealm, subscriptionRealmBinding, type SubscriptionRealmLoginOptions, type SubscriptionRealmLoginResult } from "./login.js";
8
+ export { createRealmTurns, type RealmTurnOptions, type RealmTurns, type ReleaseRealmTurn } from "./realmTurns.js";
@@ -1,4 +1,8 @@
1
1
  export { TRAINING_TRIAL_LINEAGE_NAMESPACE, prepareTrial } from "./boot.js";
2
- export { startTrainingGateway, trainingGatewayRealmVolume } from "./gateway.js";
2
+ export { startTrainingGateway, trainingGatewayLogins, trainingGatewayRealmVolume } from "./gateway.js";
3
+ export { HOME_LOGIN_FILES, saveHomeLogin, seedHomeLogin } from "./homeLogin.js";
4
+ export { JUDGE_INSTRUCTIONS, writeJudgeAgent } from "./judge.js";
3
5
  export { createBootedTrialRun } from "./launcher.js";
4
6
  export { TRIAL_INSTRUCTIONS_FILE, writeTrialProject } from "./project.js";
7
+ export { ensureSubscriptionRealmVolumes, loginSubscriptionRealm, subscriptionRealmBinding } from "./login.js";
8
+ export { createRealmTurns } from "./realmTurns.js";
@@ -0,0 +1,15 @@
1
+ /** One judge identity of a run: the engine, and the model and effort its compiled organization pins. */
2
+ export interface JudgeSpec {
3
+ engine: string;
4
+ model: string;
5
+ effort?: string;
6
+ }
7
+ /** The judge's system document: one self-contained judgment per wake, answered in the reply alone. */
8
+ export declare const JUDGE_INSTRUCTIONS: string;
9
+ /**
10
+ * Writes the judge's tool-free one-agent Daimon declaration: no skills, no
11
+ * MCP, no memory, no surfaces, no schedule. The model is pinned only where
12
+ * the engine's descriptor makes model selection configurable; otherwise the
13
+ * engine's own default model judges and the requested model is an assumption.
14
+ */
15
+ export declare const writeJudgeAgent: (spec: JudgeSpec, directory: string) => Promise<string>;
@@ -0,0 +1,42 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getDaimonEngineDescriptor } from "../../../runtime/daimon/index.js";
4
+ import { SpawnfileError } from "../../../shared/index.js";
5
+ /** The judge's system document: one self-contained judgment per wake, answered in the reply alone. */
6
+ export const JUDGE_INSTRUCTIONS = [
7
+ "You are a judge in a training run. Every message is one self-contained judging task.",
8
+ "Answer it directly in your reply. Never use a tool, command, file, memory, skill or the network:",
9
+ "a reply produced with any tool activity is discarded.",
10
+ "When the task gives a JSON Schema, reply with exactly one JSON value that matches it and nothing else."
11
+ ].join("\n");
12
+ /**
13
+ * Writes the judge's tool-free one-agent Daimon declaration: no skills, no
14
+ * MCP, no memory, no surfaces, no schedule. The model is pinned only where
15
+ * the engine's descriptor makes model selection configurable; otherwise the
16
+ * engine's own default model judges and the requested model is an assumption.
17
+ */
18
+ export const writeJudgeAgent = async (spec, directory) => {
19
+ const descriptor = getDaimonEngineDescriptor(spec.engine);
20
+ if (!descriptor)
21
+ throw new SpawnfileError("validation_error", `Judge engine ${spec.engine} is not a Daimon engine`);
22
+ const { provider, method } = descriptor.auth;
23
+ const pinned = descriptor.modelSelection.configurable && provider !== null && method !== null;
24
+ if (pinned && spec.effort !== undefined && !descriptor.modelSelection.reasoningEfforts.includes(spec.effort)) {
25
+ throw new SpawnfileError("validation_error", `Judge engine ${spec.engine} does not accept reasoning effort ${spec.effort}`);
26
+ }
27
+ const manifest = {
28
+ spawnfile_version: "0.1",
29
+ kind: "agent",
30
+ name: `judge-${spec.engine}`,
31
+ description: `Tool-free ${spec.engine} training judge`,
32
+ runtime: { name: "daimon", options: { engine: spec.engine } },
33
+ ...(pinned ? { execution: { model: { primary: {
34
+ provider, name: spec.model, auth: { method }, ...(spec.effort === undefined ? {} : { reasoning_effort: spec.effort })
35
+ } } } } : {})
36
+ };
37
+ await mkdir(directory, { recursive: true });
38
+ const manifestPath = path.join(directory, "Spawnfile");
39
+ // JSON is YAML; the trial project re-renders it with the judge instructions as its system document.
40
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
41
+ return manifestPath;
42
+ };
@@ -1,6 +1,8 @@
1
1
  import type { ResolvedAuthProfile } from "../../../auth/index.js";
2
- import { type TrialWake } from "./boot.js";
2
+ import { prepareTrial, type BootedTrial, type TrialWake } from "./boot.js";
3
3
  import { type TrialDocker } from "./docker.js";
4
+ import { type JudgeSpec } from "./judge.js";
5
+ import { type RealmTurns } from "./realmTurns.js";
4
6
  export interface BootedTrialLauncherOptions {
5
7
  /** The subject agent's own Spawnfile. */
6
8
  agentManifestPath: string;
@@ -8,25 +10,38 @@ export interface BootedTrialLauncherOptions {
8
10
  /** Trial projects, compile outputs and boot env files live here, one fresh directory per trial. */
9
11
  scratchRoot: string;
10
12
  docker?: TrialDocker;
13
+ turns?: RealmTurns;
14
+ /** Compiles and builds one organization (tests only). */
15
+ prepare?: typeof prepareTrial;
16
+ /** Bound on a judge's wait for a volume-held login another container holds (default 30 minutes). */
17
+ judgeRealmWaitMs?: number;
11
18
  }
19
+ type Wake = (text: string, options: {
20
+ deliveryId: string;
21
+ timeoutMs: number;
22
+ signal?: AbortSignal;
23
+ }) => Promise<TrialWake>;
24
+ type Booted = Pick<BootedTrial, "container" | "scanCredentials" | "teardown"> & {
25
+ wake: Wake;
26
+ };
12
27
  export interface BootedTrialRun {
13
28
  boot(input: {
14
29
  trialId: string;
15
30
  instructions: string;
16
- }): Promise<{
17
- wake(text: string, options: {
18
- deliveryId: string;
19
- timeoutMs: number;
20
- signal?: AbortSignal;
21
- }): Promise<TrialWake>;
22
- teardown(): Promise<void>;
23
- }>;
24
- /** Stops the run's gateway and removes its image and networks; the realm volume stays. */
31
+ signal?: AbortSignal;
32
+ }): Promise<Booted>;
33
+ /** The run's warm judge for this engine, model and effort: booted once, woken once per judgment. */
34
+ judge(spec: JudgeSpec): Promise<Omit<Booted, "teardown">>;
35
+ /** Tears down every judge, stops the run's gateway and removes its image and networks; the realm volumes stay. */
25
36
  close(): Promise<void>;
26
37
  }
27
38
  /**
28
- * One training run's trials: each boot compiles and starts the subject's
29
- * one-agent organization, and every trial shares the run's single auth
30
- * gateway, started from the first trial's image on first use.
39
+ * One training run's containers: each trial boot compiles and starts the
40
+ * subject's one-agent organization, each judge is a tool-free one-agent
41
+ * organization booted the same way, and all of them share the run's single
42
+ * auth gateway, started from the first image on first use. The gateway holds
43
+ * every gatewayed login of the run's auth profile, so trials and judges on
44
+ * any engine reach it with one key.
31
45
  */
32
46
  export declare const createBootedTrialRun: (options: BootedTrialLauncherOptions) => BootedTrialRun;
47
+ export {};