@markstanden/opencode-test-container 0.0.0-stage → 0.1.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 (121) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -2
  3. package/container/Containerfile +24 -0
  4. package/container/README.md +29 -0
  5. package/dist/adapters/opencode/events.d.ts +90 -0
  6. package/dist/adapters/opencode/events.js +213 -0
  7. package/dist/adapters/opencode/runtime.d.ts +87 -0
  8. package/dist/adapters/opencode/runtime.js +136 -0
  9. package/dist/adapters/podman/container.d.ts +62 -0
  10. package/dist/adapters/podman/container.js +119 -0
  11. package/dist/adapters/podman/image.d.ts +50 -0
  12. package/dist/adapters/podman/image.js +124 -0
  13. package/dist/adapters/process.d.ts +38 -0
  14. package/dist/adapters/process.js +71 -0
  15. package/dist/campaign/assess.d.ts +45 -0
  16. package/dist/campaign/assess.js +208 -0
  17. package/dist/campaign/close.d.ts +17 -0
  18. package/dist/campaign/close.js +112 -0
  19. package/dist/campaign/evidence-guard.d.ts +24 -0
  20. package/dist/campaign/evidence-guard.js +49 -0
  21. package/dist/campaign/freeze.d.ts +94 -0
  22. package/dist/campaign/freeze.js +297 -0
  23. package/dist/campaign/inventory.d.ts +62 -0
  24. package/dist/campaign/inventory.js +192 -0
  25. package/dist/campaign/judge.d.ts +209 -0
  26. package/dist/campaign/judge.js +1285 -0
  27. package/dist/campaign/ledger.d.ts +64 -0
  28. package/dist/campaign/ledger.js +149 -0
  29. package/dist/campaign/prepare.d.ts +129 -0
  30. package/dist/campaign/prepare.js +483 -0
  31. package/dist/campaign/primary.d.ts +96 -0
  32. package/dist/campaign/primary.js +189 -0
  33. package/dist/campaign/run.d.ts +151 -0
  34. package/dist/campaign/run.js +562 -0
  35. package/dist/campaign/status.d.ts +76 -0
  36. package/dist/campaign/status.js +237 -0
  37. package/dist/cli/otc.d.ts +51 -0
  38. package/dist/cli/otc.js +435 -0
  39. package/dist/contracts/canonical.d.ts +21 -0
  40. package/dist/contracts/canonical.js +44 -0
  41. package/dist/contracts/types.d.ts +261 -0
  42. package/dist/contracts/types.js +9 -0
  43. package/dist/contracts/validate.d.ts +72 -0
  44. package/dist/contracts/validate.js +1021 -0
  45. package/dist/contracts/version.d.ts +44 -0
  46. package/dist/contracts/version.js +147 -0
  47. package/dist/evaluators/docs/analysis/accounting.d.ts +140 -0
  48. package/dist/evaluators/docs/analysis/accounting.js +237 -0
  49. package/dist/evaluators/docs/analysis/blinding.d.ts +40 -0
  50. package/dist/evaluators/docs/analysis/blinding.js +80 -0
  51. package/dist/evaluators/docs/analysis/bounds.d.ts +40 -0
  52. package/dist/evaluators/docs/analysis/bounds.js +31 -0
  53. package/dist/evaluators/docs/analysis/budget.d.ts +137 -0
  54. package/dist/evaluators/docs/analysis/budget.js +305 -0
  55. package/dist/evaluators/docs/analysis/closure.d.ts +175 -0
  56. package/dist/evaluators/docs/analysis/closure.js +251 -0
  57. package/dist/evaluators/docs/analysis/decision.d.ts +59 -0
  58. package/dist/evaluators/docs/analysis/decision.js +161 -0
  59. package/dist/evaluators/docs/analysis/freeze.d.ts +201 -0
  60. package/dist/evaluators/docs/analysis/freeze.js +395 -0
  61. package/dist/evaluators/docs/analysis/guardrails.d.ts +51 -0
  62. package/dist/evaluators/docs/analysis/guardrails.js +194 -0
  63. package/dist/evaluators/docs/analysis/journal.d.ts +150 -0
  64. package/dist/evaluators/docs/analysis/journal.js +475 -0
  65. package/dist/evaluators/docs/analysis/ledger.d.ts +78 -0
  66. package/dist/evaluators/docs/analysis/ledger.js +145 -0
  67. package/dist/evaluators/docs/analysis/measures.d.ts +302 -0
  68. package/dist/evaluators/docs/analysis/measures.js +1069 -0
  69. package/dist/evaluators/docs/analysis/model.d.ts +236 -0
  70. package/dist/evaluators/docs/analysis/model.js +740 -0
  71. package/dist/evaluators/docs/analysis/pairing.d.ts +68 -0
  72. package/dist/evaluators/docs/analysis/pairing.js +95 -0
  73. package/dist/evaluators/docs/analysis/records.d.ts +229 -0
  74. package/dist/evaluators/docs/analysis/records.js +935 -0
  75. package/dist/evaluators/docs/analysis/recovery.d.ts +225 -0
  76. package/dist/evaluators/docs/analysis/recovery.js +507 -0
  77. package/dist/evaluators/docs/analysis/render.d.ts +235 -0
  78. package/dist/evaluators/docs/analysis/render.js +566 -0
  79. package/dist/evaluators/docs/analysis/replay.d.ts +15 -0
  80. package/dist/evaluators/docs/analysis/replay.js +1272 -0
  81. package/dist/evaluators/docs/analysis/screening/inventory.d.ts +136 -0
  82. package/dist/evaluators/docs/analysis/screening/inventory.js +307 -0
  83. package/dist/evaluators/docs/analysis/screening/noteVersion.d.ts +15 -0
  84. package/dist/evaluators/docs/analysis/screening/noteVersion.js +46 -0
  85. package/dist/evaluators/docs/analysis/screening/reconcile.d.ts +38 -0
  86. package/dist/evaluators/docs/analysis/screening/reconcile.js +40 -0
  87. package/dist/evaluators/docs/analysis/screening/rules.d.ts +163 -0
  88. package/dist/evaluators/docs/analysis/screening/rules.js +330 -0
  89. package/dist/evaluators/docs/analysis/screening/scan.d.ts +72 -0
  90. package/dist/evaluators/docs/analysis/screening/scan.js +362 -0
  91. package/dist/evaluators/docs/analysis/selector.d.ts +68 -0
  92. package/dist/evaluators/docs/analysis/selector.js +123 -0
  93. package/dist/evaluators/docs/assess.d.ts +129 -0
  94. package/dist/evaluators/docs/assess.js +433 -0
  95. package/dist/evaluators/docs/campaign.d.ts +133 -0
  96. package/dist/evaluators/docs/campaign.js +222 -0
  97. package/dist/evaluators/docs/index.d.ts +25 -0
  98. package/dist/evaluators/docs/index.js +25 -0
  99. package/dist/evaluators/docs/journal.d.ts +150 -0
  100. package/dist/evaluators/docs/journal.js +475 -0
  101. package/dist/evaluators/docs/scoring.d.ts +367 -0
  102. package/dist/evaluators/docs/scoring.js +662 -0
  103. package/dist/evaluators/skills/assess.d.ts +94 -0
  104. package/dist/evaluators/skills/assess.js +207 -0
  105. package/dist/index.d.ts +46 -0
  106. package/dist/index.js +29 -0
  107. package/dist/legacy/prod014.d.ts +86 -0
  108. package/dist/legacy/prod014.js +209 -0
  109. package/dist/reporting/report.d.ts +79 -0
  110. package/dist/reporting/report.js +242 -0
  111. package/docs/architecture.md +106 -0
  112. package/docs/capability-matrix.md +84 -0
  113. package/docs/offline-rehearsal.md +75 -0
  114. package/docs/parity-record.md +81 -0
  115. package/docs/pr-description.md +103 -0
  116. package/docs/schema.md +97 -0
  117. package/examples/skills/README.md +13 -0
  118. package/examples/skills/campaign.json +73 -0
  119. package/examples/skills/skills/otel-helper/SKILL.md +10 -0
  120. package/examples/skills/suite.json +29 -0
  121. package/package.json +45 -5
