@orchestraworks/cli 0.5.8 → 0.6.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.
- package/README.md +1 -1
- package/dist/cli/api/approval.js +3 -3
- package/dist/cli/commands/outpost/local.js +3 -3
- package/dist/cli/commands/outpost/machine-names.d.ts +17 -0
- package/dist/cli/commands/outpost/machine-names.js +22 -0
- package/dist/cli/commands/outpost/up.js +7 -5
- package/dist/cli/commands/worker/enroll.d.ts +4 -3
- package/dist/cli/commands/worker/enroll.js +4 -3
- package/dist/cli/core/auth.d.ts +11 -1
- package/dist/cli/core/auth.js +21 -4
- package/dist/cli/core/session-body.d.ts +1 -1
- package/dist/cli/core/session-body.js +1 -1
- package/dist/cli/firstRun.js +2 -2
- package/dist/cli/main.js +4 -3
- package/dist/constructs/environment.d.ts +24 -11
- package/dist/constructs/environment.js +76 -12
- package/dist/constructs/secret.d.ts +17 -7
- package/dist/constructs/secret.js +24 -16
- package/dist/core/labels.d.ts +32 -6
- package/dist/core/labels.js +70 -8
- package/dist/engine/diff.js +38 -11
- package/dist/engine/gateway-key-reconcile.d.ts +1 -2
- package/dist/engine/gateway-key-reconcile.js +19 -15
- package/dist/engine/pipeline-reconcile.js +2 -2
- package/dist/engine/pipeline-workflow-reconcile.js +15 -9
- package/dist/engine/reconcile.js +48 -15
- package/dist/engine/refusals.d.ts +0 -5
- package/dist/engine/refusals.js +1 -5
- package/dist/engine/secrets-apply.d.ts +10 -14
- package/dist/engine/secrets-apply.js +12 -17
- package/dist/engine/workflow-reconcile.d.ts +1 -1
- package/dist/generated/cli-commands.json +170 -34
- package/dist/generated/providers.d.ts +40 -0
- package/dist/generated/providers.js +30 -0
- package/docs/REFERENCE.md +9 -7
- package/node_modules/@orchestraworks/client/dist/esm/models/ApiErrorDetail.d.ts +1 -1
- package/node_modules/@orchestraworks/client/dist/esm/models/CreateEnvironmentRequest.d.ts +7 -0
- package/node_modules/@orchestraworks/client/dist/esm/models/CreateEnvironmentRequest.js +3 -0
- package/node_modules/@orchestraworks/client/dist/esm/models/CreateSecretRequest.d.ts +4 -3
- package/node_modules/@orchestraworks/client/dist/esm/models/Environment.d.ts +7 -0
- package/node_modules/@orchestraworks/client/dist/esm/models/Environment.js +3 -0
- package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentCredential.d.ts +38 -0
- package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentCredential.js +45 -0
- package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentProvisionSpec.d.ts +1 -1
- package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentProvisionSpecRequest.d.ts +1 -1
- package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentSnapshot.d.ts +1 -1
- package/node_modules/@orchestraworks/client/dist/esm/models/InjectionRecipe.d.ts +6 -0
- package/node_modules/@orchestraworks/client/dist/esm/models/InjectionRecipe.js +2 -0
- package/node_modules/@orchestraworks/client/dist/esm/models/OAuthTokenRequest.d.ts +1 -1
- package/node_modules/@orchestraworks/client/dist/esm/models/Secret.d.ts +1 -7
- package/node_modules/@orchestraworks/client/dist/esm/models/Secret.js +0 -4
- package/node_modules/@orchestraworks/client/dist/esm/models/UpdateEnvironmentRequest.d.ts +7 -0
- package/node_modules/@orchestraworks/client/dist/esm/models/UpdateEnvironmentRequest.js +3 -0
- package/node_modules/@orchestraworks/client/dist/esm/models/UpdateSecretRequest.d.ts +4 -3
- package/node_modules/@orchestraworks/client/dist/esm/models/index.d.ts +1 -0
- package/node_modules/@orchestraworks/client/dist/esm/models/index.js +1 -0
- package/node_modules/@orchestraworks/client/dist/models/ApiErrorDetail.d.ts +1 -1
- package/node_modules/@orchestraworks/client/dist/models/CreateEnvironmentRequest.d.ts +7 -0
- package/node_modules/@orchestraworks/client/dist/models/CreateEnvironmentRequest.js +3 -0
- package/node_modules/@orchestraworks/client/dist/models/CreateSecretRequest.d.ts +4 -3
- package/node_modules/@orchestraworks/client/dist/models/Environment.d.ts +7 -0
- package/node_modules/@orchestraworks/client/dist/models/Environment.js +3 -0
- package/node_modules/@orchestraworks/client/dist/models/EnvironmentCredential.d.ts +38 -0
- package/node_modules/@orchestraworks/client/dist/models/EnvironmentCredential.js +52 -0
- package/node_modules/@orchestraworks/client/dist/models/EnvironmentProvisionSpec.d.ts +1 -1
- package/node_modules/@orchestraworks/client/dist/models/EnvironmentProvisionSpecRequest.d.ts +1 -1
- package/node_modules/@orchestraworks/client/dist/models/EnvironmentSnapshot.d.ts +1 -1
- package/node_modules/@orchestraworks/client/dist/models/InjectionRecipe.d.ts +6 -0
- package/node_modules/@orchestraworks/client/dist/models/InjectionRecipe.js +2 -0
- package/node_modules/@orchestraworks/client/dist/models/OAuthTokenRequest.d.ts +1 -1
- package/node_modules/@orchestraworks/client/dist/models/Secret.d.ts +1 -7
- package/node_modules/@orchestraworks/client/dist/models/Secret.js +0 -4
- package/node_modules/@orchestraworks/client/dist/models/UpdateEnvironmentRequest.d.ts +7 -0
- package/node_modules/@orchestraworks/client/dist/models/UpdateEnvironmentRequest.js +3 -0
- package/node_modules/@orchestraworks/client/dist/models/UpdateSecretRequest.d.ts +4 -3
- package/node_modules/@orchestraworks/client/dist/models/index.d.ts +1 -0
- package/node_modules/@orchestraworks/client/dist/models/index.js +1 -0
- package/node_modules/@orchestraworks/client/package.json +1 -1
- package/package.json +4 -4
- package/skills/orchestra/SKILL.md +30 -14
- package/skills/orchestra-fdk/references/authoring.md +26 -20
- package/skills/orchestra-fdk/references/environment.md +49 -12
- package/skills/orchestra-fdk/references/integrations/code-repo.md +26 -5
- package/skills/orchestra-fdk/references/phases/1-discover.md +1 -1
- package/skills/orchestra-fdk/references/phases/2-plan.md +1 -1
- package/skills/orchestra-fdk/references/troubleshooting.md +3 -2
- package/templates/bug-fix/main.ts +7 -4
- package/templates/engineering-team/lib/environment.ts +6 -3
- package/templates/engineering-team/package.json +1 -1
- package/templates/todo-factory/main.ts +7 -4
- package/templates/todo-factory/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ The full guide to every construct, secrets, connections and troubleshooting is [
|
|
|
20
20
|
```bash
|
|
21
21
|
npm install -g @orchestraworks/cli # the CLI, on your path as `orchestra`
|
|
22
22
|
npm install @orchestraworks/cli # the current release
|
|
23
|
-
npm install @orchestraworks/cli@0.
|
|
23
|
+
npm install @orchestraworks/cli@0.6.0 # this exact release
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
Then `import { Factory, Agent, … } from "@orchestraworks/cli"` normally — the package `exports` entry resolves
|
package/dist/cli/api/approval.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// policy is resolved once per invocation at command-handler entry and handed in — this module reads no
|
|
7
7
|
// environment variable and holds no default, so a gate can never disagree with the banner that described it.
|
|
8
8
|
// An unattended invocation that WOULD prompt is a named refusal, never a silent proceed.
|
|
9
|
-
import { MANAGED_BY_LABEL_KEY } from "../../core/labels.js";
|
|
9
|
+
import { MANAGED_BY_LABEL_KEY, managedByOf } from "../../core/labels.js";
|
|
10
10
|
import { describeSource, preApprovalLevers, } from "../core/approval-policy.js";
|
|
11
11
|
import { isAffirmative, NOTHING_WAS_WRITTEN, readConfirmLine } from "../core/prompts.js";
|
|
12
12
|
/**
|
|
@@ -139,8 +139,8 @@ export function readManagedByMarker(body) {
|
|
|
139
139
|
if (typeof labels !== "object" || labels === null || Array.isArray(labels)) {
|
|
140
140
|
return undefined;
|
|
141
141
|
}
|
|
142
|
-
const marker = labels
|
|
143
|
-
return
|
|
142
|
+
const marker = managedByOf(labels);
|
|
143
|
+
return marker !== "" ? marker : undefined;
|
|
144
144
|
}
|
|
145
145
|
/**
|
|
146
146
|
* It states the CONSEQUENCE — the next `apply` reverts this — because that, not the marker, is what the
|
|
@@ -15,14 +15,13 @@ import { spawnSync } from "node:child_process";
|
|
|
15
15
|
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
17
|
import { StackUnreachableError, authorityBytes, readCurrentRun, readProbe, readRun, ensureDir, localDir, readRunFacts, removeIfPresent, sameAddress, stackConfigDir, addressKey, } from "../local/launcher.js";
|
|
18
|
+
import { EGRESS_CA_URL, machineValue } from "./machine-names.js";
|
|
18
19
|
/**
|
|
19
20
|
* Where a container fetches this run's certificate authority when nothing names another path. It is the
|
|
20
21
|
* agent account's own home in our runtime image, which the image contract fixes at `/home`; any other
|
|
21
22
|
* image names its own with `--trust-file`.
|
|
22
23
|
*/
|
|
23
24
|
export const DEFAULT_TRUST_FILE = "/home/.orchestra/local-authority.pem";
|
|
24
|
-
/** The name the container's own set carries the plain authority endpoint on (`local/launch/bootstrap.go`). */
|
|
25
|
-
const AUTHORITY_URL_NAME = "SFDK_EGRESS_CA_URL";
|
|
26
25
|
export async function requireLocalEnvironment(call) {
|
|
27
26
|
try {
|
|
28
27
|
await trustThisRunsAuthority(call);
|
|
@@ -116,7 +115,8 @@ function authorityEndpoint(call, env) {
|
|
|
116
115
|
// The folder is gone or unreadable, so the exported endpoint below is all there is.
|
|
117
116
|
}
|
|
118
117
|
}
|
|
119
|
-
|
|
118
|
+
// The container's own set carries the plain authority endpoint on this name (`local/launch/bootstrap.go`).
|
|
119
|
+
const exported = machineValue(env, EGRESS_CA_URL);
|
|
120
120
|
return exported === "" ? undefined : { url: exported, trustFile: DEFAULT_TRUST_FILE };
|
|
121
121
|
}
|
|
122
122
|
/**
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A machine name under the spelling a worker reads first and the former one that a worker built before the
|
|
3
|
+
* rename reads alone. Both are written, and the new one is read first, until the compatibility rules of
|
|
4
|
+
* the rename end. Each pair is the one `fwp/schema/environment-v1.json` declares.
|
|
5
|
+
*/
|
|
6
|
+
export interface MachineName {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly former: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const EGRESS_CA_URL: MachineName;
|
|
11
|
+
export declare const ENROLLMENT_TOKEN: MachineName;
|
|
12
|
+
export declare const TRANSPORT_TRUST_ROOTS: MachineName;
|
|
13
|
+
export declare const TRANSPORT_TRUST_ROOTS_FILE: MachineName;
|
|
14
|
+
/** The trimmed value under the new name, or under the former one where the new one is unset or blank. */
|
|
15
|
+
export declare function machineValue(env: NodeJS.ProcessEnv, machineName: MachineName): string;
|
|
16
|
+
/** The value under both spellings. */
|
|
17
|
+
export declare function bothSpellings(machineName: MachineName, value: string): Record<string, string>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const EGRESS_CA_URL = { name: "ORCHESTRA_EGRESS_CA_URL", former: "SFDK_EGRESS_CA_URL" };
|
|
2
|
+
export const ENROLLMENT_TOKEN = {
|
|
3
|
+
name: "ORCHESTRA_WORKER_ENROLLMENT_TOKEN",
|
|
4
|
+
former: "SFDK_ENROLLMENT_TOKEN",
|
|
5
|
+
};
|
|
6
|
+
export const TRANSPORT_TRUST_ROOTS = {
|
|
7
|
+
name: "ORCHESTRA_WORKER_TRANSPORT_TRUST_ROOTS",
|
|
8
|
+
former: "SFDK_TRANSPORT_TRUST_ROOTS",
|
|
9
|
+
};
|
|
10
|
+
export const TRANSPORT_TRUST_ROOTS_FILE = {
|
|
11
|
+
name: "ORCHESTRA_WORKER_TRANSPORT_TRUST_ROOTS_FILE",
|
|
12
|
+
former: "SFDK_TRANSPORT_TRUST_ROOTS_FILE",
|
|
13
|
+
};
|
|
14
|
+
/** The trimmed value under the new name, or under the former one where the new one is unset or blank. */
|
|
15
|
+
export function machineValue(env, machineName) {
|
|
16
|
+
const value = (env[machineName.name] ?? "").trim();
|
|
17
|
+
return value !== "" ? value : (env[machineName.former] ?? "").trim();
|
|
18
|
+
}
|
|
19
|
+
/** The value under both spellings. */
|
|
20
|
+
export function bothSpellings(machineName, value) {
|
|
21
|
+
return { [machineName.name]: value, [machineName.former]: value };
|
|
22
|
+
}
|
|
@@ -25,6 +25,7 @@ import { runWorkerBinary } from "../worker/launcher.js";
|
|
|
25
25
|
import { bootCheckOfEnrollment, evaluateChecks, latestSession, listEnrollments, parseBudgets, readAgent, readEnrollment, readEnvironment, renderChecks, } from "../local/checks.js";
|
|
26
26
|
import { ensureDir, readRun, runLocal, stackConfigDir, } from "../local/launcher.js";
|
|
27
27
|
import { DEFAULT_TRUST_FILE, composeBootstrap, mintEnrollment, placeAuthority, requireAgentId, requireEnrolledEnvironment, requireHarness, readOutpostRecord, requireLocalEnvironment, resolveRunFolder, writeOutpostRecord, } from "./local.js";
|
|
28
|
+
import { bothSpellings, EGRESS_CA_URL, ENROLLMENT_TOKEN, machineValue, TRANSPORT_TRUST_ROOTS, TRANSPORT_TRUST_ROOTS_FILE, } from "./machine-names.js";
|
|
28
29
|
/**
|
|
29
30
|
* The port every worker's token feed listens on (`worker/shim/feed.go`). One worker runs on a machine and
|
|
30
31
|
* one shim reads from it, so the two agree on a number rather than on a variable; a second outpost here
|
|
@@ -262,14 +263,14 @@ function accountNumber(args) {
|
|
|
262
263
|
* composed from is this container's own, which is where `docker run -e OPENAI_API_KEY` put it.
|
|
263
264
|
*/
|
|
264
265
|
async function prepareInContainer(deps, apis, env, agent, harness, trustFile, namedRoot) {
|
|
265
|
-
const offer = (env
|
|
266
|
+
const offer = machineValue(env, ENROLLMENT_TOKEN);
|
|
266
267
|
if (offer === "") {
|
|
267
268
|
throw new Error("this container carries no enrollment offer: run `orchestra outpost token --agent " +
|
|
268
269
|
`${agent.id}\` on the machine serving the stack and export what it prints here.`);
|
|
269
270
|
}
|
|
270
|
-
const authority = (env
|
|
271
|
+
const authority = machineValue(env, EGRESS_CA_URL);
|
|
271
272
|
if (authority === "") {
|
|
272
|
-
throw new Error(
|
|
273
|
+
throw new Error(`this container's environment names no certificate authority in ${EGRESS_CA_URL.name}, and it verifies ` +
|
|
273
274
|
"this run's certificate against one. Export what `orchestra outpost token` printed.");
|
|
274
275
|
}
|
|
275
276
|
await placeAuthority(authority, trustFile, deps.fetchApi);
|
|
@@ -278,10 +279,11 @@ async function prepareInContainer(deps, apis, env, agent, harness, trustFile, na
|
|
|
278
279
|
throw new Error("no enrollment of this run matches the offer this container carries; mint another one.");
|
|
279
280
|
}
|
|
280
281
|
const composed = workerEnvironment(env, {
|
|
281
|
-
|
|
282
|
+
...bothSpellings(TRANSPORT_TRUST_ROOTS_FILE, trustFile),
|
|
282
283
|
...emptiedCarriers(harness, env),
|
|
283
284
|
});
|
|
284
|
-
delete composed.
|
|
285
|
+
delete composed[TRANSPORT_TRUST_ROOTS.name];
|
|
286
|
+
delete composed[TRANSPORT_TRUST_ROOTS.former];
|
|
285
287
|
const home = (composed.HOME ?? "").trim();
|
|
286
288
|
const credentials = composeCredentialFiles(agent, harness, env);
|
|
287
289
|
if (credentials.length > 0 && home === "") {
|
|
@@ -3,8 +3,9 @@ export interface WorkerEnrollOptions {
|
|
|
3
3
|
root?: string;
|
|
4
4
|
}
|
|
5
5
|
/**
|
|
6
|
-
* There is deliberately no token flag. The worker reads the enrollment token from
|
|
7
|
-
* the file `
|
|
8
|
-
* listing on the machine for as long as the process runs, and it
|
|
6
|
+
* There is deliberately no token flag. The worker reads the enrollment token from
|
|
7
|
+
* `ORCHESTRA_WORKER_ENROLLMENT_TOKEN` or the file `ORCHESTRA_WORKER_ENROLLMENT_TOKEN_FILE` names, because a token
|
|
8
|
+
* on a command line is readable by every process listing on the machine for as long as the process runs, and it
|
|
9
|
+
* lands in shell history on the way in.
|
|
9
10
|
*/
|
|
10
11
|
export declare function workerEnrollCommand(options?: WorkerEnrollOptions, deps?: WorkerCommandDeps): Promise<void>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { handOffToWorker, optionalFlag } from "./launcher.js";
|
|
2
2
|
/**
|
|
3
|
-
* There is deliberately no token flag. The worker reads the enrollment token from
|
|
4
|
-
* the file `
|
|
5
|
-
* listing on the machine for as long as the process runs, and it
|
|
3
|
+
* There is deliberately no token flag. The worker reads the enrollment token from
|
|
4
|
+
* `ORCHESTRA_WORKER_ENROLLMENT_TOKEN` or the file `ORCHESTRA_WORKER_ENROLLMENT_TOKEN_FILE` names, because a token
|
|
5
|
+
* on a command line is readable by every process listing on the machine for as long as the process runs, and it
|
|
6
|
+
* lands in shell history on the way in.
|
|
6
7
|
*/
|
|
7
8
|
export async function workerEnrollCommand(options = {}, deps = {}) {
|
|
8
9
|
await handOffToWorker(["enroll", ...optionalFlag("--root", options.root)], deps);
|
package/dist/cli/core/auth.d.ts
CHANGED
|
@@ -18,10 +18,20 @@ export declare const AUTH_ENV: {
|
|
|
18
18
|
readonly tokenFile: "ORCHESTRA_API_TOKEN_FILE";
|
|
19
19
|
readonly organizationId: "ORCHESTRA_ORGANIZATION_ID";
|
|
20
20
|
/** Presence is the declaration. */
|
|
21
|
-
readonly edgeInjected: "
|
|
21
|
+
readonly edgeInjected: "ORCHESTRA_AUTH_EDGE_INJECTED";
|
|
22
22
|
/** Set by the worker on each harness process it starts. */
|
|
23
|
+
readonly sessionId: "ORCHESTRA_SESSION_ID";
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The spellings a machine set before the rename, read when the name above is absent: a running worker and a
|
|
27
|
+
* pinned runtime image still set only these.
|
|
28
|
+
*/
|
|
29
|
+
export declare const FORMER_MACHINE_ENV: {
|
|
30
|
+
readonly edgeInjected: "SFDK_AUTH_EDGE_INJECTED";
|
|
23
31
|
readonly sessionId: "SFDK_SESSION_ID";
|
|
24
32
|
};
|
|
33
|
+
/** Whether this machine declares edge authentication, under either spelling. */
|
|
34
|
+
export declare function edgeInjectedFrom(env: NodeJS.ProcessEnv): boolean;
|
|
25
35
|
/** The base URL the environment names, and which of the two variables named it. */
|
|
26
36
|
export declare function baseUrlFromEnv(env: NodeJS.ProcessEnv): {
|
|
27
37
|
readonly value: string;
|
package/dist/cli/core/auth.js
CHANGED
|
@@ -34,10 +34,22 @@ export const AUTH_ENV = {
|
|
|
34
34
|
tokenFile: "ORCHESTRA_API_TOKEN_FILE",
|
|
35
35
|
organizationId: "ORCHESTRA_ORGANIZATION_ID",
|
|
36
36
|
/** Presence is the declaration. */
|
|
37
|
-
edgeInjected: "
|
|
37
|
+
edgeInjected: "ORCHESTRA_AUTH_EDGE_INJECTED",
|
|
38
38
|
/** Set by the worker on each harness process it starts. */
|
|
39
|
+
sessionId: "ORCHESTRA_SESSION_ID",
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The spellings a machine set before the rename, read when the name above is absent: a running worker and a
|
|
43
|
+
* pinned runtime image still set only these.
|
|
44
|
+
*/
|
|
45
|
+
export const FORMER_MACHINE_ENV = {
|
|
46
|
+
edgeInjected: "SFDK_AUTH_EDGE_INJECTED",
|
|
39
47
|
sessionId: "SFDK_SESSION_ID",
|
|
40
48
|
};
|
|
49
|
+
/** Whether this machine declares edge authentication, under either spelling. */
|
|
50
|
+
export function edgeInjectedFrom(env) {
|
|
51
|
+
return firstPresent(env[AUTH_ENV.edgeInjected], env[FORMER_MACHINE_ENV.edgeInjected]) !== undefined;
|
|
52
|
+
}
|
|
41
53
|
/** The base URL the environment names, and which of the two variables named it. */
|
|
42
54
|
export function baseUrlFromEnv(env) {
|
|
43
55
|
for (const variable of [AUTH_ENV.baseUrl, AUTH_ENV.machineBaseUrl]) {
|
|
@@ -53,8 +65,13 @@ export function baseUrlFromEnv(env) {
|
|
|
53
65
|
* The only reader of the variable: auth resolution and `login` both call it.
|
|
54
66
|
*/
|
|
55
67
|
export function sessionIdFrom(env) {
|
|
56
|
-
const
|
|
57
|
-
|
|
68
|
+
for (const variable of [AUTH_ENV.sessionId, FORMER_MACHINE_ENV.sessionId]) {
|
|
69
|
+
const value = env[variable]?.trim();
|
|
70
|
+
if (value !== undefined && value !== "") {
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
58
75
|
}
|
|
59
76
|
/**
|
|
60
77
|
* The chain distinguishes "absent" from "empty", so the fields are copied conditionally: an unconditional
|
|
@@ -159,7 +176,7 @@ export async function resolveAuthWithWarnings(env, flags, deps = {}) {
|
|
|
159
176
|
const tokenFilePath = firstPresent(env[AUTH_ENV.tokenFile]);
|
|
160
177
|
const fileToken = envToken === undefined && tokenFilePath !== undefined ? tokenFromFile(tokenFilePath) : undefined;
|
|
161
178
|
const profileToken = firstPresent(profile?.token);
|
|
162
|
-
const edgeDeclared =
|
|
179
|
+
const edgeDeclared = edgeInjectedFrom(env);
|
|
163
180
|
// Read before the provider split: a person may export it by hand under any provider.
|
|
164
181
|
const sessionId = sessionIdFrom(env);
|
|
165
182
|
const withSessionId = sessionId !== undefined ? { sessionId } : {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one body rule behind `
|
|
2
|
+
* The one body rule behind `ORCHESTRA_SESSION_ID`: a JSON object body that names no `sessionId` gains the
|
|
3
3
|
* conversation id. Anything else goes out exactly as it came in: no id, a body that is not a string, a
|
|
4
4
|
* content type other than `application/json`, a body that does not parse as a JSON object, and one that
|
|
5
5
|
* already names a `sessionId`, which is how an explicit `--session-id` wins.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one body rule behind `
|
|
2
|
+
* The one body rule behind `ORCHESTRA_SESSION_ID`: a JSON object body that names no `sessionId` gains the
|
|
3
3
|
* conversation id. Anything else goes out exactly as it came in: no id, a body that is not a string, a
|
|
4
4
|
* content type other than `application/json`, a body that does not parse as a JSON object, and one that
|
|
5
5
|
* already names a `sessionId`, which is how an explicit `--session-id` wins.
|
package/dist/cli/firstRun.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// EVERYTHING THIS FILE PRINTS GOES TO STDERR, including the sign-in it runs. The interview can precede any
|
|
8
8
|
// command, and stdout is that command's result channel — under `--output json` a question printed there
|
|
9
9
|
// would land inside the document a caller is parsing.
|
|
10
|
-
import { AUTH_ENV, authFlagsFrom, firstPresent, } from "./core/auth.js";
|
|
10
|
+
import { AUTH_ENV, authFlagsFrom, edgeInjectedFrom, firstPresent, } from "./core/auth.js";
|
|
11
11
|
import { loginCommand } from "./core/login.js";
|
|
12
12
|
import { defaultIo } from "./core/output.js";
|
|
13
13
|
import { DEFAULT_PROFILE_NAME, loadProfile as loadProfileFromDisk, saveProfile as saveProfileToDisk, } from "./core/profile.js";
|
|
@@ -181,7 +181,7 @@ export async function runFirstRunIfNeeded(flags, deps = {}) {
|
|
|
181
181
|
}
|
|
182
182
|
if (firstPresent(env[AUTH_ENV.token]) !== undefined ||
|
|
183
183
|
firstPresent(env[AUTH_ENV.tokenFile]) !== undefined ||
|
|
184
|
-
|
|
184
|
+
edgeInjectedFrom(env)) {
|
|
185
185
|
return;
|
|
186
186
|
}
|
|
187
187
|
const loadProfile = deps.loadProfile ?? loadProfileFromDisk;
|
package/dist/cli/main.js
CHANGED
|
@@ -406,9 +406,10 @@ export function buildProgram(handlers = defaultHandlers, pointer = bundledSkillP
|
|
|
406
406
|
.command("enroll")
|
|
407
407
|
.option("--root <dir>", "directory this worker installed its pinned runtime and harness families into")
|
|
408
408
|
.description("redeem this machine's enrollment token, then serve it")
|
|
409
|
-
.addHelpText("after", "\nThe enrollment token is read from
|
|
410
|
-
"names
|
|
411
|
-
"
|
|
409
|
+
.addHelpText("after", "\nThe enrollment token is read from ORCHESTRA_WORKER_ENROLLMENT_TOKEN, or from the file\n" +
|
|
410
|
+
"ORCHESTRA_WORKER_ENROLLMENT_TOKEN_FILE names; the former names SFDK_ENROLLMENT_TOKEN and\n" +
|
|
411
|
+
"SFDK_ENROLLMENT_TOKEN_FILE still work. There is no flag for it: a token on the command line is readable\n" +
|
|
412
|
+
"by every process listing on the machine, and it lands in shell history on the way in.\n")
|
|
412
413
|
.action((options) => handlers.workerEnroll(options));
|
|
413
414
|
// The local environment, and the machines it runs. `up` deliberately takes NO auth flags: it starts a
|
|
414
415
|
// stack rather than reaching one, and the address and the token it prints are the stack's own answer. The
|
|
@@ -93,6 +93,7 @@ export interface RouteModelsViaGatewayOptions {
|
|
|
93
93
|
export declare class EgressPolicy {
|
|
94
94
|
private readonly defaultAction;
|
|
95
95
|
private readonly rules;
|
|
96
|
+
private gatewayKey;
|
|
96
97
|
private constructor();
|
|
97
98
|
static denyAll(): EgressPolicy;
|
|
98
99
|
/**
|
|
@@ -140,6 +141,8 @@ export declare class EgressPolicy {
|
|
|
140
141
|
* as well.
|
|
141
142
|
*/
|
|
142
143
|
routeModelsViaGateway(opts: RouteModelsViaGatewayOptions): EgressPolicy;
|
|
144
|
+
/** @internal The variables this policy names a credential in, each set to its reference. */
|
|
145
|
+
_declaredEnv(): Record<string, string>;
|
|
143
146
|
/**
|
|
144
147
|
* Each entry lands as an ordinary allow rule — the same wire shape, in the same `rules` array, in the same
|
|
145
148
|
* declaration order — so the emitted plan is byte-identical to one where the author typed every rule out by
|
|
@@ -270,18 +273,24 @@ export interface EnvironmentProps {
|
|
|
270
273
|
/** At most four, and never at `/home`. */
|
|
271
274
|
disks?: DiskMount[];
|
|
272
275
|
/**
|
|
273
|
-
* Set on every machine built from this environment and forwarded into the agent process
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
276
|
+
* Set on every machine built from this environment and forwarded into the agent process. There is no "looks
|
|
277
|
+
* like a secret" detector: one that caught most key shapes would teach authors the rest were safe. Names
|
|
278
|
+
* beginning `ORCHESTRA_` or `SFDK_` are platform-owned and refused, except ordinary `ORCHESTRA_FDK_` names and
|
|
279
|
+
* the `ORCHESTRA_WORKER_*` release settings, which reach the worker alone. `HOME` is the image's to decide;
|
|
280
|
+
* declaring it moves the harness off the mounted home. Names are compared under the target OS, so on Windows a
|
|
281
|
+
* case-variant of a reserved prefix is reserved and two keys differing only in case are one variable.
|
|
282
|
+
*
|
|
283
|
+
* A Secret or a Connection in place of text sets the reference `orchestra:secret/<id>` or
|
|
284
|
+
* `orchestra:connection/<id>`, resolved at apply, which a tool sends as its token and the egress edge
|
|
285
|
+
* replaces with that credential (`GH_TOKEN`, `GITLAB_TOKEN`, `ORCHESTRA_GIT_CREDENTIAL` for git).
|
|
286
|
+
*/
|
|
287
|
+
env?: Record<string, string | ISecret | IConnection>;
|
|
288
|
+
/**
|
|
289
|
+
* The credentials a request from this environment's machines gets when it names none, the first whose hosts
|
|
290
|
+
* cover the request's host serving. `replace` defaults to true; false leaves a request that carries its own
|
|
291
|
+
* value in the credential's header as it was sent. Omitted, apply leaves the live list alone; `[]` clears it.
|
|
283
292
|
*/
|
|
284
|
-
|
|
293
|
+
credentials?: EnvironmentCredential[];
|
|
285
294
|
/** The reference rides the environment; the value is resolved platform-side at each machine create and never
|
|
286
295
|
* leaves the control plane. */
|
|
287
296
|
imagePullCredential?: ISecret;
|
|
@@ -304,6 +313,10 @@ export interface EnvironmentProps {
|
|
|
304
313
|
/** Fixed at create; undeclared, the environment is created in Shared. */
|
|
305
314
|
team?: ITeam;
|
|
306
315
|
}
|
|
316
|
+
export interface EnvironmentCredential {
|
|
317
|
+
credential: ISecret | IConnection;
|
|
318
|
+
replace?: boolean;
|
|
319
|
+
}
|
|
307
320
|
export declare class Environment extends Construct implements IEnvironment {
|
|
308
321
|
/** Accepts a platform id or a slug interchangeably — one rule, stated on `IResource`. */
|
|
309
322
|
readonly environmentId: string;
|
|
@@ -130,10 +130,30 @@ function normalizePaths(ruleName, paths) {
|
|
|
130
130
|
}
|
|
131
131
|
return [...paths];
|
|
132
132
|
}
|
|
133
|
+
/**
|
|
134
|
+
* The variables {@link EgressPolicy.routeModelsViaGateway} sets to the key Secret's reference: claude sends the
|
|
135
|
+
* first as its key, and the worker writes the second into codex's own auth file.
|
|
136
|
+
*/
|
|
137
|
+
const GATEWAY_KEY_ENV = ["ANTHROPIC_API_KEY", "ORCHESTRA_WORKER_OPENAI_API_KEY"];
|
|
138
|
+
/** The edge reads this prefix where a request's token goes, and adds the credential the rest names. */
|
|
139
|
+
const REFERENCE_PREFIX = "orchestra:";
|
|
140
|
+
/**
|
|
141
|
+
* A Secret or a Connection as the platform names it, `secret/<id>` or `connection/<id>`, the id a reference
|
|
142
|
+
* token or the import as written until apply resolves it. Undefined for anything else.
|
|
143
|
+
*/
|
|
144
|
+
function credentialReference(value) {
|
|
145
|
+
if (typeof value !== "object" || value === null)
|
|
146
|
+
return undefined;
|
|
147
|
+
if ("connectionId" in value && typeof value.connectionId === "string")
|
|
148
|
+
return `connection/${value.connectionId}`;
|
|
149
|
+
if ("secretId" in value && typeof value.secretId === "string")
|
|
150
|
+
return `secret/${value.secretId}`;
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
133
153
|
/**
|
|
134
154
|
* A rule that still names a credential is an authored statement that can no longer apply, so it fails here
|
|
135
|
-
* with the Secret declaration that replaces it: the
|
|
136
|
-
*
|
|
155
|
+
* with the Secret declaration that replaces it: the binder's form becomes the Secret's `injection`, and the rule's
|
|
156
|
+
* hosts its `injection.hosts`.
|
|
137
157
|
*/
|
|
138
158
|
function refuseRuleCredential(ruleName, options, hosts) {
|
|
139
159
|
const declared = options.credentials !== undefined
|
|
@@ -148,7 +168,7 @@ function refuseRuleCredential(ruleName, options, hosts) {
|
|
|
148
168
|
const remedies = declared.map((entry) => secretRemedy(entry, hosts));
|
|
149
169
|
throw new Error(`egress rule '${ruleName}': a rule carries no credential, so 'credential'/'credentials' is not accepted. ` +
|
|
150
170
|
`A credential declares where and how it is presented on its own resource: ` +
|
|
151
|
-
`${remedies.length > 0 ? remedies.join("; ") : "give the Secret '
|
|
171
|
+
`${remedies.length > 0 ? remedies.join("; ") : "give the Secret an 'injection' recipe with its 'hosts'"}. ` +
|
|
152
172
|
`Then keep this rule as a network rule, with no credential.`);
|
|
153
173
|
}
|
|
154
174
|
/** The Secret declaration one binder stands for, with no value: a recipe and host names only. */
|
|
@@ -157,7 +177,7 @@ function secretRemedy(entry, hosts) {
|
|
|
157
177
|
? entry._credentialBinding()
|
|
158
178
|
: undefined;
|
|
159
179
|
if (binding === undefined)
|
|
160
|
-
return "give the Secret '
|
|
180
|
+
return "give the Secret an 'injection' recipe with its 'hosts'";
|
|
161
181
|
const name = isRefToken(binding.secretId)
|
|
162
182
|
? `Secret '${parseRefToken(binding.secretId).path.join("/")}'`
|
|
163
183
|
: `secret '${binding.secretId}'`;
|
|
@@ -166,7 +186,7 @@ function secretRemedy(entry, hosts) {
|
|
|
166
186
|
: binding.header !== undefined
|
|
167
187
|
? JSON.stringify({ headers: [{ name: binding.header, value: binding.valueTemplate }] })
|
|
168
188
|
: "its own injection recipe";
|
|
169
|
-
return `${name} takes hosts: ${JSON.stringify(hosts)}
|
|
189
|
+
return `${name} takes injection: ${injection}, with hosts: ${JSON.stringify(hosts)} inside it`;
|
|
170
190
|
}
|
|
171
191
|
/**
|
|
172
192
|
* **The builder is mutating, and every chaining method returns `this`, not a copy.** `denyAll()` or
|
|
@@ -181,6 +201,7 @@ function secretRemedy(entry, hosts) {
|
|
|
181
201
|
export class EgressPolicy {
|
|
182
202
|
defaultAction;
|
|
183
203
|
rules = [];
|
|
204
|
+
gatewayKey;
|
|
184
205
|
constructor(defaultAction) {
|
|
185
206
|
this.defaultAction = defaultAction;
|
|
186
207
|
}
|
|
@@ -300,18 +321,27 @@ export class EgressPolicy {
|
|
|
300
321
|
}
|
|
301
322
|
if (key instanceof Secret) {
|
|
302
323
|
const remedy = `Declare it as new Secret(scope, id, { value: SecretValue.fromGatewayKey(gatewayKey), ` +
|
|
303
|
-
`
|
|
304
|
-
`removeHeaders: ["x-api-key"] } }).`;
|
|
324
|
+
`injection: { headers: [{ name: "Authorization", value: "Bearer \${secret}" }], ` +
|
|
325
|
+
`removeHeaders: ["x-api-key"], hosts: ["${host}"] } }).`;
|
|
305
326
|
if (!key._presentsHeader()) {
|
|
306
327
|
throw new Error(`routeModelsViaGateway(): key Secret '${key.node.path.join("/")}' has no 'injection' header, so the ` +
|
|
307
328
|
`gateway key is never presented. ${remedy}`);
|
|
308
329
|
}
|
|
309
330
|
if (!key._hosts().some((pattern) => hostPatternCovers(pattern, host))) {
|
|
310
331
|
throw new Error(`routeModelsViaGateway(): key Secret '${key.node.path.join("/")}' does not cover '${host}' in its ` +
|
|
311
|
-
`'hosts' ${JSON.stringify(key._hosts())}, so model calls would leave with no key. ${remedy}`);
|
|
332
|
+
`'injection.hosts' ${JSON.stringify(key._hosts())}, so model calls would leave with no key. ${remedy}`);
|
|
312
333
|
}
|
|
313
334
|
}
|
|
314
|
-
|
|
335
|
+
this.allow(DEFAULT_MODEL_GATEWAY_RULE_NAME, { hosts: [host], priority: DEFAULT_MODEL_GATEWAY_PRIORITY });
|
|
336
|
+
this.gatewayKey = key;
|
|
337
|
+
return this;
|
|
338
|
+
}
|
|
339
|
+
/** @internal The variables this policy names a credential in, each set to its reference. */
|
|
340
|
+
_declaredEnv() {
|
|
341
|
+
if (this.gatewayKey === undefined)
|
|
342
|
+
return {};
|
|
343
|
+
const reference = REFERENCE_PREFIX + credentialReference(this.gatewayKey);
|
|
344
|
+
return Object.fromEntries(GATEWAY_KEY_ENV.map((name) => [name, reference]));
|
|
315
345
|
}
|
|
316
346
|
/**
|
|
317
347
|
* Each entry lands as an ordinary allow rule — the same wire shape, in the same `rules` array, in the same
|
|
@@ -403,6 +433,16 @@ export class Environment extends Construct {
|
|
|
403
433
|
constructor(scope, id, props) {
|
|
404
434
|
super(scope, id);
|
|
405
435
|
validateExplicitSlug(this, props.slug, "environment");
|
|
436
|
+
for (const [name, value] of Object.entries(props.env ?? {})) {
|
|
437
|
+
if (typeof value !== "string" && credentialReference(value) === undefined) {
|
|
438
|
+
throw new Error(`Environment '${id}': env '${name}' must be text, a Secret or a Connection.`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
props.credentials?.forEach((entry, index) => {
|
|
442
|
+
if (credentialReference(entry?.credential) === undefined) {
|
|
443
|
+
throw new Error(`Environment '${id}': credentials[${index}]: 'credential' must be a Secret or a Connection.`);
|
|
444
|
+
}
|
|
445
|
+
});
|
|
406
446
|
const declaredOnStartMachine = props.hooks?.onStartMachine?.script;
|
|
407
447
|
if (props.setup !== undefined && declaredOnStartMachine !== undefined && props.setup !== declaredOnStartMachine) {
|
|
408
448
|
throw new Error(`Environment '${id}' declares both setup and hooks.onStartMachine with different scripts. ` +
|
|
@@ -435,6 +475,9 @@ export class Environment extends Construct {
|
|
|
435
475
|
// Copied so a key added after the construct accepted the literal cannot ride a later synth into a
|
|
436
476
|
// machine's env.
|
|
437
477
|
...(props.env !== undefined ? { env: { ...props.env } } : {}),
|
|
478
|
+
...(props.credentials !== undefined
|
|
479
|
+
? { credentials: props.credentials.map((entry) => ({ ...entry })) }
|
|
480
|
+
: {}),
|
|
438
481
|
};
|
|
439
482
|
this.explicitSlug = props.slug;
|
|
440
483
|
this.displayName = props.name ?? id;
|
|
@@ -464,6 +507,19 @@ export class Environment extends Construct {
|
|
|
464
507
|
const resources = this.props.resources ?? {};
|
|
465
508
|
const cpu = resources.cpu ?? STANDARD_CPU;
|
|
466
509
|
const onStartMachine = this.props.hooks?.onStartMachine ?? (this.props.setup ? { script: this.props.setup } : undefined);
|
|
510
|
+
// A FRESH object each synth: emitting the stored map by reference would let a caller mutating one returned
|
|
511
|
+
// plan's `payload.env` change every later synth.
|
|
512
|
+
const env = {};
|
|
513
|
+
for (const [name, value] of Object.entries(this.props.env ?? {})) {
|
|
514
|
+
env[name] = typeof value === "string" ? value : REFERENCE_PREFIX + credentialReference(value);
|
|
515
|
+
}
|
|
516
|
+
for (const [name, reference] of Object.entries(this.props.egress._declaredEnv())) {
|
|
517
|
+
if (env[name] !== undefined && env[name] !== reference) {
|
|
518
|
+
throw new Error(`Environment '${this.node.id}': env '${name}' is declared by routeModelsViaGateway() as the gateway ` +
|
|
519
|
+
`key's reference, ${reference}. Drop it from env.`);
|
|
520
|
+
}
|
|
521
|
+
env[name] = reference;
|
|
522
|
+
}
|
|
467
523
|
// The egress rules, `ingressPolicy.endpoints`, `volumeMounts` and `env` are ALWAYS emitted — empty when
|
|
468
524
|
// unset — so clearing any of them reconciles through the platform's whole-object PATCH replace instead of
|
|
469
525
|
// being masked as omitted. `env` inherits the known map residual: emptying a declared map as the SOLE
|
|
@@ -518,9 +574,7 @@ export class Environment extends Construct {
|
|
|
518
574
|
}),
|
|
519
575
|
...(this.props.disks ?? []).map((disk) => ({ type: "disk", mountPath: disk.mountPath, size: disk.size })),
|
|
520
576
|
],
|
|
521
|
-
|
|
522
|
-
// returned plan's `payload.env` change every later synth.
|
|
523
|
-
env: { ...(this.props.env ?? {}) },
|
|
577
|
+
env,
|
|
524
578
|
// Always emitted — a token or an explicit null — so removing the credential reconciles to a cleared
|
|
525
579
|
// field. An omitted key would be dropped by JSON.stringify and the PATCH would read "unchanged".
|
|
526
580
|
imagePullSecretId: this.props.imagePullCredential?.secretId ?? null,
|
|
@@ -531,6 +585,16 @@ export class Environment extends Construct {
|
|
|
531
585
|
? { computeProviderId: this.props.computeProvider?.computeProviderId ?? null }
|
|
532
586
|
: {}),
|
|
533
587
|
},
|
|
588
|
+
// Only when declared, `[]` included: an omitted list must not clear defaults set in the console or by
|
|
589
|
+
// the platform's own seed.
|
|
590
|
+
...(this.props.credentials !== undefined
|
|
591
|
+
? {
|
|
592
|
+
credentials: this.props.credentials.map((entry) => ({
|
|
593
|
+
credential: credentialReference(entry.credential),
|
|
594
|
+
replace: entry.replace ?? true,
|
|
595
|
+
})),
|
|
596
|
+
}
|
|
597
|
+
: {}),
|
|
534
598
|
...(this.props.team !== undefined ? { teamId: this.props.team.teamId } : {}),
|
|
535
599
|
};
|
|
536
600
|
return { kind: "environment", path: this.node.path.join("/"), slug, managed: true, payload };
|
|
@@ -3,9 +3,14 @@ import { Construct } from "../core/construct.js";
|
|
|
3
3
|
import type { PlanResource, ValueFrom } from "../core/plan.js";
|
|
4
4
|
import type { CredentialBindingData, IGatewayKey, ISecret, ITeam } from "./interfaces.js";
|
|
5
5
|
/**
|
|
6
|
-
* The request mutations the broker applies from this secret: `headers` are injected after credential
|
|
7
|
-
* rendering, `removeHeaders` strips client-sent headers
|
|
8
|
-
*
|
|
6
|
+
* The request mutations the broker applies from this secret, and where: `headers` are injected after credential
|
|
7
|
+
* rendering, `removeHeaders` strips client-sent headers first, and `hosts` names the hosts the recipe applies on.
|
|
8
|
+
* Both lists are optional here and `[]` on the wire, so an inject-only recipe round-trips against the server's
|
|
9
|
+
* shape. A secret with no recipe is stored only: the broker presents it on no request.
|
|
10
|
+
*
|
|
11
|
+
* For git over HTTPS, store `base64("<user>:<token>")` and present it as `Basic ${secret}`; GitHub's user is
|
|
12
|
+
* `x-access-token`. Encode it first, `printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n'`, or let
|
|
13
|
+
* `SecretValue.basicAuth` encode it at apply.
|
|
9
14
|
*/
|
|
10
15
|
export interface InjectionRecipeProps {
|
|
11
16
|
readonly headers: {
|
|
@@ -13,6 +18,12 @@ export interface InjectionRecipeProps {
|
|
|
13
18
|
readonly value: string;
|
|
14
19
|
}[];
|
|
15
20
|
readonly removeHeaders?: string[];
|
|
21
|
+
/**
|
|
22
|
+
* The hosts this secret is presented on, in egress rule host syntax: an exact host, `*.suffix`, or `*`. A
|
|
23
|
+
* request to a covered host gets the `headers` at the egress edge; an environment's rules only decide whether
|
|
24
|
+
* the request may leave. Omitted, the recipe covers no host.
|
|
25
|
+
*/
|
|
26
|
+
readonly hosts?: string[];
|
|
16
27
|
}
|
|
17
28
|
/**
|
|
18
29
|
* Shared with the egress `inject()` binding factory so a bad recipe fails at synth identically whether it
|
|
@@ -84,12 +95,11 @@ export interface SecretProps {
|
|
|
84
95
|
slug?: string;
|
|
85
96
|
name?: string;
|
|
86
97
|
description?: string;
|
|
98
|
+
/** How and where the secret is presented. Omitted, the secret is stored only. */
|
|
87
99
|
injection?: InjectionRecipeProps;
|
|
88
100
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* decide whether the request may leave. Omitted, the secret covers no host, and apply sends `[]`, so
|
|
92
|
-
* removing the prop clears the list.
|
|
101
|
+
* @deprecated Declare `injection.hosts` instead. Accepted for one release and moved into the recipe; refused
|
|
102
|
+
* when the recipe declares a different list.
|
|
93
103
|
*/
|
|
94
104
|
hosts?: string[];
|
|
95
105
|
/** Fixed at create; undeclared, the secret is created in Shared. */
|