Skip to content

Commit 6e0d68b

Browse files
phodalcodex
andcommitted
feat(studio): load external artifact provider modules
Implement docs/specs/2026-08-25-external-artifact-provider-modules.md by loading explicit operator-provisioned factories through the existing receipt and activation boundary. Validated with Node 24 Studio build, 270 unit tests, focused module tests, hosted Playwright, and the Homology cross-repository browser E2E. Co-authored-by: Codex (GPT 5.6 Sol) <codex@openai.com>
1 parent 33b7f08 commit 6e0d68b

9 files changed

Lines changed: 239 additions & 1 deletion

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# External Artifact provider modules
2+
3+
## Traceability
4+
5+
- Spec ID: external-artifact-provider-modules
6+
- Status: Implemented
7+
8+
## Intent
9+
10+
Let an operator run a separately published Artifact Provider in Harness Studio
11+
without copying its renderer or adapter into Studio. The host owns module
12+
loading and Provider validation; the external package owns format semantics and
13+
its receipt-bound implementation.
14+
15+
## Acceptance Scenarios
16+
17+
- AC-1: Repeated `--artifact-provider-module <specifier>` options load the
18+
module's `createArtifactProvider()` result and inject it through Studio's
19+
existing external Provider registry.
20+
- AC-2: Relative module paths resolve from `--cwd`, package specifiers resolve
21+
through Node, and URL/builtin/empty/duplicate/over-budget inputs fail before
22+
Studio opens a port.
23+
- AC-3: A real published Homology Notebook Provider can be activated and render
24+
an `.ipynb` through the generic hosted surface; Studio does not import the
25+
Provider's private renderer or adapter files.
26+
- AC-4: Help, TypeScript build, focused unit/server tests, and a clean packed
27+
cross-repository consumer prove the integration boundary.
28+
29+
## Non-goals
30+
31+
- Downloading or installing Provider packages from the Studio UI.
32+
- Granting an untrusted module a sandbox; module loading is an explicit
33+
operator-authorized local-code action.
34+
- Moving Notebook or kernel execution semantics into Better Harness.
35+
- Automatically activating a contribution without the existing fingerprint-
36+
bound activation record.
37+
38+
## Plan and Tasks
39+
40+
- Add a bounded Node module loader beside Artifact Provider discovery.
41+
- Add a repeatable CLI option and pass loaded Providers through the existing
42+
`artifactProviders` embedding seam.
43+
- Declare the Homology Provider as an optional peer integration after it has a
44+
public npm identity; keep Studio independently runnable without it.
45+
- Test parsing, path/package resolution, failure closure, registry status, and
46+
the real cross-repository package lane.
47+
48+
## Test and Review Evidence
49+
50+
- AC-1/AC-2: focused Vitest for loader and CLI behavior.
51+
- AC-3: Homology clean-consumer tarballs plus Harness Studio server/browser E2E.
52+
- AC-4: Node 24 package build/test/pack and Review Readiness Check.
53+
- Risk: loaded modules execute with the Studio process authority. The CLI help
54+
and spec identify this as an operator-provisioned local-code boundary; no
55+
browser-controlled module specifier is accepted.
56+
- Implemented evidence: Node 24 Studio build and 46 files/270 Vitest tests;
57+
generic external-host Playwright 1/1; Homology's packed-provider browser E2E
58+
loaded the public factory, verified/activated its receipt, rendered Markdown
59+
plus stored output, and reported 0 console/page errors.

package-lock.json

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/harness-studio/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,20 @@ are part of build/cache identity. They do not expand the package allowlist.
103103
Injected Provider receipts are fingerprint-checked, and an inactive or changed
104104
fingerprint never enters selection.
105105

106+
The standalone CLI accepts the same boundary without importing Provider-private
107+
files. Install the Provider beside Studio, then name its public module
108+
explicitly (loading it executes trusted local code):
109+
110+
```bash
111+
harness-studio \
112+
--artifact-provider-module @homology/integration-harness-notebook-provider \
113+
--artifacts ./artifacts
114+
```
115+
116+
The module must export `createArtifactProvider()`. Contributions remain inactive
117+
until `harness-studio artifact-provider activate` records the exact Provider
118+
fingerprint and matcher.
119+
106120
## Architecture
107121