@@ -0,0 +1,87 @@
1
+ import type { Arm, Campaign } from "../../contracts/types.ts";
2
+ /** One plugin entry handed to OpenCode. */
3
+ export interface PluginEntry {
4
+ /** The package value. */
5
+ package: string;
6
+ /** The options value. */
7
+ options?: unknown;
8
+ }
9
+ /** The OpenCode configuration OTC writes for one arm. */
10
+ export interface OpencodeConfig {
11
+ /** The schema value. */
12
+ $schema: string;
13
+ /** The model value. */
14
+ model: string;
15
+ /** The plugins value. */
16
+ plugins?: PluginEntry[];
17
+ /** The permissions value. */
18
+ permissions?: Record<string, unknown>;
19
+ }
20
+ /**
21
+ * Build the arm's non-secret OpenCode configuration.
22
+ * @param campaign The normalised campaign.
23
+ * @param arm The selected arm.
24
+ * @returns The configuration object written into the read-only config home.
25
+ */
26
+ export declare function opencodeConfig(campaign: Campaign, arm: Arm): OpencodeConfig;
27
+ /**
28
+ * The exact `opencode run` argv. This is the argv recorded in evidence and
29
+ * the argv actually spawned.
30
+ * @param options Model route, title, prompt and logging choice.
31
+ * @returns The complete argv (without the binary).
32
+ * @param options.model The model value.
33
+ * @param options.title The title value.
34
+ * @param options.prompt The prompt value.
35
+ * @param options.printLogs The printLogs value.
36
+ */
37
+ export declare function opencodeRunArgs(options: {
38
+ model: string;
39
+ title: string;
40
+ prompt: string;
41
+ printLogs?: boolean;
42
+ }): string[];
43
+ /**
44
+ * The provider overlay passed inline through `OPENCODE_CONFIG_CONTENT`. Only
45
+ * non-secret routing belongs here; credentials travel by environment name.
46
+ * @param campaign The frozen campaign.
47
+ * @returns JSON text, or null when the campaign declares no overlay.
48
+ */
49
+ export declare function providerOverlay(campaign: Campaign): string | null;
50
+ /**
51
+ * Write the read-only config home for one arm: its OpenCode configuration.
52
+ * Skill bundles are staged into the same tree by the trial runner.
53
+ * @param trialDirectory The trial's private directory.
54
+ * @param campaign The normalised campaign.
55
+ * @param arm The selected arm.
56
+ * @returns The config-home path.
57
+ */
58
+ export declare function writeConfigHome(trialDirectory: string, campaign: Campaign, arm: Arm): string;
59
+ /**
60
+ * Write the judge's neutral config home: no skills, no plugins, only the
61
+ * judge model route. The judge never sees an arm's staged subjects.
62
+ * @param directory The attempt directory.
63
+ * @param campaign The frozen campaign.
64
+ * @param model The judge model route.
65
+ * @returns The config-home path.
66
+ */
67
+ export declare function writeJudgeConfigHome(directory: string, campaign: Campaign, model: string): string;
68
+ /**
69
+ * Environment for one headless session. Ambient OpenCode session identity is
70
+ * removed; credentials and routing arrive only through declared names.
71
+ * @param base The host environment.
72
+ * @param options Config home, trial data/cache directories, overlay and names
73
+ * whose values are passed through.
74
+ * @returns A fresh environment object.
75
+ * @param options.configHome The configHome value.
76
+ * @param options.dataHome The dataHome value.
77
+ * @param options.cacheHome The cacheHome value.
78
+ * @param options.overlay The overlay value.
79
+ * @param options.passthrough The passthrough value.
80
+ */
81
+ export declare function sessionEnv(base: NodeJS.ProcessEnv, options: {
82
+ configHome: string;
83
+ dataHome: string;
84
+ cacheHome: string;
85
+ overlay: string | null;
86
+ passthrough: readonly string[];
87
+ }): NodeJS.ProcessEnv;
@@ -0,0 +1,136 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Build the arm's non-secret OpenCode configuration.
5
+ * @param campaign The normalised campaign.
6
+ * @param arm The selected arm.
7
+ * @returns The configuration object written into the read-only config home.
8
+ */
9
+ export function opencodeConfig(campaign, arm) {
10
+ const config = {
11
+ $schema: "https://opencode.ai/config.json",
12
+ model: campaign.runtime.provider.model,
13
+ };
14
+ if (arm.plugins !== undefined && arm.plugins.length > 0) {
15
+ config.plugins = arm.plugins.map((plugin) => ({
16
+ package: `/subjects/plugins/${plugin.id}`,
17
+ ...(plugin.options === undefined
18
+ ? {}
19
+ : { options: plugin.options }),
20
+ }));
21
+ }
22
+ if (campaign.permissions !== undefined) {
23
+ config.permissions = campaign.permissions;
24
+ }
25
+ return config;
26
+ }
27
+ /**
28
+ * The exact `opencode run` argv. This is the argv recorded in evidence and
29
+ * the argv actually spawned.
30
+ * @param options Model route, title, prompt and logging choice.
31
+ * @returns The complete argv (without the binary).
32
+ * @param options.model The model value.
33
+ * @param options.title The title value.
34
+ * @param options.prompt The prompt value.
35
+ * @param options.printLogs The printLogs value.
36
+ */
37
+ export function opencodeRunArgs(options) {
38
+ return [
39
+ "run",
40
+ "--standalone",
41
+ "--auto",
42
+ "--archived",
43
+ "--title",
44
+ options.title,
45
+ "--model",
46
+ options.model,
47
+ "--format",
48
+ "json",
49
+ ...(options.printLogs === true ? ["--print-logs"] : []),
50
+ options.prompt,
51
+ ];
52
+ }
53
+ /**
54
+ * The provider overlay passed inline through `OPENCODE_CONFIG_CONTENT`. Only
55
+ * non-secret routing belongs here; credentials travel by environment name.
56
+ * @param campaign The frozen campaign.
57
+ * @returns JSON text, or null when the campaign declares no overlay.
58
+ */
59
+ export function providerOverlay(campaign) {
60
+ const overlay = campaign.runtime.provider.overlay;
61
+ return overlay === undefined ? null : JSON.stringify(overlay);
62
+ }
63
+ /**
64
+ * Write the read-only config home for one arm: its OpenCode configuration.
65
+ * Skill bundles are staged into the same tree by the trial runner.
66
+ * @param trialDirectory The trial's private directory.
67
+ * @param campaign The normalised campaign.
68
+ * @param arm The selected arm.
69
+ * @returns The config-home path.
70
+ */
71
+ export function writeConfigHome(trialDirectory, campaign, arm) {
72
+ const configHome = join(trialDirectory, "config-home");
73
+ const opencode = join(configHome, "opencode");
74
+ mkdirSync(join(opencode, "skills"), { recursive: true });
75
+ writeFileSync(join(opencode, "opencode.json"), `${JSON.stringify(opencodeConfig(campaign, arm), null, 2)}\n`, "utf8");
76
+ return configHome;
77
+ }
78
+ /**
79
+ * Write the judge's neutral config home: no skills, no plugins, only the
80
+ * judge model route. The judge never sees an arm's staged subjects.
81
+ * @param directory The attempt directory.
82
+ * @param campaign The frozen campaign.
83
+ * @param model The judge model route.
84
+ * @returns The config-home path.
85
+ */
86
+ export function writeJudgeConfigHome(directory, campaign, model) {
87
+ const configHome = join(directory, "config-home");
88
+ const opencode = join(configHome, "opencode");
89
+ mkdirSync(opencode, { recursive: true });
90
+ writeFileSync(join(opencode, "opencode.json"), `${JSON.stringify({
91
+ $schema: "https://opencode.ai/config.json",
92
+ model,
93
+ ...(campaign.permissions === undefined
94
+ ? {}
95
+ : { permissions: campaign.permissions }),
96
+ }, null, 2)}\n`, "utf8");
97
+ return configHome;
98
+ }
99
+ /**
100
+ * Environment for one headless session. Ambient OpenCode session identity is
101
+ * removed; credentials and routing arrive only through declared names.
102
+ * @param base The host environment.
103
+ * @param options Config home, trial data/cache directories, overlay and names
104
+ * whose values are passed through.
105
+ * @returns A fresh environment object.
106
+ * @param options.configHome The configHome value.
107
+ * @param options.dataHome The dataHome value.
108
+ * @param options.cacheHome The cacheHome value.
109
+ * @param options.overlay The overlay value.
110
+ * @param options.passthrough The passthrough value.
111
+ */
112
+ export function sessionEnv(base, options) {
113
+ const env = { ...base };
114
+ for (const key of [
115
+ "OPENCODE_SESSION_ID",
116
+ "OPENCODE_TERMINAL",
117
+ "OPENCODE",
118
+ "AI_AGENT",
119
+ "OPENCODE_CONFIG_CONTENT",
120
+ ]) {
121
+ delete env[key];
122
+ }
123
+ env.HOME = "/tmp/agent-home";
124
+ env.XDG_CONFIG_HOME = options.configHome;
125
+ env.XDG_DATA_HOME = options.dataHome;
126
+ env.XDG_CACHE_HOME = options.cacheHome;
127
+ if (options.overlay !== null) {
128
+ env.OPENCODE_CONFIG_CONTENT = options.overlay;
129
+ }
130
+ for (const name of options.passthrough) {
131
+ const value = base[name];
132
+ if (value !== undefined)
133
+ env[name] = value;
134
+ }
135
+ return env;
136
+ }
@@ -0,0 +1,62 @@
1
+ import { runCommand, type CommandResult } from "../process.ts";
2
+ /** One bind mount in a trial sandbox. */
3
+ export interface Mount {
4
+ /** The source value. */
5
+ source: string;
6
+ /** The target value. */
7
+ target: string;
8
+ /** The read only value. */
9
+ readOnly: boolean;
10
+ }
11
+ /** The complete sandbox plan for one trial. */
12
+ export interface ContainerPlan {
13
+ /** The image id value. */
14
+ imageId: string;
15
+ /** The mounts value. */
16
+ mounts: Mount[];
17
+ /** The workdir value. */
18
+ workdir: string;
19
+ /** The network value. */
20
+ network: "host" | "none";
21
+ /** The argv value. */
22
+ argv: string[];
23
+ /** Environment names passed by reference; values never enter argv. */
24
+ envNames: string[];
25
+ /** A 0600 env file read by Podman; values never enter argv. */
26
+ envFile: string | null;
27
+ /** The timeout ms value. */
28
+ timeoutMs: number;
29
+ }
30
+ /**
31
+ * Validate a mount plan before it reaches Podman: absolute targets, existing
32
+ * sources, unique targets, and no read-write mount over a read-only one.
33
+ * @param mounts The proposed mounts.
34
+ * @returns Problems; empty means the plan is accepted.
35
+ */
36
+ export declare function mountProblems(mounts: readonly Mount[]): string[];
37
+ /**
38
+ * The exact Podman argv for one trial. `--userns=keep-id`, a read-only root,
39
+ * no new privileges, dropped capabilities and a bounded /tmp are the sandbox
40
+ * floor; network mode is declared honestly, because host networking is not
41
+ * provider-only isolation.
42
+ * @param plan The validated plan.
43
+ * @returns The complete argv after `podman`.
44
+ */
45
+ export declare function containerArgv(plan: ContainerPlan): string[];
46
+ /**
47
+ * Run one trial container.
48
+ * @param plan The validated plan.
49
+ * @param run Process adapter, injected for tests.
50
+ * @returns The bounded process result.
51
+ */
52
+ export declare function runContainer(plan: ContainerPlan, run?: typeof runCommand): Promise<CommandResult>;
53
+ /** The container-side workspace path. */
54
+ export declare const CONTAINER_WORKSPACE = "/workspace";
55
+ /** The container-side config home. */
56
+ export declare const CONTAINER_CONFIG_HOME = "/config-home";
57
+ /** The container-side subject root. */
58
+ export declare const CONTAINER_SUBJECTS = "/subjects";
59
+ /** The container-side journal directory, when docs instrumentation is on. */
60
+ export declare const CONTAINER_JOURNAL = "/journal";
61
+ /** Resolve and normalise all mount sources. */
62
+ export declare function normaliseMounts(mounts: readonly Mount[]): Mount[];
@@ -0,0 +1,119 @@
1
+ import { existsSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
+ import { runCommand } from "../process.js";
4
+ /** Check one proposed mount's path syntax and existence. */
5
+ function mountEntryProblems(mount) {
6
+ const problems = [];
7
+ if (!isAbsolute(mount.source) || !isAbsolute(mount.target)) {
8
+ problems.push(`mount ${mount.source} -> ${mount.target} must use absolute paths`);
9
+ }
10
+ if (!existsSync(mount.source)) {
11
+ problems.push(`mount source ${mount.source} does not exist`);
12
+ }
13
+ if (mount.target.includes(",")) {
14
+ problems.push(`mount target ${mount.target} may not contain a comma`);
15
+ }
16
+ return problems;
17
+ }
18
+ /** Check for a read-write mount nested under a read-only target. */
19
+ function nestedMountProblems(targets) {
20
+ const readOnly = [...targets.entries()]
21
+ .filter(([, value]) => value)
22
+ .map(([target]) => target);
23
+ const problems = [];
24
+ for (const [target, value] of targets) {
25
+ if (value)
26
+ continue;
27
+ if (readOnly.some((parent) => target.startsWith(`${parent}/`))) {
28
+ problems.push(`read-write mount ${target} sits under a read-only mount`);
29
+ }
30
+ }
31
+ return problems;
32
+ }
33
+ /**
34
+ * Validate a mount plan before it reaches Podman: absolute targets, existing
35
+ * sources, unique targets, and no read-write mount over a read-only one.
36
+ * @param mounts The proposed mounts.
37
+ * @returns Problems; empty means the plan is accepted.
38
+ */
39
+ export function mountProblems(mounts) {
40
+ const problems = mounts.flatMap(mountEntryProblems);
41
+ const targets = new Map();
42
+ for (const mount of mounts) {
43
+ if (targets.has(mount.target)) {
44
+ problems.push(`duplicate mount target ${mount.target}`);
45
+ }
46
+ targets.set(mount.target, mount.readOnly);
47
+ }
48
+ problems.push(...nestedMountProblems(targets));
49
+ return problems;
50
+ }
51
+ /**
52
+ * The exact Podman argv for one trial. `--userns=keep-id`, a read-only root,
53
+ * no new privileges, dropped capabilities and a bounded /tmp are the sandbox
54
+ * floor; network mode is declared honestly, because host networking is not
55
+ * provider-only isolation.
56
+ * @param plan The validated plan.
57
+ * @returns The complete argv after `podman`.
58
+ */
59
+ export function containerArgv(plan) {
60
+ const args = [
61
+ "run",
62
+ "--rm",
63
+ "--network",
64
+ plan.network,
65
+ "--userns=keep-id",
66
+ "--read-only",
67
+ "--cap-drop=ALL",
68
+ "--security-opt=no-new-privileges",
69
+ "--tmpfs",
70
+ "/tmp:rw,nosuid,size=768m",
71
+ ];
72
+ for (const mount of plan.mounts) {
73
+ args.push("--mount", `type=bind,src=${mount.source},dst=${mount.target}${mount.readOnly ? ",ro" : ""}`);
74
+ }
75
+ if (plan.envFile !== null) {
76
+ args.push("--env-file", plan.envFile);
77
+ }
78
+ args.push("--workdir", plan.workdir, plan.imageId, ...plan.argv);
79
+ return args;
80
+ }
81
+ /**
82
+ * Run one trial container.
83
+ * @param plan The validated plan.
84
+ * @param run Process adapter, injected for tests.
85
+ * @returns The bounded process result.
86
+ */
87
+ export async function runContainer(plan, run = runCommand) {
88
+ const problems = mountProblems(plan.mounts);
89
+ if (problems.length > 0) {
90
+ return {
91
+ status: null,
92
+ signal: null,
93
+ stdout: "",
94
+ stderr: problems.join("; "),
95
+ timedOut: false,
96
+ error: "invalid mount plan",
97
+ };
98
+ }
99
+ return run("podman", containerArgv(plan), {
100
+ timeoutMs: plan.timeoutMs,
101
+ maxBuffer: 64 * 1024 * 1024,
102
+ });
103
+ }
104
+ /** The container-side workspace path. */
105
+ export const CONTAINER_WORKSPACE = "/workspace";
106
+ /** The container-side config home. */
107
+ export const CONTAINER_CONFIG_HOME = "/config-home";
108
+ /** The container-side subject root. */
109
+ export const CONTAINER_SUBJECTS = "/subjects";
110
+ /** The container-side journal directory, when docs instrumentation is on. */
111
+ export const CONTAINER_JOURNAL = "/journal";
112
+ /** Resolve and normalise all mount sources. */
113
+ export function normaliseMounts(mounts) {
114
+ return mounts.map((mount) => ({
115
+ ...mount,
116
+ source: resolve(mount.source),
117
+ target: resolve(mount.target),
118
+ }));
119
+ }
@@ -0,0 +1,50 @@
1
+ import { runCommand, type CommandResult } from "../process.ts";
2
+ /** The recorded identity and CLI version of one runtime image. */
3
+ export interface ResolvedImage {
4
+ /** The image value. */
5
+ image: string;
6
+ /** The image id value. */
7
+ imageId: string;
8
+ /** The opencode version value. */
9
+ opencodeVersion: string;
10
+ /** The container cli value. */
11
+ containerCli: string;
12
+ }
13
+ /** An image inspection, or the observed refusal. */
14
+ export type ImageResolution = {
15
+ ok: true;
16
+ image: ResolvedImage;
17
+ } | {
18
+ ok: false;
19
+ problem: string;
20
+ };
21
+ /** Locate the package's `container/` directory from source or dist. */
22
+ export declare function containerDirectory(): string;
23
+ /** Extract a version from `opencode --version` output. */
24
+ export declare function parseOpencodeVersion(stdout: string): string | null;
25
+ /**
26
+ * Resolve a runtime image to the immutable ID and the OpenCode version it
27
+ * actually contains. A mutable tag alone is never accepted as frozen state.
28
+ * @param image The image name or ID.
29
+ * @param declaredVersion The version the campaign declared.
30
+ * @param run The process adapter, injected for tests.
31
+ * @returns The resolution, or the refusal.
32
+ */
33
+ export declare function resolveImage(image: string, declaredVersion: string, run?: typeof runCommand): Promise<ImageResolution>;
34
+ /**
35
+ * Build the sandbox image from the packaged recipe with explicit inputs. The
36
+ * recipe does not float: both Node and the OpenCode CLI are build arguments.
37
+ * @param options The build inputs.
38
+ * @param options.tag The image tag.
39
+ * @param options.nodeBase The pinned Node base image.
40
+ * @param options.opencodeVersion The pinned OpenCode CLI version.
41
+ * @param run The process adapter.
42
+ * @returns The build result.
43
+ */
44
+ export declare function buildImage(options: {
45
+ tag: string;
46
+ nodeBase: string;
47
+ opencodeVersion: string;
48
+ }, run?: typeof runCommand): Promise<CommandResult>;
49
+ /** Read the packaged Containerfile; useful for delivery checks and docs. */
50
+ export declare function containerfileText(): string;
@@ -0,0 +1,124 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { runCommand } from "../process.js";
5
+ /** Locate the package's `container/` directory from source or dist. */
6
+ export function containerDirectory() {
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ for (const candidate of [
9
+ resolve(here, "../../../container"),
10
+ resolve(here, "../../container"),
11
+ resolve(process.cwd(), "container"),
12
+ ]) {
13
+ if (existsSync(join(candidate, "Containerfile")))
14
+ return candidate;
15
+ }
16
+ return resolve(process.cwd(), "container");
17
+ }
18
+ /** Extract a version from `opencode --version` output. */
19
+ export function parseOpencodeVersion(stdout) {
20
+ const match = /(?:opencode\s+)?v?([0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,6}(?:[-+][0-9A-Za-z.-]{1,40})?)/.exec(stdout.trim());
21
+ return match?.[1] ?? null;
22
+ }
23
+ /** Resolve a tag to a normalised immutable image id. */
24
+ async function inspectImageId(image, run) {
25
+ const inspect = await run("podman", ["image", "inspect", "--format", "{{.Id}}", image], { timeoutMs: 30_000 });
26
+ if (inspect.status !== 0) {
27
+ return {
28
+ ok: false,
29
+ problem: `cannot inspect image ${image}: ${inspect.stderr.trim() || inspect.error || "unknown error"}`,
30
+ };
31
+ }
32
+ const raw = inspect.stdout.trim();
33
+ if (!/^(sha256:)?[0-9a-f]{12,64}$/i.test(raw)) {
34
+ return {
35
+ ok: false,
36
+ problem: `image ${image} did not report an immutable id`,
37
+ };
38
+ }
39
+ return { ok: true, id: raw.startsWith("sha256:") ? raw : `sha256:${raw}` };
40
+ }
41
+ /** Read the OpenCode version the image actually contains. */
42
+ async function installedVersion(imageId, run) {
43
+ const version = await run("podman", [
44
+ "run",
45
+ "--rm",
46
+ "--network",
47
+ "none",
48
+ "--entrypoint",
49
+ "opencode",
50
+ imageId,
51
+ "--version",
52
+ ], { timeoutMs: 60_000 });
53
+ const observed = parseOpencodeVersion(version.stdout + version.stderr);
54
+ if (version.status !== 0 || observed === null) {
55
+ return {
56
+ ok: false,
57
+ problem: `image ${imageId} did not report an OpenCode version: ${version.stderr.trim() || version.error || "no output"}`,
58
+ };
59
+ }
60
+ return { ok: true, version: observed };
61
+ }
62
+ /**
63
+ * Resolve a runtime image to the immutable ID and the OpenCode version it
64
+ * actually contains. A mutable tag alone is never accepted as frozen state.
65
+ * @param image The image name or ID.
66
+ * @param declaredVersion The version the campaign declared.
67
+ * @param run The process adapter, injected for tests.
68
+ * @returns The resolution, or the refusal.
69
+ */
70
+ export async function resolveImage(image, declaredVersion, run = runCommand) {
71
+ const inspected = await inspectImageId(image, run);
72
+ if (!inspected.ok)
73
+ return inspected;
74
+ const installed = await installedVersion(inspected.id, run);
75
+ if (!installed.ok)
76
+ return installed;
77
+ if (installed.version !== declaredVersion) {
78
+ return {
79
+ ok: false,
80
+ problem: `image ${inspected.id} contains OpenCode ${installed.version}, campaign declares ${declaredVersion}`,
81
+ };
82
+ }
83
+ return {
84
+ ok: true,
85
+ image: {
86
+ image,
87
+ imageId: inspected.id,
88
+ opencodeVersion: installed.version,
89
+ containerCli: "podman",
90
+ },
91
+ };
92
+ }
93
+ /**
94
+ * Build the sandbox image from the packaged recipe with explicit inputs. The
95
+ * recipe does not float: both Node and the OpenCode CLI are build arguments.
96
+ * @param options The build inputs.
97
+ * @param options.tag The image tag.
98
+ * @param options.nodeBase The pinned Node base image.
99
+ * @param options.opencodeVersion The pinned OpenCode CLI version.
100
+ * @param run The process adapter.
101
+ * @returns The build result.
102
+ */
103
+ export async function buildImage(options, run = runCommand) {
104
+ return run("podman", [
105
+ "build",
106
+ "--network",
107
+ "host",
108
+ "--build-arg",
109
+ `NODE_BASE=${options.nodeBase}`,
110
+ "--build-arg",
111
+ `OPENCODE_VERSION=${options.opencodeVersion}`,
112
+ "--build-arg",
113
+ `HOST_UID=${process.getuid?.() ?? 1000}`,
114
+ "--build-arg",
115
+ `HOST_GID=${process.getgid?.() ?? 1000}`,
116
+ "-t",
117
+ options.tag,
118
+ containerDirectory(),
119
+ ], { timeoutMs: 15 * 60_000 });
120
+ }
121
+ /** Read the packaged Containerfile; useful for delivery checks and docs. */
122
+ export function containerfileText() {
123
+ return readFileSync(join(containerDirectory(), "Containerfile"), "utf8");
124
+ }
@@ -0,0 +1,38 @@
1
+ /** The bounded result of one child process. */
2
+ export interface CommandResult {
3
+ /** The status value. */
4
+ status: number | null;
5
+ /** The signal value. */
6
+ signal: NodeJS.Signals | null;
7
+ /** The stdout value. */
8
+ stdout: string;
9
+ /** The stderr value. */
10
+ stderr: string;
11
+ /** The timed out value. */
12
+ timedOut: boolean;
13
+ /** The error value. */
14
+ error: string | null;
15
+ }
16
+ /** Limits for one process call. */
17
+ export interface CommandOptions {
18
+ /** The cwd value. */
19
+ cwd?: string;
20
+ /** The env value. */
21
+ env?: NodeJS.ProcessEnv;
22
+ /** The timeout ms value. */
23
+ timeoutMs?: number;
24
+ /** The max buffer value. */
25
+ maxBuffer?: number;
26
+ /** The stdin value. */
27
+ stdin?: "ignore" | "pipe";
28
+ }
29
+ /**
30
+ * Run one child process without a shell, bounded in time and output. This is
31
+ * the only place OTC spawns a process; adapters inject it so ordinary tests
32
+ * never need Podman or OpenCode.
33
+ * @param command The executable.
34
+ * @param args Its argv, verbatim.
35
+ * @param options Working directory, environment, timeout and capture cap.
36
+ * @returns The bounded result; a spawn failure is data, not an exception.
37
+ */
38
+ export declare function runCommand(command: string, args: readonly string[], options?: CommandOptions): Promise<CommandResult>;
@@ -0,0 +1,71 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Run one child process without a shell, bounded in time and output. This is
4
+ * the only place OTC spawns a process; adapters inject it so ordinary tests
5
+ * never need Podman or OpenCode.
6
+ * @param command The executable.
7
+ * @param args Its argv, verbatim.
8
+ * @param options Working directory, environment, timeout and capture cap.
9
+ * @returns The bounded result; a spawn failure is data, not an exception.
10
+ */
11
+ export function runCommand(command, args, options = {}) {
12
+ const timeoutMs = options.timeoutMs ?? 120_000;
13
+ const maxBuffer = options.maxBuffer ?? 64 * 1024 * 1024;
14
+ return new Promise((resolvePromise) => {
15
+ let settled = false;
16
+ let timedOut = false;
17
+ let stdout = "";
18
+ let stderr = "";
19
+ let timer = null;
20
+ const finish = (result) => {
21
+ if (settled)
22
+ return;
23
+ settled = true;
24
+ if (timer !== null)
25
+ clearTimeout(timer);
26
+ resolvePromise({
27
+ status: result.status ?? null,
28
+ signal: result.signal ?? null,
29
+ stdout: result.stdout ?? stdout,
30
+ stderr: result.stderr ?? stderr,
31
+ timedOut,
32
+ error: result.error ?? null,
33
+ });
34
+ };
35
+ let child;
36
+ try {
37
+ child = spawn(command, [...args], {
38
+ cwd: options.cwd,
39
+ env: options.env,
40
+ stdio: [options.stdin ?? "ignore", "pipe", "pipe"],
41
+ });
42
+ }
43
+ catch (error) {
44
+ finish({
45
+ error: error instanceof Error ? error.message : String(error),
46
+ });
47
+ return;
48
+ }
49
+ const append = (stream, chunk) => {
50
+ if (stream === "out") {
51
+ if (stdout.length < maxBuffer)
52
+ stdout += String(chunk);
53
+ }
54
+ else if (stderr.length < maxBuffer) {
55
+ stderr += String(chunk);
56
+ }
57
+ };
58
+ child.stdout?.on("data", (chunk) => append("out", chunk));
59
+ child.stderr?.on("data", (chunk) => append("err", chunk));
60
+ child.on("error", (error) => finish({ error: error.message }));
61
+ timer = setTimeout(() => {
62
+ timedOut = true;
63
+ child.kill("SIGTERM");
64
+ setTimeout(() => {
65
+ if (!settled)
66
+ child.kill("SIGKILL");
67
+ }, 2_000);
68
+ }, timeoutMs);
69
+ child.on("close", (status, signal) => finish({ status, signal, stdout, stderr }));
70
+ });
71
+ }