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 +21 -0
- package/README.md +15 -11
- package/dist/daemon/items.js +1 -1
- package/dist/daemon/orchestrator.js +38 -3
- package/dist/design/schemas.js +20 -1
- package/dist/sandbox/allowlist.js +6 -0
- package/dist/sandbox/openshell.js +15 -0
- package/dist/sandbox/policy.js +39 -4
- package/dist/setup/doctor.js +66 -0
- package/dist/setup/init.js +1 -1
- package/dist/setup/setup.js +5 -24
- package/dist/web/assets/{index-hRWASvyl.js → index-CLq7s9N9.js} +1 -1
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/roles/architect.md +6 -0
- package/roles/developer.md +4 -1
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,
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
173
|
-
stack
|
|
174
|
-
|
|
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.
|
|
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": ["
|
|
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` | `["
|
|
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,
|
|
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
|
|
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.
|
package/dist/daemon/items.js
CHANGED
|
@@ -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.
|
|
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'.
|
package/dist/design/schemas.js
CHANGED
|
@@ -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
|
-
|
|
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)
|
package/dist/sandbox/policy.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/dist/setup/doctor.js
CHANGED
|
@@ -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;
|
package/dist/setup/init.js
CHANGED
|
@@ -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: ['
|
|
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);
|
package/dist/setup/setup.js
CHANGED
|
@@ -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
|
|
213
|
-
*
|
|
214
|
-
* hashes it, and into .harness/setup.json for
|
|
215
|
-
* the doctor, which creates the AWS provider
|
|
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();
|