stageflow 0.35.0 → 0.36.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 (94) hide show
  1. package/dist/agent/claudeAdapter.js +6 -0
  2. package/dist/agent/piAdapter.js +9 -0
  3. package/dist/agent/port.d.ts +2 -0
  4. package/dist/channels/config.d.ts +11 -0
  5. package/dist/channels/config.js +95 -0
  6. package/dist/channels/configWriter.d.ts +40 -0
  7. package/dist/channels/configWriter.js +148 -0
  8. package/dist/channels/connectionEdits.d.ts +34 -0
  9. package/dist/channels/connectionEdits.js +53 -0
  10. package/dist/channels/errorInfo.d.ts +18 -0
  11. package/dist/channels/errorInfo.js +71 -0
  12. package/dist/channels/errors.d.ts +12 -0
  13. package/dist/channels/errors.js +13 -0
  14. package/dist/channels/health.d.ts +41 -0
  15. package/dist/channels/health.js +99 -0
  16. package/dist/channels/host.d.ts +55 -0
  17. package/dist/channels/host.js +185 -0
  18. package/dist/channels/http.d.ts +31 -0
  19. package/dist/channels/http.js +79 -0
  20. package/dist/channels/index.d.ts +10 -0
  21. package/dist/channels/index.js +9 -0
  22. package/dist/channels/ledger.d.ts +37 -0
  23. package/dist/channels/ledger.js +95 -0
  24. package/dist/channels/providers/agentmail.d.ts +26 -0
  25. package/dist/channels/providers/agentmail.js +81 -0
  26. package/dist/channels/providers/kit.d.ts +40 -0
  27. package/dist/channels/providers/kit.js +78 -0
  28. package/dist/channels/providers/photonImessage.d.ts +45 -0
  29. package/dist/channels/providers/photonImessage.js +152 -0
  30. package/dist/channels/providers/photonWhatsapp.d.ts +49 -0
  31. package/dist/channels/providers/photonWhatsapp.js +146 -0
  32. package/dist/channels/providers/slack.d.ts +25 -0
  33. package/dist/channels/providers/slack.js +101 -0
  34. package/dist/channels/providers/telegram.d.ts +21 -0
  35. package/dist/channels/providers/telegram.js +100 -0
  36. package/dist/channels/providers.d.ts +5 -0
  37. package/dist/channels/providers.js +22 -0
  38. package/dist/channels/registry.d.ts +54 -0
  39. package/dist/channels/registry.js +186 -0
  40. package/dist/channels/secretStore.d.ts +21 -0
  41. package/dist/channels/secretStore.js +75 -0
  42. package/dist/channels/secrets.d.ts +15 -0
  43. package/dist/channels/secrets.js +50 -0
  44. package/dist/channels/service.d.ts +54 -0
  45. package/dist/channels/service.js +307 -0
  46. package/dist/channels/stageChannels.d.ts +45 -0
  47. package/dist/channels/stageChannels.js +110 -0
  48. package/dist/channels/types.d.ts +142 -0
  49. package/dist/channels/types.js +1 -0
  50. package/dist/cli/doctorCommand.js +3 -1
  51. package/dist/config/draftPackage.d.ts +1 -0
  52. package/dist/config/draftPackage.js +4 -0
  53. package/dist/config/hostConfig.js +1 -0
  54. package/dist/config/loadPipeline.js +27 -3
  55. package/dist/config/loadStage.js +15 -0
  56. package/dist/config/mergePipelineIncludes.d.ts +3 -0
  57. package/dist/config/mergePipelineIncludes.js +27 -0
  58. package/dist/config/pipelineStageKeys.js +1 -0
  59. package/dist/config/validateCatalog.d.ts +4 -1
  60. package/dist/config/validateCatalog.js +23 -0
  61. package/dist/errors/codes.d.ts +1 -1
  62. package/dist/errors/codes.js +1 -0
  63. package/dist/mcp/catalogTools.js +1 -0
  64. package/dist/package-meta.d.ts +1 -1
  65. package/dist/package-meta.js +1 -1
  66. package/dist/preflight/channelChecks.d.ts +17 -0
  67. package/dist/preflight/channelChecks.js +106 -0
  68. package/dist/preflight/pipelinePreflight.d.ts +9 -2
  69. package/dist/preflight/pipelinePreflight.js +9 -1
  70. package/dist/runtime/pipelineRunner.js +3 -1
  71. package/dist/runtime/repositoryMaterialize.d.ts +1 -1
  72. package/dist/runtime/runManager.js +4 -1
  73. package/dist/runtime/stageAttemptBootstrap.js +16 -0
  74. package/dist/runtime/stageProcessLauncher.js +31 -1
  75. package/dist/server/bootstrap.d.ts +3 -0
  76. package/dist/server/bootstrap.js +4 -0
  77. package/dist/server/channelRoutes.d.ts +9 -0
  78. package/dist/server/channelRoutes.js +88 -0
  79. package/dist/server/http.js +11 -0
  80. package/dist/tools/readEmail.d.ts +3 -3
  81. package/dist/tools/sendMessage.d.ts +21 -0
  82. package/dist/tools/sendMessage.js +25 -0
  83. package/dist/types/pipeline.d.ts +4 -1
  84. package/dist/types/stage.d.ts +13 -0
  85. package/dist/ui/assets/index-BOhqnPNg.js +260 -0
  86. package/dist/ui/assets/{index-CIPbbBUi.css → index-Clv4sJpU.css} +1 -1
  87. package/dist/ui/index.html +2 -2
  88. package/examples/channels-smoke/README.md +12 -0
  89. package/examples/channels-smoke/channels-smoke.pipeline.yaml +11 -0
  90. package/examples/channels-smoke/channels-smoke.task.yaml +3 -0
  91. package/examples/channels-smoke/send-email.yaml +32 -0
  92. package/examples/channels-smoke/send-text.yaml +31 -0
  93. package/package.json +8 -3
  94. package/dist/ui/assets/index-BoFPkKFg.js +0 -256
