@orchestraworks/cli 0.5.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/api/approval.js +3 -3
  3. package/dist/cli/commands/outpost/local.js +3 -3
  4. package/dist/cli/commands/outpost/machine-names.d.ts +17 -0
  5. package/dist/cli/commands/outpost/machine-names.js +22 -0
  6. package/dist/cli/commands/outpost/up.js +7 -5
  7. package/dist/cli/commands/worker/enroll.d.ts +4 -3
  8. package/dist/cli/commands/worker/enroll.js +4 -3
  9. package/dist/cli/core/auth.d.ts +11 -1
  10. package/dist/cli/core/auth.js +21 -4
  11. package/dist/cli/core/session-body.d.ts +1 -1
  12. package/dist/cli/core/session-body.js +1 -1
  13. package/dist/cli/firstRun.js +2 -2
  14. package/dist/cli/main.js +4 -3
  15. package/dist/constructs/environment.d.ts +24 -11
  16. package/dist/constructs/environment.js +76 -12
  17. package/dist/constructs/secret.d.ts +17 -7
  18. package/dist/constructs/secret.js +24 -16
  19. package/dist/core/labels.d.ts +32 -6
  20. package/dist/core/labels.js +70 -8
  21. package/dist/engine/diff.js +38 -11
  22. package/dist/engine/gateway-key-reconcile.d.ts +1 -2
  23. package/dist/engine/gateway-key-reconcile.js +19 -15
  24. package/dist/engine/pipeline-reconcile.js +2 -2
  25. package/dist/engine/pipeline-workflow-reconcile.js +15 -9
  26. package/dist/engine/reconcile.js +48 -15
  27. package/dist/engine/refusals.d.ts +0 -5
  28. package/dist/engine/refusals.js +1 -5
  29. package/dist/engine/secrets-apply.d.ts +10 -14
  30. package/dist/engine/secrets-apply.js +12 -17
  31. package/dist/engine/workflow-reconcile.d.ts +1 -1
  32. package/dist/generated/cli-commands.json +170 -34
  33. package/dist/generated/providers.d.ts +40 -0
  34. package/dist/generated/providers.js +30 -0
  35. package/docs/REFERENCE.md +9 -7
  36. package/node_modules/@orchestraworks/client/dist/esm/models/ApiErrorDetail.d.ts +1 -1
  37. package/node_modules/@orchestraworks/client/dist/esm/models/CreateEnvironmentRequest.d.ts +7 -0
  38. package/node_modules/@orchestraworks/client/dist/esm/models/CreateEnvironmentRequest.js +3 -0
  39. package/node_modules/@orchestraworks/client/dist/esm/models/CreateSecretRequest.d.ts +4 -3
  40. package/node_modules/@orchestraworks/client/dist/esm/models/Environment.d.ts +7 -0
  41. package/node_modules/@orchestraworks/client/dist/esm/models/Environment.js +3 -0
  42. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentCredential.d.ts +38 -0
  43. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentCredential.js +45 -0
  44. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentProvisionSpec.d.ts +1 -1
  45. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentProvisionSpecRequest.d.ts +1 -1
  46. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentSnapshot.d.ts +1 -1
  47. package/node_modules/@orchestraworks/client/dist/esm/models/InjectionRecipe.d.ts +6 -0
  48. package/node_modules/@orchestraworks/client/dist/esm/models/InjectionRecipe.js +2 -0
  49. package/node_modules/@orchestraworks/client/dist/esm/models/OAuthTokenRequest.d.ts +1 -1
  50. package/node_modules/@orchestraworks/client/dist/esm/models/Secret.d.ts +1 -7
  51. package/node_modules/@orchestraworks/client/dist/esm/models/Secret.js +0 -4
  52. package/node_modules/@orchestraworks/client/dist/esm/models/UpdateEnvironmentRequest.d.ts +7 -0
  53. package/node_modules/@orchestraworks/client/dist/esm/models/UpdateEnvironmentRequest.js +3 -0
  54. package/node_modules/@orchestraworks/client/dist/esm/models/UpdateSecretRequest.d.ts +4 -3
  55. package/node_modules/@orchestraworks/client/dist/esm/models/index.d.ts +1 -0
  56. package/node_modules/@orchestraworks/client/dist/esm/models/index.js +1 -0
  57. package/node_modules/@orchestraworks/client/dist/models/ApiErrorDetail.d.ts +1 -1
  58. package/node_modules/@orchestraworks/client/dist/models/CreateEnvironmentRequest.d.ts +7 -0
  59. package/node_modules/@orchestraworks/client/dist/models/CreateEnvironmentRequest.js +3 -0
  60. package/node_modules/@orchestraworks/client/dist/models/CreateSecretRequest.d.ts +4 -3
  61. package/node_modules/@orchestraworks/client/dist/models/Environment.d.ts +7 -0
  62. package/node_modules/@orchestraworks/client/dist/models/Environment.js +3 -0
  63. package/node_modules/@orchestraworks/client/dist/models/EnvironmentCredential.d.ts +38 -0
  64. package/node_modules/@orchestraworks/client/dist/models/EnvironmentCredential.js +52 -0
  65. package/node_modules/@orchestraworks/client/dist/models/EnvironmentProvisionSpec.d.ts +1 -1
  66. package/node_modules/@orchestraworks/client/dist/models/EnvironmentProvisionSpecRequest.d.ts +1 -1
  67. package/node_modules/@orchestraworks/client/dist/models/EnvironmentSnapshot.d.ts +1 -1
  68. package/node_modules/@orchestraworks/client/dist/models/InjectionRecipe.d.ts +6 -0
  69. package/node_modules/@orchestraworks/client/dist/models/InjectionRecipe.js +2 -0
  70. package/node_modules/@orchestraworks/client/dist/models/OAuthTokenRequest.d.ts +1 -1
  71. package/node_modules/@orchestraworks/client/dist/models/Secret.d.ts +1 -7
  72. package/node_modules/@orchestraworks/client/dist/models/Secret.js +0 -4
  73. package/node_modules/@orchestraworks/client/dist/models/UpdateEnvironmentRequest.d.ts +7 -0
  74. package/node_modules/@orchestraworks/client/dist/models/UpdateEnvironmentRequest.js +3 -0
  75. package/node_modules/@orchestraworks/client/dist/models/UpdateSecretRequest.d.ts +4 -3
  76. package/node_modules/@orchestraworks/client/dist/models/index.d.ts +1 -0
  77. package/node_modules/@orchestraworks/client/dist/models/index.js +1 -0
  78. package/node_modules/@orchestraworks/client/package.json +1 -1
  79. package/package.json +4 -4
  80. package/skills/orchestra/SKILL.md +132 -18
  81. package/skills/orchestra-fdk/references/authoring.md +511 -20
  82. package/skills/orchestra-fdk/references/environment.md +49 -12
  83. package/skills/orchestra-fdk/references/integrations/code-repo.md +26 -5
  84. package/skills/orchestra-fdk/references/phases/1-discover.md +1 -1
  85. package/skills/orchestra-fdk/references/phases/2-plan.md +1 -1
  86. package/skills/orchestra-fdk/references/troubleshooting.md +3 -2
  87. package/templates/bug-fix/main.ts +7 -4
  88. package/templates/engineering-team/lib/environment.ts +6 -3
  89. package/templates/engineering-team/package.json +1 -1
  90. package/templates/todo-factory/main.ts +7 -4
  91. 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.5.8 # this exact release