108122
```text

packages/harness-studio/package.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@
6161
"react-arborist": "^3.16.0",
6262
"react-dom": "19.2.8"
6363
},
64+
"peerDependencies": {
65+
"@homology/integration-harness-notebook-provider": "0.1.0"
66+
},
67+
"peerDependenciesMeta": {
68+
"@homology/integration-harness-notebook-provider": {
69+
"optional": true
70+
}
71+
},
6472
"devDependencies": {
6573
"@playwright/test": "^1.62.1",
6674
"@types/node": "26.2.0",

packages/harness-studio/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,7 @@ export {
4646
resolveArtifactCompileLimits,
4747
type ArtifactCompileLimits,
4848
} from "./server/artifacts/registry/artifact-compile-runtime.js";
49+
export {
50+
loadArtifactProviderModules,
51+
providerModuleTarget,
52+
} from "./server/artifacts/registry/artifact-provider-modules.js";
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { isAbsolute, resolve } from "node:path";
2+
import { pathToFileURL } from "node:url";
3+
import type { ExternalArtifactProvider } from "../../../contracts/artifact.js";
4+
5+
const MAX_PROVIDER_MODULES = 16;
6+
const PACKAGE_SPECIFIER = /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/u;
7+
8+
interface ArtifactProviderModule {
9+
readonly createArtifactProvider?: () => ExternalArtifactProvider | Promise<ExternalArtifactProvider>;
10+
}
11+
12+
/** Load explicit operator-provisioned modules before Studio opens a port. */
13+
export async function loadArtifactProviderModules(
14+
specifiers: readonly string[],
15+
cwd: string,
16+
): Promise<readonly ExternalArtifactProvider[]> {
17+
if (specifiers.length > MAX_PROVIDER_MODULES) {
18+
throw new Error(`At most ${MAX_PROVIDER_MODULES} Artifact Provider modules may be loaded.`);
19+
}
20+
const seen = new Set<string>();
21+
const providers: ExternalArtifactProvider[] = [];
22+
for (const specifier of specifiers) {
23+
const target = providerModuleTarget(specifier, cwd);
24+
if (seen.has(target)) throw new Error(`Artifact Provider module '${specifier}' was supplied more than once.`);
25+
seen.add(target);
26+
const loaded: unknown = await import(target);
27+
if (loaded === null || typeof loaded !== "object") {
28+
throw new Error(`Artifact Provider module '${specifier}' has no module exports.`);
29+
}
30+
const factory = (loaded as ArtifactProviderModule).createArtifactProvider;
31+
if (typeof factory !== "function") {
32+
throw new Error(`Artifact Provider module '${specifier}' must export createArtifactProvider().`);
33+
}
34+
const provider = await factory();
35+
if (provider === null || typeof provider !== "object") {
36+
throw new Error(`Artifact Provider module '${specifier}' returned an invalid Provider.`);
37+
}
38+
providers.push(provider);
39+
}
40+
return Object.freeze(providers);
41+
}
42+
43+
export function providerModuleTarget(specifier: string, cwd: string): string {
44+
if (specifier.trim() !== specifier || specifier.length === 0) {
45+
throw new Error("Artifact Provider module specifiers must be non-empty and contain no surrounding whitespace.");
46+
}
47+
if (specifier.startsWith(".") || isAbsolute(specifier)) {
48+
return pathToFileURL(resolve(cwd, specifier)).href;
49+
}
50+
if (!PACKAGE_SPECIFIER.test(specifier)) {
51+
throw new Error(`Artifact Provider module '${specifier}' must be a package name or filesystem path.`);
52+
}
53+
return specifier;
54+
}

packages/harness-studio/src/server/cli.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { readSourceCatalogFile } from "./workspace/source-catalog.js";
77
import { runWalnutBootstrapCli } from "./providers/walnut/cli.js";
88
import { runArtifactProviderCli } from "./artifacts/registry/artifact-provider-cli.js";
99
import { createBundledAgentCustomizationCollector } from "./customization-collector.js";
10+
import { loadArtifactProviderModules } from "./artifacts/registry/artifact-provider-modules.js";
1011

1112
const HELP = `harness-studio — local studio for harness runs and compare evidence
1213
@@ -39,6 +40,9 @@ Options:
3940
Prebuilt Canvas SDK media directory
4041
--provider-state <dir>
4142
Studio-private external provider activation state
43+
--artifact-provider-module <specifier>
44+
Repeatable operator-provisioned module exporting
45+
createArtifactProvider() (executes trusted local code)
4246
--walnut-cache <dir> Studio-owned Walnut cache root
4347
--source-catalog <file>
4448
JSON catalog of bounded switchable Studio inputs
@@ -99,6 +103,7 @@ interface ParsedArgs {
99103
canvasSdkRoot?: string;
100104
canvasSdkMedia?: string;
101105
providerState?: string;
106+
artifactProviderModules: string[];
102107
walnutCache?: string;
103108
sourceCatalog?: string;
104109
port: number;
@@ -111,7 +116,14 @@ interface ParsedArgs {
111116
}
112117

113118
export function parseHarnessStudioArgs(argv: string[]): ParsedArgs {
114-
const parsed: ParsedArgs = { port: 3311, host: "127.0.0.1", allowRemote: false, help: false, acpArgs: [] };
119+
const parsed: ParsedArgs = {
120+
port: 3311,
121+
host: "127.0.0.1",
122+
allowRemote: false,
123+
help: false,
124+
acpArgs: [],
125+
artifactProviderModules: [],
126+
};
115127
for (let index = 0; index < argv.length; index += 1) {
116128
const arg = argv[index];
117129
const takeValue = (): string | undefined => {
@@ -176,6 +188,12 @@ export function parseHarnessStudioArgs(argv: string[]): ParsedArgs {
176188
case "--provider-state":
177189
parsed.providerState = takeValue();
178190
break;
191+
case "--artifact-provider-module": {
192+
const value = takeValue();
193+
if (value === undefined) parsed.error = "--artifact-provider-module requires a package name or filesystem path.";
194+
else parsed.artifactProviderModules.push(value);
195+
break;
196+
}
179197
case "--walnut-cache":
180198
parsed.walnutCache = takeValue();
181199
break;
@@ -243,6 +261,16 @@ export async function runHarnessStudioCli(argv: string[], io: HarnessStudioCliIo
243261
// Skills are conventionally declared relative to their `.harness` file (see
244262
// examples/*.harness), so loading one without a flag still delivers them.
245263
const sourceRoot = resolveHarnessStudioSourceRoot(parsed.harness, parsed.sourceRoot);
264+
let artifactProviders;
265+
try {
266+
artifactProviders = await loadArtifactProviderModules(
267+
parsed.artifactProviderModules,
268+
resolve(parsed.cwd ?? process.cwd()),
269+
);
270+
} catch (error) {
271+
io.stderr(`${error instanceof Error ? error.message : String(error)}\n`);
272+
return 2;
273+
}
246274
const started = await startHarnessStudioServer({
247275
appDir: defaultAppDir(),
248276
port: parsed.port,
@@ -269,6 +297,7 @@ export async function runHarnessStudioCli(argv: string[], io: HarnessStudioCliIo
269297
...(parsed.canvasSdkRoot !== undefined ? { canvasSdkRoot: resolve(parsed.canvasSdkRoot) } : {}),
270298
...(parsed.canvasSdkMedia !== undefined ? { canvasSdkMedia: resolve(parsed.canvasSdkMedia) } : {}),
271299
...(parsed.providerState !== undefined ? { artifactProviderStateRoot: resolve(parsed.providerState) } : {}),
300+
...(artifactProviders.length > 0 ? { artifactProviders } : {}),
272301
...(parsed.walnutCache !== undefined ? { walnutCacheRoot: resolve(parsed.walnutCache) } : {}),
273302
...(sourceCatalog.length > 0 ? { sourceCatalog } : {}),
274303
...(parsed.cwd !== undefined ? { cwd: parsed.cwd } : {}),
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import {
6+
loadArtifactProviderModules,
7+
providerModuleTarget,
8+
} from "../src/server/artifacts/registry/artifact-provider-modules.js";
9+
10+
const temporary: string[] = [];
11+
afterEach(async () => {
12+
await Promise.all(temporary.splice(0).map(async (path) => await rm(path, { recursive: true, force: true })));
13+
});
14+
15+
describe("Artifact Provider modules", () => {
16+
it("loads a relative operator-provisioned module through its canonical factory", async () => {
17+
const root = await mkdtemp(join(tmpdir(), "artifact-provider-module-"));
18+
temporary.push(root);
19+
await mkdir(join(root, "providers"));
20+
await writeFile(join(root, "providers", "fixture.mjs"), `
21+
export async function createArtifactProvider() {
22+
return { id: "fixture.module", version: "1.0.0", label: "Fixture", contributions: [] };
23+
}
24+
`, "utf8");
25+
const providers = await loadArtifactProviderModules(["./providers/fixture.mjs"], root);
26+
expect(providers).toEqual([expect.objectContaining({ id: "fixture.module", version: "1.0.0" })]);
27+
expect(Object.isFrozen(providers)).toBe(true);
28+
});
29+
30+
it("accepts bounded package names and rejects implicit URL, builtin, duplicate, and over-budget loading", async () => {
31+
expect(providerModuleTarget("@homology/integration-harness-notebook-provider", "/workspace")).toBe(
32+
"@homology/integration-harness-notebook-provider",
33+
);
34+
expect(() => providerModuleTarget("node:fs", "/workspace")).toThrow("package name or filesystem path");
35+
expect(() => providerModuleTarget("https://example.com/provider.mjs", "/workspace")).toThrow("package name or filesystem path");
36+
const root = await mkdtemp(join(tmpdir(), "artifact-provider-duplicate-"));
37+
temporary.push(root);
38+
await writeFile(join(root, "fixture.mjs"), "export const createArtifactProvider = () => ({});", "utf8");
39+
await expect(loadArtifactProviderModules(["./fixture.mjs", "./fixture.mjs"], root)).rejects.toThrow("more than once");
40+
await expect(loadArtifactProviderModules(Array.from({ length: 17 }, (_, index) => `provider-${index}`), root)).rejects.toThrow("At most 16");
41+
});
42+
43+
it("fails closed when the canonical factory is missing", async () => {
44+
const root = await mkdtemp(join(tmpdir(), "artifact-provider-missing-factory-"));
45+
temporary.push(root);
46+
await writeFile(join(root, "fixture.mjs"), "export const value = 1;", "utf8");
47+
await expect(loadArtifactProviderModules(["./fixture.mjs"], root)).rejects.toThrow("createArtifactProvider");
48+
});
49+
});

packages/harness-studio/test/server.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,6 +1311,19 @@ describe("harness-studio CLI", () => {
13111311
expect(parseHarnessStudioArgs([]).error).toBeUndefined();
13121312
});
13131313

1314+
it("parses repeated operator-provisioned Artifact Provider modules", () => {
1315+
expect(parseHarnessStudioArgs([
1316+
"--artifact-provider-module", "@homology/integration-harness-notebook-provider",
1317+
"--artifact-provider-module", "./providers/local.mjs",
1318+
]).artifactProviderModules).toEqual([
1319+
"@homology/integration-harness-notebook-provider",
1320+
"./providers/local.mjs",
1321+
]);
1322+
expect(parseHarnessStudioArgs(["--artifact-provider-module"]).error).toBe(
1323+
"--artifact-provider-module requires a package name or filesystem path.",
1324+
);
1325+
});
1326+
13141327
it("resolves the default source root from the harness file and honors an override", () => {
13151328
expect(resolveHarnessStudioSourceRoot("/workspace/harnesses/agent.harness")).toBe(
13161329
resolve("/workspace/harnesses"),

0 commit comments

Comments
 (0)