razzou-harness 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,27 @@
3
3
  The notable changes in each release of razzou-harness. Before 1.0, a new minor version
4
4
  may change how a project is set up, and its notes say how.
5
5
 
6
+ ## 0.4.1 (2026-11-08)
7
+
8
+ **Upgrading a project from 0.4.0:** install the new version, then rebuild the agents'
9
+ image with `harness doctor --build-images`: its tag follows the harness's version. A
10
+ project's network stays as its `harness/config.json` lists it; for every bundle, set
11
+ `"bundles": ["all"]`.
12
+
13
+ - **Every network bundle by default.** `"all"` in `network.bundles` stands for every
14
+ bundle, those later versions add included, and it is the default: `harness init`
15
+ writes it, and `harness setup` no longer asks which bundles the stack needs. The
16
+ sandboxes of every role reach every bundle's package registries and documentation,
17
+ read-only. A project narrows it by listing bundles. An unknown bundle now fails when
18
+ the config is read, not when a sandbox is made.
19
+ - **Third-party APIs**, in `network.apis`: the hosts the code may call with any method,
20
+ like a payment provider's test API. With `"key": "STRIPE_API_KEY"`, `harness doctor`
21
+ asks for the API's key at a hidden prompt and stores it in an OpenShell provider of
22
+ its own; every sandbox gets it, and holds a placeholder in that variable, which
23
+ OpenShell replaces with the key on requests to that host alone, in headers and URLs,
24
+ not in request bodies. While a key isn't stored on the machine, no new attempt
25
+ starts: the pipeline pauses and says so.
26
+
6
27
  ## 0.4.0 (2026-11-08)
7
28
 
8
29
  **Upgrading a project from 0.3.x:** install the new version, then rebuild the agents'
package/README.md CHANGED
@@ -134,9 +134,9 @@ such as the Harness.io CLI, comes first on the PATH.
134
134
 
135
135
  `harness setup` does every step it can and asks only for what is yours: a `gh` login,
136
136
  the token from `claude setup-token` at a hidden prompt, two clicks on GitHub for the