23
+ npm install @orchestraworks/cli@0.7.0 # this exact release
24
24
  ```
25
25
 
26
26
  Then `import { Factory, Agent, … } from "@orchestraworks/cli"` normally — the package `exports` entry resolves
@@ -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[MANAGED_BY_LABEL_KEY];
143
- return typeof marker === "string" && marker !== "" ? marker : undefined;
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
- const exported = (env[AUTHORITY_URL_NAME] ?? "").trim();
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.SFDK_ENROLLMENT_TOKEN ?? "").trim();
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.SFDK_EGRESS_CA_URL ?? "").trim();
271
+ const authority = machineValue(env, EGRESS_CA_URL);
271
272
  if (authority === "") {
272
- throw new Error("this container's environment names no certificate authority in SFDK_EGRESS_CA_URL, and it verifies " +
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
- SFDK_TRANSPORT_TRUST_ROOTS_FILE: trustFile,
282
+ ...bothSpellings(TRANSPORT_TRUST_ROOTS_FILE, trustFile),
282
283
  ...emptiedCarriers(harness, env),
283
284
  });
284
- delete composed.SFDK_TRANSPORT_TRUST_ROOTS;
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 `SFDK_ENROLLMENT_TOKEN` or
7
- * the file `SFDK_ENROLLMENT_TOKEN_FILE` names, because a token on a command line is readable by every process
8
- * listing on the machine for as long as the process runs, and it lands in shell history on the way in.
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 `SFDK_ENROLLMENT_TOKEN` or
4
- * the file `SFDK_ENROLLMENT_TOKEN_FILE` names, because a token on a command line is readable by every process
5
- * listing on the machine for as long as the process runs, and it lands in shell history on the way in.
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);
@@ -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: "SFDK_AUTH_EDGE_INJECTED";
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;
@@ -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: "SFDK_AUTH_EDGE_INJECTED",
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 value = env[AUTH_ENV.sessionId]?.trim();
57
- return value === undefined || value === "" ? undefined : value;
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 = firstPresent(env[AUTH_ENV.edgeInjected]) !== undefined;
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 `SFDK_SESSION_ID`: a JSON object body that names no `sessionId` gains the
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 `SFDK_SESSION_ID`: a JSON object body that names no `sessionId` gains the
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.
@@ -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
- firstPresent(env[AUTH_ENV.edgeInjected]) !== undefined) {
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 SFDK_ENROLLMENT_TOKEN, or from the file SFDK_ENROLLMENT_TOKEN_FILE\n" +
410
- "names. There is no flag for it: a token on the command line is readable by every process listing on\n" +
411
- "the machine, and it lands in shell history on the way in.\n")
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 itself. There is
274
- * deliberately no "looks like a secret" detector on them: a checker that caught most API-key shapes would
275
- * teach authors that the ones it missed were safe. Names beginning `SFDK_` are platform-owned, written last
276
- * and rejected with a 400, except the four `SFDK_WORKER_*` release settings, which reach the worker alone. `HOME` is not a platform
277
- * variable at all — the account inside the runtime image decides where the home directory is — so declaring
278
- * it points the harness elsewhere, and a home off the mounted volume does not survive a machine recycle.
279
- * `ANTHROPIC_BASE_URL` is not reserved; the way to point a harness at the model gateway is the agent's
280
- * `modelSettings.enableModelGateway`. Whether two names are the same name is keyed on the TARGET OS, which makes two rejections real on
281
- * Windows that cannot happen on POSIX: a case-variant of the reserved prefix is reserved, and two keys
282
- * differing only in case are one variable, so the API keeps neither.
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
- env?: Record<string, string>;
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 rule's hosts become the Secret's `hosts`, and the binder's
136
- * form becomes its `injection`.
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 'hosts' and an 'injection' recipe"}. ` +
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 'hosts' and an 'injection' recipe";
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)} and injection: ${injection}`;
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
- `hosts: ["${host}"], injection: { headers: [{ name: "Authorization", value: "Bearer \${secret}" }], ` +
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
- return this.allow(DEFAULT_MODEL_GATEWAY_RULE_NAME, { hosts: [host], priority: DEFAULT_MODEL_GATEWAY_PRIORITY });
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
- // A FRESH object each synth: emitting the stored map by reference would let a caller mutating one
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 before injection. `removeHeaders` is optional here
8
- * and normalized to `[]` on the wire, so an inject-only recipe round-trips against the server's shape.
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
- * The hosts this secret is presented on, in egress rule host syntax: an exact host, `*.suffix`, or `*`. A
90
- * request to a covered host gets the `injection` headers at the egress edge; an environment's rules only
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. */