@@ -0,0 +1,101 @@
1
+ import { z } from "zod";
2
+ import { ChannelError } from "../errors.js";
3
+ import { httpCall, parseJson, statusFailure } from "../http.js";
4
+ import { healthOf, receiptFor, zodValidator } from "./kit.js";
5
+ export const SLACK_BASE_URL = "https://slack.com/api";
6
+ export const SLACK_META = {
7
+ label: "Slack",
8
+ blurb: "Not connected · post as a bot",
9
+ icon: "hash",
10
+ fields: [
11
+ { key: "botToken", label: "Bot token", hint: "xoxb-… from your Slack app (OAuth & Permissions). Needs chat:write.", target: "secret", input: "password", required: true, placeholder: "xoxb-..." },
12
+ { key: "teamId", label: "Workspace ID (optional)", hint: "Pins the connection to one workspace, such as T0123ABCD.", target: "config", input: "text", required: false, placeholder: "T0123ABCD" },
13
+ ],
14
+ recipient: { label: "Channel id", placeholder: "C0123ABCD (channel, group, DM or user id)", multiple: false, cc: false, template: false },
15
+ envNames: ["SLACK_BOT_TOKEN"],
16
+ yamlExample: `connections:
17
+ - id: ops-slack
18
+ provider: slack
19
+ secrets:
20
+ botToken: env:SLACK_BOT_TOKEN
21
+ `,
22
+ stageExample: " to: C0123ABCD",
23
+ };
24
+ const configSchema = z.object({ teamId: z.string().regex(/^T[A-Z0-9]{2,31}$/).optional() }).strict();
25
+ const targetSchema = z.object({ to: z.string().regex(/^[CGDU][A-Z0-9]{2,31}$/) }).strict();
26
+ const validateConfig = zodValidator(configSchema, "config");
27
+ const validateTarget = zodValidator(targetSchema, "target", (data) => ({ channel: data.to }));
28
+ const AUTH_ERRORS = new Set(["invalid_auth", "not_authed", "account_inactive", "token_revoked", "token_expired"]);
29
+ const SCOPE_ERRORS = new Set(["missing_scope", "not_allowed_token_type", "no_permission", "restricted_action", "access_denied"]);
30
+ const RECIPIENT_ERRORS = new Set([
31
+ "channel_not_found", "user_not_found", "is_archived", "not_in_channel", "cannot_dm_bot", "user_disabled",
32
+ "restricted_action_read_only_channel", "restricted_action_thread_only_channel", "channel_is_archived",
33
+ ]);
34
+ const TRANSIENT_ERRORS = new Set(["internal_error", "fatal_error", "service_unavailable", "request_timeout"]);
35
+ /** Maps a Slack `ok: false` error name. Names are mapped, never echoed. */
36
+ export function slackFailure(name, mode, connectionId) {
37
+ const detail = { connectionId };
38
+ if (typeof name !== "string")
39
+ return new ChannelError(mode === "send" ? "CHANNEL_SEND_OUTCOME_UNKNOWN" : "CHANNEL_CONNECTION_FAILED", mode !== "send", detail);
40
+ if (AUTH_ERRORS.has(name))
41
+ return new ChannelError("CHANNEL_AUTH_FAILED", false, detail);
42
+ if (SCOPE_ERRORS.has(name))
43
+ return new ChannelError("CHANNEL_UNAUTHORIZED", false, detail);
44
+ if (RECIPIENT_ERRORS.has(name))
45
+ return new ChannelError("CHANNEL_RECIPIENT_REJECTED", false, detail);
46
+ if (name === "ratelimited" || name === "rate_limited")
47
+ return new ChannelError("CHANNEL_RATE_LIMIT", true, detail);
48
+ if (TRANSIENT_ERRORS.has(name)) {
49
+ return mode === "send" ? new ChannelError("CHANNEL_SEND_OUTCOME_UNKNOWN", false, detail) : new ChannelError("CHANNEL_CONNECTION_FAILED", true, detail);
50
+ }
51
+ return new ChannelError("CHANNEL_INVALID_INPUT", false, detail);
52
+ }
53
+ /**
54
+ * Posts as a Slack bot through `chat.postMessage`. The bot needs `chat:write`; posting to a
55
+ * public channel it has not joined also needs `chat:write.public`, and a direct message to a
56
+ * user id needs `im:write`. Receiving is not part of this phase.
57
+ */
58
+ export function createSlackProvider(options = {}) {
59
+ const doFetch = options.fetch ?? fetch;
60
+ const base = options.baseUrl ?? SLACK_BASE_URL;
61
+ const headers = (connection) => ({ authorization: `Bearer ${connection.secrets.botToken}`, accept: "application/json" });
62
+ return {
63
+ id: "slack",
64
+ meta: SLACK_META,
65
+ operations: ["send"],
66
+ secrets: { required: ["botToken"] },
67
+ validateConfig,
68
+ validateTarget,
69
+ describe: config => (config.teamId ? { identity: config.teamId } : {}),
70
+ check: (connection, signal) => healthOf(async () => {
71
+ const result = await httpCall({
72
+ fetch: doFetch, url: `${base}/auth.test`, method: "POST", headers: headers(connection),
73
+ timeoutMs: options.checkTimeoutMs ?? 10000, signal, mode: "check", connectionId: connection.id,
74
+ });
75
+ if (result.status < 200 || result.status >= 300)
76
+ throw statusFailure(result.status, "check", connection.id);
77
+ const body = parseJson(result.text);
78
+ if (body?.ok !== true)
79
+ throw slackFailure(body?.error, "check", connection.id);
80
+ if (connection.config.teamId !== undefined && body.team_id !== connection.config.teamId) {
81
+ throw new ChannelError("CHANNEL_INVALID_INPUT", false, { connectionId: connection.id, field: "teamId" });
82
+ }
83
+ }),
84
+ async send(connection, target, message, _context) {
85
+ const text = message.subject ? `*${message.subject}*\n${message.text}` : message.text;
86
+ const result = await httpCall({
87
+ fetch: doFetch, url: `${base}/chat.postMessage`, method: "POST", headers: headers(connection),
88
+ body: { channel: target.channel, text, link_names: false, unfurl_links: false, unfurl_media: false },
89
+ timeoutMs: options.sendTimeoutMs ?? 30000, mode: "send", connectionId: connection.id,
90
+ });
91
+ if (result.status < 200 || result.status >= 300)
92
+ throw statusFailure(result.status, "send", connection.id);
93
+ const body = parseJson(result.text);
94
+ if (body === undefined)
95
+ throw new ChannelError("CHANNEL_SEND_OUTCOME_UNKNOWN", false, { connectionId: connection.id });
96
+ if (body.ok !== true)
97
+ throw slackFailure(body.error, "send", connection.id);
98
+ return receiptFor(typeof body.ts === "string" ? body.ts : undefined);
99
+ },
100
+ };
101
+ }
@@ -0,0 +1,21 @@
1
+ import type { ChannelProvider, ProviderMeta } from "../types.js";
2
+ export type TelegramConfig = {
3
+ botUsername?: string;
4
+ };
5
+ export type TelegramTarget = {
6
+ chatId: string | number;
7
+ };
8
+ export declare const TELEGRAM_BASE_URL = "https://api.telegram.org";
9
+ export declare const TELEGRAM_MAX_TEXT = 4096;
10
+ export declare const TELEGRAM_META: ProviderMeta;
11
+ export type TelegramOptions = {
12
+ fetch?: typeof fetch;
13
+ baseUrl?: string;
14
+ sendTimeoutMs?: number;
15
+ checkTimeoutMs?: number;
16
+ };
17
+ /**
18
+ * Sends through the Telegram Bot API with a bot token. The recipient must have started the
19
+ * bot (or the bot must be a member of the group or channel). Receiving is not part of this phase.
20
+ */
21
+ export declare function createTelegramProvider(options?: TelegramOptions): ChannelProvider<TelegramConfig, TelegramTarget>;
@@ -0,0 +1,100 @@
1
+ import { z } from "zod";
2
+ import { ChannelError } from "../errors.js";
3
+ import { httpCall, parseJson, statusFailure } from "../http.js";
4
+ import { healthOf, receiptFor, zodValidator } from "./kit.js";
5
+ export const TELEGRAM_BASE_URL = "https://api.telegram.org";
6
+ export const TELEGRAM_MAX_TEXT = 4096;
7
+ export const TELEGRAM_META = {
8
+ label: "Telegram",
9
+ blurb: "Not connected · bot token",
10
+ icon: "send",
11
+ fields: [
12
+ { key: "botToken", label: "Bot token", hint: "From @BotFather. People must start the bot before it can message them.", target: "secret", input: "password", required: true, placeholder: "123456:ABC..." },
13
+ { key: "botUsername", label: "Bot username (optional)", hint: "Fails the check if the token belongs to another bot.", target: "config", input: "text", required: false, placeholder: "acme_alerts_bot" },
14
+ ],
15
+ recipient: { label: "Chat id", placeholder: "123456789 or @channelname", multiple: false, cc: false, template: false },
16
+ envNames: ["TELEGRAM_BOT_TOKEN"],
17
+ yamlExample: `connections:
18
+ - id: alerts-telegram
19
+ provider: telegram
20
+ secrets:
21
+ botToken: env:TELEGRAM_BOT_TOKEN
22
+ `,
23
+ stageExample: ' to: "123456789"',
24
+ };
25
+ const configSchema = z.object({ botUsername: z.string().regex(/^[A-Za-z][A-Za-z0-9_]{4,31}$/).optional() }).strict();
26
+ const chatId = z.union([
27
+ z.number().int().refine(Number.isSafeInteger),
28
+ z.string().regex(/^-?\d{1,20}$/),
29
+ z.string().regex(/^@[A-Za-z][A-Za-z0-9_]{4,31}$/),
30
+ ]);
31
+ const targetSchema = z.object({ to: chatId }).strict();
32
+ const validateConfig = zodValidator(configSchema, "config");
33
+ const validateTarget = zodValidator(targetSchema, "target", (data) => ({ chatId: data.to }));
34
+ /**
35
+ * Telegram mirrors its error in the HTTP status. A bot cannot message a user who has not
36
+ * started it or who blocked it (403), and an unknown chat is a 400. The description is only
37
+ * matched, never echoed.
38
+ */
39
+ function telegramFailure(status, body, mode, connectionId) {
40
+ const detail = { connectionId };
41
+ const description = typeof body?.description === "string" ? body.description.toLowerCase() : "";
42
+ if (status === 404)
43
+ return new ChannelError("CHANNEL_AUTH_FAILED", false, detail);
44
+ if (status === 403 && mode === "send")
45
+ return new ChannelError("CHANNEL_RECIPIENT_REJECTED", false, detail);
46
+ if (status === 400 && mode === "send" && /chat not found|user not found|peer_id_invalid|chat_id is empty/.test(description)) {
47
+ return new ChannelError("CHANNEL_RECIPIENT_REJECTED", false, detail);
48
+ }
49
+ return statusFailure(status, mode, connectionId);
50
+ }
51
+ /**
52
+ * Sends through the Telegram Bot API with a bot token. The recipient must have started the
53
+ * bot (or the bot must be a member of the group or channel). Receiving is not part of this phase.
54
+ */
55
+ export function createTelegramProvider(options = {}) {
56
+ const doFetch = options.fetch ?? fetch;
57
+ const base = options.baseUrl ?? TELEGRAM_BASE_URL;
58
+ const url = (connection, method) => `${base}/bot${connection.secrets.botToken}/${method}`;
59
+ return {
60
+ id: "telegram",
61
+ meta: TELEGRAM_META,
62
+ operations: ["send"],
63
+ secrets: { required: ["botToken"] },
64
+ validateConfig,
65
+ validateTarget,
66
+ describe: config => (config.botUsername ? { identity: `@${config.botUsername}` } : {}),
67
+ check: (connection, signal) => healthOf(async () => {
68
+ const result = await httpCall({
69
+ fetch: doFetch, url: url(connection, "getMe"), method: "GET", headers: { accept: "application/json" },
70
+ timeoutMs: options.checkTimeoutMs ?? 10000, signal, mode: "check", connectionId: connection.id,
71
+ });
72
+ const body = parseJson(result.text);
73
+ if (result.status < 200 || result.status >= 300)
74
+ throw telegramFailure(result.status, body, "check", connection.id);
75
+ if (body?.ok !== true)
76
+ throw new ChannelError("CHANNEL_CONNECTION_FAILED", true, { connectionId: connection.id });
77
+ const pinned = connection.config.botUsername;
78
+ if (pinned !== undefined && String(body.result?.username).toLowerCase() !== pinned.toLowerCase()) {
79
+ throw new ChannelError("CHANNEL_INVALID_INPUT", false, { connectionId: connection.id, field: "botUsername" });
80
+ }
81
+ }),
82
+ async send(connection, target, message, _context) {
83
+ const text = message.subject ? `${message.subject}\n\n${message.text}` : message.text;
84
+ if (text.length > TELEGRAM_MAX_TEXT)
85
+ throw new ChannelError("CHANNEL_INVALID_INPUT", false, { connectionId: connection.id, field: "text" });
86
+ const result = await httpCall({
87
+ fetch: doFetch, url: url(connection, "sendMessage"), method: "POST", headers: { accept: "application/json" },
88
+ body: { chat_id: target.chatId, text, link_preview_options: { is_disabled: true } },
89
+ timeoutMs: options.sendTimeoutMs ?? 30000, mode: "send", connectionId: connection.id,
90
+ });
91
+ const body = parseJson(result.text);
92
+ if (result.status < 200 || result.status >= 300)
93
+ throw telegramFailure(result.status, body, "send", connection.id);
94
+ if (body?.ok !== true)
95
+ throw new ChannelError("CHANNEL_SEND_OUTCOME_UNKNOWN", false, { connectionId: connection.id });
96
+ const id = body.result?.message_id;
97
+ return receiptFor(typeof id === "number" ? String(id) : undefined);
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,5 @@
1
+ import type { AnyChannelProvider } from "./types.js";
2
+ /** Built-in providers are listed in the initial map as they are implemented. */
3
+ export declare function channelProviders(): AnyChannelProvider[];
4
+ export declare function registerChannelProvider(provider: AnyChannelProvider): void;
5
+ export declare function unregisterChannelProvider(id: string): void;
@@ -0,0 +1,22 @@
1
+ import { createAgentMailProvider } from "./providers/agentmail.js";
2
+ import { createSlackProvider } from "./providers/slack.js";
3
+ import { createTelegramProvider } from "./providers/telegram.js";
4
+ import { createPhotonImessageProvider } from "./providers/photonImessage.js";
5
+ import { createPhotonWhatsappProvider } from "./providers/photonWhatsapp.js";
6
+ const providers = new Map([
7
+ ["agentmail", createAgentMailProvider()],
8
+ ["slack", createSlackProvider()],
9
+ ["telegram", createTelegramProvider()],
10
+ ["imessage", createPhotonImessageProvider()],
11
+ ["whatsapp", createPhotonWhatsappProvider()],
12
+ ]);
13
+ /** Built-in providers are listed in the initial map as they are implemented. */
14
+ export function channelProviders() {
15
+ return [...providers.values()];
16
+ }
17
+ export function registerChannelProvider(provider) {
18
+ providers.set(provider.id, provider);
19
+ }
20
+ export function unregisterChannelProvider(id) {
21
+ providers.delete(id);
22
+ }
@@ -0,0 +1,54 @@
1
+ import { type ConnectionsFile } from "./config.js";
2
+ import { ChannelSecretStore, type SecretReader } from "./secretStore.js";
3
+ import type { AnyChannelProvider, ConnectionSummary, ResolvedConnection, ResolvedCredential } from "./types.js";
4
+ export declare const CONNECTIONS_FILE_NAME = "connections.yaml";
5
+ /**
6
+ * Where connections live. Connections are host-level, like provider credentials, so the default is
7
+ * `$STAGEFLOW_HOME/connections.yaml`. A `connections.yaml` in the project root takes precedence when
8
+ * it exists (CI and checked-in setups), and `STAGEFLOW_CONNECTIONS_FILE` overrides both.
9
+ */
10
+ export declare function connectionsFilePath(cwd: string, env?: NodeJS.ProcessEnv): string;
11
+ export declare function defaultSecretStore(): ChannelSecretStore;
12
+ export type ChannelRegistryOptions = {
13
+ cwd: string;
14
+ providers?: readonly AnyChannelProvider[];
15
+ env?: NodeJS.ProcessEnv;
16
+ /** Owner scope. TODO(multi-tenant): the local default is the workspace path, like email accounts. */
17
+ scope?: string;
18
+ file?: string;
19
+ /** Already parsed content, used to validate an edit before it is written. */
20
+ parsed?: ConnectionsFile;
21
+ secretStore?: SecretReader;
22
+ };
23
+ /** Host-owned, read-only view of `connections.yaml`. Reads return copies. */
24
+ export declare class ChannelRegistry {
25
+ readonly scope: string;
26
+ private readonly env;
27
+ private readonly secretStore;
28
+ private readonly providers;
29
+ private readonly credentials;
30
+ private readonly connections;
31
+ constructor(options: ChannelRegistryOptions);
32
+ list(): ConnectionSummary[];
33
+ private summarize;
34
+ get(id: string, requireEnabled?: boolean): ConnectionSummary;
35
+ providerById(providerId: string): AnyChannelProvider;
36
+ /** Environment variable names that connections and credentials point at with `env:` references. */
37
+ envSecretNames(): string[];
38
+ providerList(): AnyChannelProvider[];
39
+ hasCredential(id: string): boolean;
40
+ /** Resolves a shared credential by id, for providers that reference one. */
41
+ resolveCredential(id: string): ResolvedCredential;
42
+ hasConnection(id: string): boolean;
43
+ providerFor(id: string): AnyChannelProvider;
44
+ validateTarget(id: string, target: unknown): unknown;
45
+ /** Reads secrets from the environment at call time. Values are never cached on the registry. */
46
+ resolve(id: string, requireEnabled?: boolean): ResolvedConnection;
47
+ private entry;
48
+ private requireProvider;
49
+ private addCredential;
50
+ private addConnection;
51
+ private checkSecretNames;
52
+ private checkCredential;
53
+ }
54
+ export declare function sealed<T extends ResolvedConnection>(value: T): T;
@@ -0,0 +1,186 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { inspect } from "node:util";
4
+ import { loadConnectionsFile } from "./config.js";
5
+ import { ChannelError } from "./errors.js";
6
+ import { channelProviders } from "./providers.js";
7
+ import { resolveSecrets } from "./secrets.js";
8
+ import { ChannelSecretStore } from "./secretStore.js";
9
+ import { globalCredentialRoot, globalStageflowHome } from "../project/globalHome.js";
10
+ export const CONNECTIONS_FILE_NAME = "connections.yaml";
11
+ function hostConnectionsFile(env) {
12
+ const home = env.STAGEFLOW_HOME?.trim() || (env === process.env ? globalStageflowHome() : undefined);
13
+ return home ? path.join(path.resolve(home), CONNECTIONS_FILE_NAME) : undefined;
14
+ }
15
+ /**
16
+ * Where connections live. Connections are host-level, like provider credentials, so the default is
17
+ * `$STAGEFLOW_HOME/connections.yaml`. A `connections.yaml` in the project root takes precedence when
18
+ * it exists (CI and checked-in setups), and `STAGEFLOW_CONNECTIONS_FILE` overrides both.
19
+ */
20
+ export function connectionsFilePath(cwd, env = process.env) {
21
+ const override = env.STAGEFLOW_CONNECTIONS_FILE?.trim();
22
+ if (override)
23
+ return path.isAbsolute(override) ? path.resolve(override) : path.resolve(cwd, override);
24
+ const project = path.join(path.resolve(cwd), CONNECTIONS_FILE_NAME);
25
+ if (existsSync(project))
26
+ return project;
27
+ return hostConnectionsFile(env) ?? project;
28
+ }
29
+ export function defaultSecretStore() {
30
+ return new ChannelSecretStore(path.join(globalCredentialRoot(), "channel-secrets.json"));
31
+ }
32
+ /** Host-owned, read-only view of `connections.yaml`. Reads return copies. */
33
+ export class ChannelRegistry {
34
+ scope;
35
+ env;
36
+ secretStore;
37
+ providers = new Map();
38
+ credentials = new Map();
39
+ connections = new Map();
40
+ constructor(options) {
41
+ this.scope = options.scope ?? path.resolve(options.cwd);
42
+ this.env = options.env ?? process.env;
43
+ for (const provider of options.providers ?? channelProviders())
44
+ this.providers.set(provider.id, provider);
45
+ this.secretStore = options.secretStore ?? { get: name => defaultSecretStore().get(name) };
46
+ const file = options.parsed ?? loadConnectionsFile(options.file ?? connectionsFilePath(options.cwd, this.env));
47
+ for (const credential of file.credentials)
48
+ this.addCredential(credential);
49
+ for (const entry of file.connections)
50
+ this.addConnection(entry);
51
+ }
52
+ list() {
53
+ return [...this.connections.values()].map(entry => this.summarize(entry));
54
+ }
55
+ summarize(loaded) {
56
+ const identity = this.providers.get(loaded.provider)?.describe?.(loaded.config)?.identity;
57
+ return {
58
+ id: loaded.id, scope: this.scope, provider: loaded.provider, displayName: loaded.displayName,
59
+ enabled: loaded.enabled, ...(identity ? { identity } : {}),
60
+ ...(loaded.credential ? { credential: loaded.credential } : {}),
61
+ secrets: Object.fromEntries(Object.entries(loaded.secrets).map(([field, ref]) => [field, ref.startsWith("store:") ? "store" : "env"])),
62
+ };
63
+ }
64
+ get(id, requireEnabled = true) {
65
+ return this.summarize(this.entry(id, requireEnabled));
66
+ }
67
+ providerById(providerId) {
68
+ return this.requireProvider(providerId);
69
+ }
70
+ /** Environment variable names that connections and credentials point at with `env:` references. */
71
+ envSecretNames() {
72
+ const names = new Set();
73
+ for (const entry of [...this.credentials.values(), ...this.connections.values()]) {
74
+ for (const ref of Object.values(entry.secrets))
75
+ if (ref.startsWith("env:"))
76
+ names.add(ref.slice("env:".length));
77
+ }
78
+ return [...names];
79
+ }
80
+ providerList() {
81
+ return [...this.providers.values()];
82
+ }
83
+ hasCredential(id) {
84
+ return this.credentials.has(id);
85
+ }
86
+ /** Resolves a shared credential by id, for providers that reference one. */
87
+ resolveCredential(id) {
88
+ const entry = this.credentials.get(id);
89
+ if (!entry)
90
+ throw new ChannelError("CHANNEL_INVALID_INPUT", false, { field: "credential" });
91
+ return { id: entry.id, provider: entry.provider, secrets: resolveSecrets(entry.secrets, this.env, `credential:${entry.id}`, this.secretStore) };
92
+ }
93
+ hasConnection(id) {
94
+ return this.connections.has(id);
95
+ }
96
+ providerFor(id) {
97
+ return this.requireProvider(this.entry(id, false).provider);
98
+ }
99
+ validateTarget(id, target) {
100
+ const result = this.providerFor(id).validateTarget(target);
101
+ if (!result.ok)
102
+ throw new ChannelError("CHANNEL_TARGET_INVALID", false, { connectionId: id });
103
+ return result.value;
104
+ }
105
+ /** Reads secrets from the environment at call time. Values are never cached on the registry. */
106
+ resolve(id, requireEnabled = true) {
107
+ const entry = this.entry(id, requireEnabled);
108
+ const secrets = resolveSecrets(entry.secrets, this.env, entry.id, this.secretStore);
109
+ const credentialEntry = entry.credential ? this.credentials.get(entry.credential) : undefined;
110
+ const credential = credentialEntry && {
111
+ id: credentialEntry.id,
112
+ provider: credentialEntry.provider,
113
+ secrets: resolveSecrets(credentialEntry.secrets, this.env, `credential:${credentialEntry.id}`, this.secretStore),
114
+ };
115
+ return sealed({
116
+ id: entry.id, scope: this.scope, provider: entry.provider, displayName: entry.displayName,
117
+ config: structuredClone(entry.config), secrets, ...(credential ? { credential } : {}),
118
+ });
119
+ }
120
+ entry(id, requireEnabled) {
121
+ const entry = this.connections.get(id);
122
+ if (!entry)
123
+ throw new ChannelError("CHANNEL_NOT_FOUND", false, { connectionId: id });
124
+ if (requireEnabled && !entry.enabled)
125
+ throw new ChannelError("CHANNEL_DISABLED", false, { connectionId: id });
126
+ return entry;
127
+ }
128
+ requireProvider(id) {
129
+ const provider = this.providers.get(id);
130
+ if (!provider)
131
+ throw new ChannelError("CHANNEL_PROVIDER_UNSUPPORTED");
132
+ return provider;
133
+ }
134
+ addCredential(entry) {
135
+ this.credentials.set(entry.id, entry);
136
+ }
137
+ addConnection(entry) {
138
+ const provider = this.providers.get(entry.provider);
139
+ if (!provider)
140
+ throw new ChannelError("CHANNEL_PROVIDER_UNSUPPORTED", false, { connectionId: entry.id });
141
+ const config = provider.validateConfig(entry.config);
142
+ if (!config.ok)
143
+ throw new ChannelError("CHANNEL_INVALID_INPUT", false, { connectionId: entry.id, field: "config" });
144
+ this.checkSecretNames(provider, entry);
145
+ this.checkCredential(provider, entry);
146
+ this.connections.set(entry.id, {
147
+ ...entry, config: config.value, displayName: entry.displayName ?? entry.id,
148
+ });
149
+ }
150
+ checkSecretNames(provider, entry) {
151
+ const allowed = new Set([...provider.secrets.required, ...(provider.secrets.optional ?? [])]);
152
+ for (const name of Object.keys(entry.secrets)) {
153
+ if (!allowed.has(name))
154
+ throw new ChannelError("CHANNEL_INVALID_INPUT", false, { connectionId: entry.id, field: name });
155
+ }
156
+ for (const name of provider.secrets.required) {
157
+ if (!(name in entry.secrets))
158
+ throw new ChannelError("CHANNEL_INVALID_INPUT", false, { connectionId: entry.id, field: name });
159
+ }
160
+ }
161
+ checkCredential(provider, entry) {
162
+ if (!provider.credentialProvider) {
163
+ if (entry.credential)
164
+ throw new ChannelError("CHANNEL_INVALID_INPUT", false, { connectionId: entry.id, field: "credential" });
165
+ return;
166
+ }
167
+ const credential = entry.credential ? this.credentials.get(entry.credential) : undefined;
168
+ if (!credential || credential.provider !== provider.credentialProvider) {
169
+ throw new ChannelError("CHANNEL_INVALID_INPUT", false, { connectionId: entry.id, field: "credential" });
170
+ }
171
+ }
172
+ }
173
+ export function sealed(value) {
174
+ const { secrets, credential, ...visible } = value;
175
+ const out = { ...visible };
176
+ Object.defineProperty(out, "secrets", { value: secrets, enumerable: false });
177
+ if (credential) {
178
+ const { secrets: credentialSecrets, ...credentialVisible } = credential;
179
+ const sealedCredential = { ...credentialVisible };
180
+ Object.defineProperty(sealedCredential, "secrets", { value: credentialSecrets, enumerable: false });
181
+ Object.defineProperty(out, "credential", { value: sealedCredential, enumerable: false });
182
+ }
183
+ Object.defineProperty(out, "toJSON", { value: () => visible, enumerable: false });
184
+ Object.defineProperty(out, inspect.custom, { value: () => ({ ...visible, secrets: "[redacted]" }), enumerable: false });
185
+ return out;
186
+ }
@@ -0,0 +1,21 @@
1
+ export declare const STORE_NAME_PATTERN: RegExp;
2
+ /** Read side used when resolving `store:NAME` references. */
3
+ export interface SecretReader {
4
+ get(name: string): string | undefined;
5
+ }
6
+ /**
7
+ * Secrets entered through the console. One JSON file, owner-only, replaced atomically. It lives
8
+ * in the credential root next to provider auth, so it has the same trust level as that file:
9
+ * anything running as the same user can read it. Values are never logged or returned by the API.
10
+ */
11
+ export declare class ChannelSecretStore implements SecretReader {
12
+ private readonly file;
13
+ constructor(file: string);
14
+ private read;
15
+ private write;
16
+ get(name: string): string | undefined;
17
+ has(name: string): boolean;
18
+ set(name: string, value: string): void;
19
+ delete(name: string): void;
20
+ deleteWithPrefix(prefix: string): void;
21
+ }
@@ -0,0 +1,75 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { ChannelError } from "./errors.js";
4
+ export const STORE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
5
+ const MAX_STORE_BYTES = 1048576;
6
+ const MAX_SECRET_LENGTH = 8192;
7
+ /**
8
+ * Secrets entered through the console. One JSON file, owner-only, replaced atomically. It lives
9
+ * in the credential root next to provider auth, so it has the same trust level as that file:
10
+ * anything running as the same user can read it. Values are never logged or returned by the API.
11
+ */
12
+ export class ChannelSecretStore {
13
+ file;
14
+ constructor(file) {
15
+ this.file = file;
16
+ }
17
+ read() {
18
+ if (!existsSync(this.file))
19
+ return {};
20
+ try {
21
+ if (statSync(this.file).size > MAX_STORE_BYTES)
22
+ throw new ChannelError("CHANNEL_RESOURCE_LIMIT");
23
+ const parsed = JSON.parse(readFileSync(this.file, "utf8"));
24
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
25
+ throw new ChannelError("CHANNEL_STORAGE_FAILED");
26
+ const out = {};
27
+ for (const [name, value] of Object.entries(parsed))
28
+ if (typeof value === "string")
29
+ out[name] = value;
30
+ return out;
31
+ }
32
+ catch (error) {
33
+ if (error instanceof ChannelError)
34
+ throw error;
35
+ throw new ChannelError("CHANNEL_STORAGE_FAILED");
36
+ }
37
+ }
38
+ write(values) {
39
+ const temporary = `${this.file}.${process.pid}.tmp`;
40
+ try {
41
+ mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
42
+ writeFileSync(temporary, JSON.stringify(values), { mode: 0o600 });
43
+ renameSync(temporary, this.file);
44
+ chmodSync(this.file, 0o600);
45
+ }
46
+ catch {
47
+ throw new ChannelError("CHANNEL_STORAGE_FAILED");
48
+ }
49
+ }
50
+ get(name) {
51
+ return this.read()[name];
52
+ }
53
+ has(name) {
54
+ return this.get(name) !== undefined;
55
+ }
56
+ set(name, value) {
57
+ if (!STORE_NAME_PATTERN.test(name) || value.length === 0 || value.length > MAX_SECRET_LENGTH || /[\r\n\0]/.test(value)) {
58
+ throw new ChannelError("CHANNEL_INVALID_INPUT");
59
+ }
60
+ this.write({ ...this.read(), [name]: value });
61
+ }
62
+ delete(name) {
63
+ const values = this.read();
64
+ if (!(name in values))
65
+ return;
66
+ delete values[name];
67
+ this.write(values);
68
+ }
69
+ deleteWithPrefix(prefix) {
70
+ const values = this.read();
71
+ const kept = Object.fromEntries(Object.entries(values).filter(([name]) => !name.startsWith(prefix)));
72
+ if (Object.keys(kept).length !== Object.keys(values).length)
73
+ this.write(kept);
74
+ }
75
+ }
@@ -0,0 +1,15 @@
1
+ import type { SecretReader } from "./secretStore.js";
2
+ import type { SecretRef } from "./types.js";
3
+ export declare const SECRET_REF_PATTERN: RegExp;
4
+ export declare function isSecretRef(value: unknown): value is SecretRef;
5
+ /**
6
+ * Resolves `env:NAME` with the repo's `NAME` / `NAME_FILE` convention, or `store:NAME` from the
7
+ * console secret store. Errors carry the connection id and field only, never a value or path.
8
+ */
9
+ export declare function resolveSecretRef(ref: SecretRef, env: NodeJS.ProcessEnv, where: {
10
+ connectionId: string;
11
+ field: string;
12
+ }, store?: SecretReader): string;
13
+ export declare function resolveSecrets(refs: Readonly<Record<string, SecretRef>>, env: NodeJS.ProcessEnv, connectionId: string, store?: SecretReader): Record<string, string>;
14
+ /** Removes every secret value from text before it reaches an error, log, or tool result. */
15
+ export declare function redactSecrets(text: string, secrets: Iterable<string>): string;
@@ -0,0 +1,50 @@
1
+ import { readSecretFromEnvOrFile, SecretFromEnvError } from "../config/secretFromEnvOrFile.js";
2
+ import { ChannelError } from "./errors.js";
3
+ export const SECRET_REF_PATTERN = /^(?:env:[A-Za-z_][A-Za-z0-9_]*|store:[A-Za-z0-9][A-Za-z0-9._-]{0,199})$/;
4
+ export function isSecretRef(value) {
5
+ return typeof value === "string" && SECRET_REF_PATTERN.test(value);
6
+ }
7
+ /**
8
+ * Resolves `env:NAME` with the repo's `NAME` / `NAME_FILE` convention, or `store:NAME` from the
9
+ * console secret store. Errors carry the connection id and field only, never a value or path.
10
+ */
11
+ export function resolveSecretRef(ref, env, where, store) {
12
+ let value;
13
+ if (ref.startsWith("store:")) {
14
+ try {
15
+ value = store?.get(ref.slice("store:".length));
16
+ }
17
+ catch {
18
+ throw new ChannelError("CHANNEL_SECRET_UNAVAILABLE", false, where);
19
+ }
20
+ }
21
+ else {
22
+ const name = ref.slice("env:".length);
23
+ try {
24
+ value = readSecretFromEnvOrFile(env, name);
25
+ }
26
+ catch (error) {
27
+ if (error instanceof SecretFromEnvError)
28
+ throw new ChannelError("CHANNEL_SECRET_UNAVAILABLE", false, where);
29
+ throw error;
30
+ }
31
+ }
32
+ if (value === undefined || value.length === 0)
33
+ throw new ChannelError("CHANNEL_SECRET_UNAVAILABLE", false, where);
34
+ return value;
35
+ }
36
+ export function resolveSecrets(refs, env, connectionId, store) {
37
+ const out = {};
38
+ for (const [field, ref] of Object.entries(refs))
39
+ out[field] = resolveSecretRef(ref, env, { connectionId, field }, store);
40
+ return out;
41
+ }
42
+ /** Removes every secret value from text before it reaches an error, log, or tool result. */
43
+ export function redactSecrets(text, secrets) {
44
+ let result = text;
45
+ for (const secret of secrets) {
46
+ if (secret.length >= 4)
47
+ result = result.split(secret).join("[redacted]");
48
+ }
49
+ return result;
50
+ }