137
- App, since GitHub creates and installs Apps only in the browser, three questions (your
138
- stack's network bundles, AWS or not, the hours of your time), and, with AWS, the role
139
- ARNs from your bootstrap and the gateway key. In order, it logs you in to `gh`; creates
137
+ App, since GitHub creates and installs Apps only in the browser, two questions (AWS or
138
+ not, the hours of your time), and, with AWS, the role ARNs from your bootstrap and the
139
+ gateway key. In order, it logs you in to `gh`; creates
140
140
  the repository on GitHub if the project has no remote; creates the App from its
141
141
  manifest when this machine has none, and waits for you to install it; writes the
142
142
  harness's files; asks the questions and writes `harness/config.json`, with the
@@ -169,9 +169,10 @@ from a sandbox like an agent's and from outside, everything that must be refused
169
169
  writes and secret reads, other projects' state, `git push`, unlisted hosts and the
170
170
  metadata address, a workflow pushed by the App, a merge without your approval.
171
171
 
172
- `harness init` lets the sandboxes reach only the `core` network bundle. Add your
173
- stack's bundles, such as `node` for npm, in `harness/config.json` (see
174
- [Configuration](#configuration)).
172
+ `harness init` lets the sandboxes reach every network bundle: the package registries
173
+ and documentation of each stack, read-only. To narrow it, list fewer bundles in
174
+ `harness/config.json`; to let the code call a third-party API, add it under
175
+ `network.apis` (see [Configuration](#configuration)).
175
176
 
176
177
  ## Your first module
177
178
 
@@ -432,14 +433,14 @@ through an `Arch:` pull request, or a commit of your own on `main`.
432
433
 
433
434
  ```json
434
435
  {
435
- "harnessVersion": "0.4.0",
436
+ "harnessVersion": "0.4.1",
436
437
  "project": { "name": "shop", "repo": "<org>/shop", "bot": "<org>-harness[bot]" },
437
438
  "roles": {
438
439
  "architect": { "model": "claude-opus-5-5", "effort": "max" },
439
440
  "manager": { "model": "claude-opus-5-5", "effort": "high" },
440
441
  "developer": { "model": "claude-opus-5-5", "effort": "xhigh", "attemptTimeoutMinutes": 90, "maxAttempts": 2 }
441
442
  },
442
- "network": { "bundles": ["core", "node"], "extraHosts": [] },
443
+ "network": { "bundles": ["all"], "apis": [{ "host": "api.stripe.com", "key": "STRIPE_API_KEY" }] },
443
444
  "checks": [{ "id": "unit", "run": "npm test", "phase": "pre-merge" }]
444
445
  }
445
446
  ```
@@ -452,8 +453,9 @@ through an `Arch:` pull request, or a commit of your own on `main`.
452
453
  | `roles.<role>.effort` | `max`, `high`, `xhigh` | Claude Code's `--effort`; `null` for a model without effort levels, such as Haiku |
453
454
  | `roles.developer.attemptTimeoutMinutes` | 120 | An attempt's deadline |
454
455
  | `roles.developer.maxAttempts` | 2 | Attempts per feature before it is escalated to you |
455
- | `network.bundles` | `["core"]` | The hosts sandboxes reach, by bundle: `core`, `node`, `python`, `aws`, `terraform`, `go`, `rust`, `jvm`, `opentofu`, `browsers`. The Architect gets them all. |
456
+ | `network.bundles` | `["all"]` | The hosts sandboxes reach directly, read-only, by bundle: `core`, `node`, `python`, `aws`, `terraform`, `go`, `rust`, `jvm`, `opentofu`, `browsers`, or `all` for every one, those later versions add included. The Architect gets them all. |
456
457
  | `network.extraHosts` | `[]` | More hosts, read-only |
458
+ | `network.apis` | `[]` | Third-party APIs the code may call with any method, like a payment provider's test API: `{ "host": "api.stripe.com" }`, with `"key": "STRIPE_API_KEY"` for one that needs a key. `harness doctor` asks for the key at a hidden prompt and stores it in OpenShell; in a sandbox the variable holds a placeholder, which OpenShell replaces with the key on requests to that host alone, in headers and URLs, not in request bodies. CI gets no key. |
457
459
  | `checks` | `[]` | Checks that every feature must keep passing, such as `npm run lint` |
458
460
  | `protect.files`, `protect.jsonFields` | none | More protected files, as paths or globs (`scripts/*.sh`, `docs/`, `**/Makefile`), and protected JSON fields by file, like `{"tsconfig.json": ["/compilerOptions/strict"]}` |
459
461
  | `sandbox.image` | `razzou-harness/agent:<version>` | The agents' image, tagged with the harness's version, which `harness doctor --build-images` builds |
@@ -581,7 +583,8 @@ RDS, `manage_master_user_password`); anything else uses write-only arguments.
581
583
 
582
584
  What agents can't do, and what stops them:
583
585
  - **Hold credentials.** Sandboxes hold placeholders. The OpenShell proxy adds the Claude
584
- token on `api.anthropic.com` only, and signs AWS requests with the read-only role.
586
+ token on `api.anthropic.com` only, each key of `network.apis` on its API's host only,
587
+ and signs AWS requests with the read-only role.
585
588
  - **Reach the host.** Each agent runs in its own container, with no host folder mounted;
586
589
  code moves in and out as git bundles. The host never runs code or git hooks that an
587
590
  agent wrote.
@@ -594,7 +597,8 @@ What agents can't do, and what stops them:
594
597
  They change only through an Arch PR you merge, or your own commits.
595
598
  - **Change AWS.** A read-only role with explicit denies, and applies only in CI after
596
599
  your merge.
597
- - **Go anywhere online.** Sandboxes reach listed hosts, read-only. Other pages go
600
+ - **Go anywhere online.** Sandboxes reach listed hosts, read-only, and the APIs in
601
+ `network.apis` with any method; OpenShell has no rule for every host. Other pages go
598
602
  through `web_read`: GET only, public addresses only, never this machine's own
599
603
  addresses or the networks it is on (IPv6 included), checked again after every
600
604
  redirect, within 75 seconds and 2 MB, decompressed.
@@ -263,7 +263,7 @@ function refusedLines(attempt, failed) {
263
263
  ...shown.map((d) => `- ${d.target} ${d.program ? `from ${d.program}` : '(a DNS query)'}, ${d.count} time${d.count === 1 ? '' : 's'}: ${d.reason.replace(/_/g, ' ')}`),
264
264
  ...(refused.length > shown.length ? [`- and ${refused.length - shown.length} more`] : []),
265
265
  ...(failed
266
- ? ["A sandbox reaches only the hosts of network.bundles and network.extraHosts in harness/config.json, and the next attempt meets the same policy. If the feature can do without a host, give the way around it in your guidance. If it can't, ask_owner to allow the host, with blocking: true when no attempt can succeed without it: only the owner widens the policy."]
266
+ ? ["A sandbox reaches only the hosts of network.bundles, network.extraHosts and network.apis in harness/config.json, and the next attempt meets the same policy. If the feature can do without a host, give the way around it in your guidance. If it can't, ask_owner to allow the host, with blocking: true when no attempt can succeed without it: only the owner widens the policy."]
267
267
  : []),
268
268
  ];
269
269
  }
@@ -15,7 +15,7 @@ import { rulesProblems } from '../github/setup.js';
15
15
  import { toolsManifest } from '../roles.js';
16
16
  import { PROJECT_LABEL } from '../sandbox/driver.js';
17
17
  import { BUNDLES } from '../sandbox/allowlist.js';
18
- import { buildPolicy } from '../sandbox/policy.js';
18
+ import { apiProviderName, buildPolicy } from '../sandbox/policy.js';
19
19
  import { Denials, LOG_LINES } from '../sandbox/refusals.js';
20
20
  import { EventStore } from '../state/events.js';
21
21
  import { activeAttempts, apply, countedAttempts, featureKey, interruptions, isActive, managersOf, planKey, planMerged, project, writeSnapshot, } from '../state/state.js';
@@ -75,6 +75,8 @@ export class Orchestrator {
75
75
  stepErrors = { key: '', count: 0 };
76
76
  /** When the left-over sandboxes are next deleted: at the first pass, then every hour. */
77
77
  nextSweep = 0;
78
+ /** The gateway's providers, read at most once a minute, for the keys of the project's APIs. */
79
+ providerNames;
78
80
  /** Errors in a row of each plan's step, and when it is tried again: one plan that keeps failing never stops the others. */
79
81
  planErrors = new Map();
80
82
  /** The heads of Arch and Fix PRs already given the reviewers' statuses as "not reviewed". */
@@ -262,10 +264,11 @@ export class Orchestrator {
262
264
  this.record({ type: 'PIPELINE_PAUSED', payload: { reason, ...(until ? { until } : {}) } });
263
265
  return true;
264
266
  }
265
- /** The owner lifts a pause. */
267
+ /** The owner lifts a pause. An API key stored meanwhile counts at once. */
266
268
  resume() {
267
269
  if (!this.state.pipeline.paused)
268
270
  return false;
271
+ this.providerNames = undefined;
269
272
  this.record({ type: 'PIPELINE_RESUMED', payload: {} });
270
273
  this.kick();
271
274
  return true;
@@ -430,6 +433,8 @@ export class Orchestrator {
430
433
  return 'progress';
431
434
  if (paused)
432
435
  return 'wait';
436
+ if (await this.apiKeysMissing())
437
+ return 'wait';
433
438
  // Manual builds one feature at a time, and looks at nothing else while it runs. Swarm starts
434
439
  // more while others run, up to its limit, and follows its other plans meanwhile (v3 §6.2).
435
440
  const swarm = this.state.mode === 'swarm';
@@ -438,6 +443,32 @@ export class Orchestrator {
438
443
  return 'wait';
439
444
  return this.scheduleNext(active.length, limit);
440
445
  }
446
+ /**
447
+ * An API of the design whose key isn't stored on this machine pauses the pipeline, with
448
+ * what to do: every new sandbox carries the API's provider, and OpenShell refuses one
449
+ * that doesn't exist. A design change that adds an API leads here.
450
+ */
451
+ async apiKeysMissing() {
452
+ const keyed = this.design.config.network.apis.filter((api) => api.key);
453
+ if (keyed.length === 0)
454
+ return false;
455
+ if (!this.providerNames || this.now() - this.providerNames.at >= 60_000) {
456
+ try {
457
+ this.providerNames = { at: this.now(), names: await this.driver.providers() };
458
+ }
459
+ catch (error) {
460
+ // The gateway's trouble shows elsewhere; this check doesn't hold the pipeline for it.
461
+ this.log(`the providers could not be listed: ${error.message}`);
462
+ return false;
463
+ }
464
+ }
465
+ const project = this.design.config.project.name;
466
+ const missing = keyed.filter((api) => !this.providerNames.names.includes(apiProviderName(project, api.host)));
467
+ if (missing.length === 0)
468
+ return false;
469
+ this.pausePipeline(`no key stored on this machine for ${missing.map((api) => api.host).join(', ')}: run \`harness doctor\` in a terminal, which asks for it, then resume`);
470
+ return true;
471
+ }
441
472
  /** One attempt's next step. An error counts against this attempt alone, and interrupts it past the limit. */
442
473
  async stepAttempt(feature, attempt) {
443
474
  try {
@@ -1724,12 +1755,16 @@ export class Orchestrator {
1724
1755
  * A sandbox's providers, policy and settings. Agents get Claude; with AWS in the
1725
1756
  * project, they and the checks that need it also get the view role, which OpenShell
1726
1757
  * holds and signs with (§12.1). A provider is only attached where its endpoints are.
1758
+ * Every sandbox gets the keys of the project's APIs, for the code it runs.
1727
1759
  */
1728
1760
  access(options) {
1729
1761
  const aws = options.aws ? this.design.config.aws : undefined;
1730
1762
  const limits = this.state.sandboxLimits;
1763
+ const project = this.design.config.project.name;
1764
+ // The keys of the project's APIs, which OpenShell adds on requests to their hosts alone.
1765
+ const apiKeys = this.design.config.network.apis.flatMap((api) => (api.key ? [apiProviderName(project, api.host)] : []));
1731
1766
  return {
1732
- providers: [...(options.claude ? [this.design.config.sandbox.claudeProvider] : []), ...(aws ? [aws.provider] : [])],
1767
+ providers: [...(options.claude ? [this.design.config.sandbox.claudeProvider] : []), ...(aws ? [aws.provider] : []), ...apiKeys],
1733
1768
  policyFile: this.policyFile({ allBundles: options.allBundles ?? false, aws: aws !== undefined }),
1734
1769
  env: this.sandboxEnv(),
1735
1770
  // Every sandbox the daemon makes says whose it is: the dashboard tells them from other projects'.
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { z } from 'zod';
3
+ import { ALL_BUNDLES, BUNDLES } from '../sandbox/allowlist.js';
3
4
  import { canonicalJson, sha256 } from '../util.js';
4
5
  /** The agents' image is tagged with the harness's version, which may change what goes into it: `harness doctor --build-images` builds it. */
5
6
  const AGENT_IMAGE = `razzou-harness/agent:${JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version}`;
@@ -71,6 +72,14 @@ export function featureDependency(planId, dep) {
71
72
  const [a, b] = dep.split('/');
72
73
  return b === undefined ? { plan: planId, feature: a } : { plan: a, feature: b };
73
74
  }
75
+ /** Variables the harness, OpenShell or the system set in a sandbox: an API's key can't take one of their names. */
76
+ const RESERVED_VARIABLES = /^(AWS_|CLAUDE_|OPENSHELL_|HARNESS_|SSL_|NODE_|NPM_)|^(PATH|HOME|USER|SHELL|LANG|TERM|PWD|TMPDIR|REQUESTS_CA_BUNDLE|CURL_CA_BUNDLE|DISABLE_AUTOUPDATER|CHECKPOINT_DISABLE|MCP_TOOL_TIMEOUT)$/;
77
+ /** A third-party API of `network.apis`: an exact host, and the variable its key is read from, if it has one. */
78
+ const ApiSchema = z.object({
79
+ host: z.string().regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/, 'a host name, like api.stripe.com'),
80
+ key: z.string().regex(/^[A-Z][A-Z0-9_]*$/, 'a variable name in capitals, like STRIPE_API_KEY')
81
+ .refine((name) => !RESERVED_VARIABLES.test(name), 'a variable the harness, OpenShell or the system sets; pick another name, like STRIPE_API_KEY').optional(),
82
+ }).strict();
74
83
  export const ConfigSchema = z.object({
75
84
  harnessVersion: z.string(),
76
85
  project: z.object({
@@ -117,8 +126,18 @@ export const ConfigSchema = z.object({
117
126
  .prefault({}),
118
127
  network: z
119
128
  .object({
120
- bundles: z.array(z.string()).default(['core']),
129
+ /** The hosts sandboxes reach directly, read-only, by bundle; `all`, the default, is every bundle. */
130
+ bundles: z.array(z.string().refine((name) => name === ALL_BUNDLES || name in BUNDLES, `a bundle: ${ALL_BUNDLES}, ${Object.keys(BUNDLES).join(', ')}`)).default([ALL_BUNDLES]),
121
131
  extraHosts: z.array(z.string()).default([]),
132
+ /**
133
+ * Third-party APIs sandboxes may call with any method, like a payment provider's test
134
+ * API. `key` names the variable the project's code reads the API's key from: in a
135
+ * sandbox it holds a placeholder, which OpenShell replaces with the key on requests to
136
+ * the host alone. `harness doctor` stores the key, at a hidden prompt.
137
+ */
138
+ apis: z.array(ApiSchema).default([])
139
+ .refine((apis) => new Set(apis.map((a) => a.host)).size === apis.length, 'each host once')
140
+ .refine((apis) => { const keys = apis.flatMap((a) => (a.key ? [a.key] : [])); return new Set(keys).size === keys.length; }, 'each key once'),
122
141
  })
123
142
  .prefault({}),
124
143
  checks: z.array(CheckSchema).default([]),
@@ -19,6 +19,12 @@ export const BUNDLES = {
19
19
  opentofu: read('registry.opentofu.org', 'opentofu.org'),
20
20
  browsers: read('cdn.playwright.dev', 'playwright.download.prss.microsoft.com'),
21
21
  };
22
+ /** Every bundle, those later versions add included: what a project gets unless it lists fewer. */
23
+ export const ALL_BUNDLES = 'all';
24
+ /** The bundles a project's list names, `all` standing for every one. */
25
+ export function bundleNames(names) {
26
+ return names.includes(ALL_BUNDLES) ? Object.keys(BUNDLES) : [...names];
27
+ }
22
28
  /** Throws on a host OpenShell would reject, so a bad entry fails at load time rather than at sandbox creation. */
23
29
  export function checkHost(host) {
24
30
  const labels = host.split('.');
@@ -157,6 +157,21 @@ export class OpenShellDriver {
157
157
  throw new Error(`openshell logs failed: ${(result.stderr || result.stdout).trim().slice(0, 300)}`);
158
158
  return result.stdout;
159
159
  }
160
+ async providers() {
161
+ const names = [];
162
+ let token = '';
163
+ for (let page = 0; page < 20; page++) {
164
+ const result = await run(this.bin, ['provider', 'list', '--output', 'json', ...(token ? ['--page-token', token] : [])], { timeout: 30_000, killSignal: 'SIGKILL' });
165
+ if (result.code !== 0)
166
+ throw new Error(`openshell provider list failed: ${(result.stderr || result.stdout).trim().slice(0, 300)}`);
167
+ const listed = JSON.parse(result.stdout);
168
+ names.push(...(listed.providers ?? []).flatMap((p) => (typeof p.name === 'string' ? [p.name] : [])));
169
+ token = listed.next_page_token ?? '';
170
+ if (!token)
171
+ break;
172
+ }
173
+ return names;
174
+ }
160
175
  async gateway() {
161
176
  const result = await run(this.bin, ['status', '--output', 'json'], { timeout: 15_000, killSignal: 'SIGKILL' }).catch((error) => ({ code: 1, stdout: '', stderr: error.message }));
162
177
  if (result.code !== 0)
@@ -1,4 +1,4 @@
1
- import { BUNDLES, checkHost } from './allowlist.js';
1
+ import { BUNDLES, bundleNames, checkHost } from './allowlist.js';
2
2
  /** Services whose endpoints don't follow `<service>.<region>.amazonaws.com`. Global ones sign for us-east-1. */
3
3
  const AWS_SERVICES = {
4
4
  iam: { hosts: () => ['iam.amazonaws.com'], signing: 'iam', global: true },
@@ -43,16 +43,21 @@ const BINARIES = ['/usr/local/bin/claude', '/usr/bin/bash', '/usr/bin/dash'];
43
43
  */
44
44
  export function buildPolicy(options) {
45
45
  const rules = [];
46
- for (const name of options.bundles) {
46
+ for (const name of bundleNames(options.bundles)) {
47
47
  const bundle = BUNDLES[name];
48
48
  if (!bundle)
49
- throw new Error(`unknown network bundle "${name}" (known: ${Object.keys(BUNDLES).join(', ')})`);
49
+ throw new Error(`unknown network bundle "${name}" (known: all, ${Object.keys(BUNDLES).join(', ')})`);
50
50
  rules.push(...bundle);
51
51
  }
52
52
  rules.push(...options.extraHosts.map((host) => ({ host, access: 'read' })));
53
53
  // A host's first rule wins: a bundle's, like github.com's with git, over an extra host's read-only one.
54
- const unique = rules.filter((rule, i) => rules.findIndex((r) => r.host === rule.host) === i);
54
+ // An API's own rule wins over both, so a host listed as an API takes every method.
55
+ const apis = options.apis ?? [];
56
+ const apiHosts = new Set(apis.map((api) => api.host));
57
+ const unique = rules.filter((rule, i) => rules.findIndex((r) => r.host === rule.host) === i && !apiHosts.has(rule.host));
55
58
  unique.forEach((rule) => checkHost(rule.host));
59
+ apis.forEach((api) => checkHost(api.host));
60
+ const keyless = apis.filter((api) => !api.key);
56
61
  const binaries = BINARIES.map((path) => ({ path }));
57
62
  return {
58
63
  version: 1,
@@ -65,9 +70,39 @@ export function buildPolicy(options) {
65
70
  read_only_web: { endpoints: unique.filter((r) => r.access === 'read').map(readEndpoint), binaries },
66
71
  git_fetch: { endpoints: unique.filter((r) => r.access === 'git').map(gitEndpoint), binaries },
67
72
  ...(options.aws ? { aws_view: { endpoints: awsEndpoints(options.aws), binaries } } : {}),
73
+ ...(keyless.length > 0 ? { third_party_apis: { endpoints: keyless.map((api) => apiEndpoint(api.host)), binaries } } : {}),
68
74
  },
69
75
  };
70
76
  }
77
+ /** A third-party API's host, open to every method: the project's code calls it as it would in production. */
78
+ function apiEndpoint(host) {
79
+ return { host, port: 443, protocol: 'rest', enforcement: 'enforce', access: 'full' };
80
+ }
81
+ /**
82
+ * The OpenShell provider, and its profile, that hold a third-party API's key for a
83
+ * project. Providers are per machine, so the name says whose and which.
84
+ */
85
+ export function apiProviderName(project, host) {
86
+ const safe = (text) => text.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
87
+ return `api-${safe(project)}-${safe(host)}`;
88
+ }
89
+ /**
90
+ * The provider profile of a third-party API's key. A sandbox with the provider sees a
91
+ * placeholder in the variable, and OpenShell's proxy puts the key in its place on
92
+ * requests to the host alone: in any header, Basic auth included, and in the URL, but
93
+ * not in a request's body. The profile also opens the host to every method.
94
+ */
95
+ export function apiProfile(project, api) {
96
+ return {
97
+ id: apiProviderName(project, api.host),
98
+ display_name: `${api.host} for ${project}`,
99
+ description: `The key of ${api.host} that the harness's sandboxes of ${project} use, as ${api.key}`,
100
+ category: 'other',
101
+ credentials: [{ name: 'key', description: `The key of ${api.host}`, env_vars: [api.key], required: true, auth_style: 'bearer', header_name: 'authorization' }],
102
+ endpoints: [{ host: api.host, port: 443, protocol: 'rest', access: 'full', enforcement: 'enforce' }],
103
+ binaries: BINARIES,
104
+ };
105
+ }
71
106
  function readEndpoint(rule) {
72
107
  return {
73
108
  host: rule.host,
@@ -11,6 +11,7 @@ import { GitHubClient, loadAppCredentials } from '../github/app.js';
11
11
  import { checkApp } from '../github/setup.js';
12
12
  import { BUNDLES } from '../sandbox/allowlist.js';
13
13
  import { OpenShellDriver } from '../sandbox/openshell.js';
14
+ import { apiProfile, apiProviderName } from '../sandbox/policy.js';
14
15
  import { run } from '../util.js';
15
16
  import { buildCache, removeOldAgentImages } from './images.js';
16
17
  import { probe, stateLocation } from './probe.js';
@@ -213,6 +214,7 @@ async function project(options, config, report) {
213
214
  }
214
215
  if (config.aws)
215
216
  await aws(options, config, report);
217
+ await apis(options, config, report);
216
218
  if (config.terraform) {
217
219
  const tf = config.terraform;
218
220
  for (const file of [tf.backendConfig, tf.varFile]) {
@@ -220,6 +222,70 @@ async function project(options, config, report) {
220
222
  }
221
223
  }
222
224
  }
225
+ /** The project's third-party APIs: each key in an OpenShell provider, under the variable the config names. */
226
+ async function apis(options, config, report) {
227
+ for (const api of config.network.apis) {
228
+ if (!api.key) {
229
+ report(ok('API', `${api.host}: every method, no key`));
230
+ continue;
231
+ }
232
+ const name = apiProviderName(config.project.name, api.host);
233
+ const keys = await providerKeys(name);
234
+ if (keys && keys.includes(api.key)) {
235
+ report(ok('API', `${api.host}: its key in ${name}, as ${api.key}`));
236
+ continue;
237
+ }
238
+ if (keys) {
239
+ report(fail('API', `${name} holds the key of ${api.host} as ${keys.join(', ') || 'nothing'}, but the config says ${api.key}`, `delete it with \`openshell provider delete ${name}\`, then run \`harness doctor\` in a terminal`));
240
+ continue;
241
+ }
242
+ if (!options.interactive) {
243
+ report(fail('API', `no key stored for ${api.host}`, 'run `harness doctor` in a terminal: it asks for the key'));
244
+ continue;
245
+ }
246
+ const secret = await askSecret(`The key of ${api.host}, which the project's code reads as ${api.key} (hidden, or Enter to skip): `);
247
+ if (!secret) {
248
+ report(fail('API', `no key stored for ${api.host}`, 'run `harness doctor` again with the key at hand'));
249
+ continue;
250
+ }
251
+ const problem = await storeApiKey(config.project.name, { host: api.host, key: api.key }, secret);
252
+ report(problem ? fail('API', `${api.host}: ${problem}`, 'fix it, then run `harness doctor` again') : ok('API', `${api.host}: key stored in ${name}`));
253
+ }
254
+ }
255
+ /** The variables a provider holds credentials under, or undefined when there is no such provider. */
256
+ async function providerKeys(name, bin = 'openshell') {
257
+ const listed = await run(bin, ['provider', 'list', '--output', 'json']).catch(() => undefined);
258
+ if (listed?.code !== 0)
259
+ return undefined;
260
+ const provider = JSON.parse(listed.stdout).providers?.find((p) => p.name === name);
261
+ return provider ? provider.credential_keys ?? [] : undefined;
262
+ }
263
+ /**
264
+ * Stores an API's key in a new OpenShell provider, after importing the profile the
265
+ * harness writes for it (apiProfile). The key reaches `openshell` through its
266
+ * environment, never its arguments, which other users of the machine can read. Returns
267
+ * what went wrong, if anything.
268
+ */
269
+ export async function storeApiKey(project, api, secret, bin = 'openshell') {
270
+ const name = apiProviderName(project, api.host);
271
+ const dir = mkdtempSync(join(tmpdir(), 'harness-api-'));
272
+ try {
273
+ const file = join(dir, `${name}.yaml`);
274
+ writeFileSync(file, `${JSON.stringify(apiProfile(project, api), null, 2)}\n`);
275
+ // A profile left by an earlier try, or written for another variable, gives way to this one.
276
+ await run(bin, ['profile', 'delete', name, '--global']).catch(() => undefined);
277
+ const imported = await run(bin, ['profile', 'import', '-f', file, '--global']);
278
+ if (imported.code !== 0)
279
+ return `openshell profile import: ${firstLine(imported.stderr || imported.stdout)}`;
280
+ const created = await run(bin, ['provider', 'create', '--name', name, '--type', name, '--credential', api.key, '--global-profile'], { env: { ...process.env, [api.key]: secret } });
281
+ if (created.code !== 0)
282
+ return `openshell provider create: ${firstLine(created.stderr || created.stdout)}`;
283
+ return undefined;
284
+ }
285
+ finally {
286
+ rmSync(dir, { recursive: true, force: true });
287
+ }
288
+ }
223
289
  /** The AWS view role in OpenShell (§17.3, step 5): the gateway user's key, assuming the project's view role. */
224
290
  async function aws(options, config, report) {
225
291
  const provider = config.aws.provider;
@@ -49,7 +49,7 @@ export async function init(options) {
49
49
  Object.assign(project, { repo, bot });
50
50
  else
51
51
  notes.push(repo ? 'No GitHub App on this machine yet: `harness setup` creates it, and sets project.repo and project.bot in harness/config.json.' : 'No GitHub remote found: the project stays local until you set project.repo and project.bot.');
52
- write('harness/config.json', `${JSON.stringify({ harnessVersion: options.version, project, network: { bundles: ['core'] } }, null, 2)}\n`);
52
+ write('harness/config.json', `${JSON.stringify({ harnessVersion: options.version, project, network: { bundles: ['all'] } }, null, 2)}\n`);
53
53
  const owner = options.owner ?? ((await run('gh', ['api', 'user', '--jq', '.login']).catch(() => undefined))?.stdout.trim() || undefined);
54
54
  for (const [path, content] of ciFiles(options.packageDir, owner))
55
55
  write(path, content);
@@ -4,7 +4,6 @@ import { computeLock, directoryReader } from '../ci/harness-ci.js';
4
4
  import { ConfigSchema, requiredChecks } from '../design/schemas.js';
5
5
  import { configDir, loadAppCredentials } from '../github/app.js';
6
6
  import { applyRepositoryRules, checkApp, createApp, installCi } from '../github/setup.js';
7
- import { BUNDLES } from '../sandbox/allowlist.js';
8
7
  import { run, sha256 } from '../util.js';
9
8
  import { doctor, granted } from './doctor.js';
10
9
  import { appBot, githubRepo, init } from './init.js';
@@ -32,13 +31,6 @@ export function fixFor(check) {
32
31
  return undefined;
33
32
  }
34
33
  }
35
- /** The bundles the owner named, besides core, that exist; unknown ones are reported. */
36
- export function parseBundles(answer) {
37
- const named = answer.split(/[,\s]+/).map((b) => b.trim()).filter(Boolean);
38
- const unknown = named.filter((b) => !(b in BUNDLES));
39
- const bundles = ['core', ...named.filter((b) => b in BUNDLES && b !== 'core')];
40
- return { bundles: [...new Set(bundles)], unknown };
41
- }
42
34
  /**
43
35
  * The OpenShell provider of the project's AWS view role. Providers are per machine: it is
44
36
  * named after the repository, or else after the folder and a hash of its path, so two
@@ -209,10 +201,11 @@ async function ownerLogin(options) {
209
201
  return login?.code === 0 ? login.stdout.trim() || undefined : undefined;
210
202
  }
211
203
  /**
212
- * The three questions (v4 §4), asked once: the stack's network bundles, AWS, and the
213
- * owner's hours. What they settle goes into harness/config.json, with the lock that
214
- * hashes it, and into .harness/setup.json for the daemon. The view role's ARN goes to
215
- * the doctor, which creates the AWS provider with it.
204
+ * The questions (v4 §4), asked once: AWS, and the owner's hours. The network needs none:
205
+ * a project gets every bundle unless its config lists fewer. What they settle goes into
206
+ * harness/config.json, with the lock that hashes it, and into .harness/setup.json for
207
+ * the daemon. The view role's ARN goes to the doctor, which creates the AWS provider
208
+ * with it.
216
209
  */
217
210
  async function askSettings(options) {
218
211
  const { projectDir, say, ask } = options;
@@ -221,18 +214,6 @@ async function askSettings(options) {
221
214
  const written = [];
222
215
  let changed = false;
223
216
  let viewRole;
224
- const bundles = config.network?.bundles ?? ['core'];
225
- if (bundles.length === 1 && bundles[0] === 'core') {
226
- const others = Object.keys(BUNDLES).filter((b) => b !== 'core').join(', ');
227
- const answer = await ask(`Which network bundles do the sandboxes need besides core, for your stack? (${others}; Enter for none) `);
228
- const parsed = parseBundles(answer);
229
- if (parsed.unknown.length > 0)
230
- say(`unknown bundles, ignored: ${parsed.unknown.join(', ')}`);
231
- if (parsed.bundles.length > 1) {
232
- config.network = { ...(config.network ?? {}), bundles: parsed.bundles };
233
- changed = true;
234
- }
235
- }
236
217
  if (!config.aws && /^y/i.test(await ask('Does this project deploy to AWS with Terraform? [y/N] '))) {
237
218
  say('The roles come from your bootstrap (see the README\'s AWS section); the key is asked for later, at a hidden prompt.');
238
219
  const region = (await ask('AWS region (like eu-west-1): ')).trim();