@homeflare/alchemy 0.48.0 → 0.49.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 (97) hide show
  1. package/README.md +11 -0
  2. package/dist/cloudflare/cloudflared-tunnel-api.d.ts +87 -0
  3. package/dist/cloudflare/cloudflared-tunnel-api.d.ts.map +1 -0
  4. package/dist/cloudflare/cloudflared-tunnel-form.d.ts +49 -0
  5. package/dist/cloudflare/cloudflared-tunnel-form.d.ts.map +1 -0
  6. package/dist/cloudflare/cloudflared-tunnel-harness.d.ts +45 -0
  7. package/dist/cloudflare/cloudflared-tunnel-harness.d.ts.map +1 -0
  8. package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts +21 -0
  9. package/dist/cloudflare/cloudflared-tunnel-lifecycle.d.ts.map +1 -0
  10. package/dist/cloudflare/cloudflared-tunnel.d.ts +39 -0
  11. package/dist/cloudflare/cloudflared-tunnel.d.ts.map +1 -0
  12. package/dist/cloudflare/fake-access.d.ts +40 -0
  13. package/dist/cloudflare/fake-access.d.ts.map +1 -0
  14. package/dist/cloudflare/fake-mesh.d.ts +7 -2
  15. package/dist/cloudflare/fake-mesh.d.ts.map +1 -1
  16. package/dist/cloudflare/fake-tunnel.d.ts +48 -0
  17. package/dist/cloudflare/fake-tunnel.d.ts.map +1 -0
  18. package/dist/cloudflare/index.d.ts +15 -4
  19. package/dist/cloudflare/index.d.ts.map +1 -1
  20. package/dist/cloudflare/index.js +518 -7
  21. package/dist/cloudflare/index.js.map +15 -4
  22. package/dist/cloudflare/providers.d.ts +1 -1
  23. package/dist/cloudflare/providers.d.ts.map +1 -1
  24. package/dist/cloudflare/saas-oidc-api.d.ts +88 -0
  25. package/dist/cloudflare/saas-oidc-api.d.ts.map +1 -0
  26. package/dist/cloudflare/saas-oidc-consumer.d.ts +22 -0
  27. package/dist/cloudflare/saas-oidc-consumer.d.ts.map +1 -0
  28. package/dist/cloudflare/saas-oidc-error.d.ts +22 -0
  29. package/dist/cloudflare/saas-oidc-error.d.ts.map +1 -0
  30. package/dist/cloudflare/saas-oidc-form.d.ts +48 -0
  31. package/dist/cloudflare/saas-oidc-form.d.ts.map +1 -0
  32. package/dist/cloudflare/saas-oidc-harness.d.ts +50 -0
  33. package/dist/cloudflare/saas-oidc-harness.d.ts.map +1 -0
  34. package/dist/cloudflare/saas-oidc-lifecycle.d.ts +14 -0
  35. package/dist/cloudflare/saas-oidc-lifecycle.d.ts.map +1 -0
  36. package/dist/cloudflare/saas-oidc-props.d.ts +139 -0
  37. package/dist/cloudflare/saas-oidc-props.d.ts.map +1 -0
  38. package/dist/cloudflare/saas-oidc-team.d.ts +9 -0
  39. package/dist/cloudflare/saas-oidc-team.d.ts.map +1 -0
  40. package/dist/cloudflare/saas-oidc-wire.d.ts +58 -0
  41. package/dist/cloudflare/saas-oidc-wire.d.ts.map +1 -0
  42. package/dist/cloudflare/saas-oidc.d.ts +36 -0
  43. package/dist/cloudflare/saas-oidc.d.ts.map +1 -0
  44. package/dist/talos/cluster-adapter-errors.d.ts.map +1 -1
  45. package/dist/talos/cluster-adapter.d.ts +2 -1
  46. package/dist/talos/cluster-adapter.d.ts.map +1 -1
  47. package/dist/talos/index.js +19 -8
  48. package/dist/talos/index.js.map +6 -6
  49. package/dist/talos/mint-temp-file.d.ts.map +1 -1
  50. package/dist/talos/talosctl.d.ts +29 -0
  51. package/dist/talos/talosctl.d.ts.map +1 -1
  52. package/docs/cloudflared-tunnel.md +136 -0
  53. package/docs/credentials.md +1 -1
  54. package/docs/ownership.md +3 -2
  55. package/docs/saas-oidc.md +185 -0
  56. package/docs/talos-openbao-adapter.md +3 -2
  57. package/docs/upstream-conformance-families.md +1 -1
  58. package/docs/upstream-conformance.md +3 -0
  59. package/package.json +1 -1
  60. package/src/cloudflare/cloudflared-tunnel-api.ts +150 -0
  61. package/src/cloudflare/cloudflared-tunnel-edges.test.ts +179 -0
  62. package/src/cloudflare/cloudflared-tunnel-form.ts +78 -0
  63. package/src/cloudflare/cloudflared-tunnel-harness.ts +126 -0
  64. package/src/cloudflare/cloudflared-tunnel-lifecycle.ts +146 -0
  65. package/src/cloudflare/cloudflared-tunnel-state.test.ts +104 -0
  66. package/src/cloudflare/cloudflared-tunnel.test.ts +164 -0
  67. package/src/cloudflare/cloudflared-tunnel.ts +116 -0
  68. package/src/cloudflare/fake-access.ts +196 -0
  69. package/src/cloudflare/fake-mesh.ts +6 -3
  70. package/src/cloudflare/fake-tunnel.ts +148 -0
  71. package/src/cloudflare/index.ts +27 -4
  72. package/src/cloudflare/providers.ts +16 -3
  73. package/src/cloudflare/saas-oidc-api.ts +224 -0
  74. package/src/cloudflare/saas-oidc-consumer.ts +35 -0
  75. package/src/cloudflare/saas-oidc-error.ts +52 -0
  76. package/src/cloudflare/saas-oidc-form.test.ts +227 -0
  77. package/src/cloudflare/saas-oidc-form.ts +190 -0
  78. package/src/cloudflare/saas-oidc-harness.ts +140 -0
  79. package/src/cloudflare/saas-oidc-lifecycle.ts +150 -0
  80. package/src/cloudflare/saas-oidc-parse-error.test.ts +180 -0
  81. package/src/cloudflare/saas-oidc-props.ts +147 -0
  82. package/src/cloudflare/saas-oidc-read.test.ts +85 -0
  83. package/src/cloudflare/saas-oidc-stable.test.ts +38 -0
  84. package/src/cloudflare/saas-oidc-state.test.ts +128 -0
  85. package/src/cloudflare/saas-oidc-team.test.ts +88 -0
  86. package/src/cloudflare/saas-oidc-team.ts +70 -0
  87. package/src/cloudflare/saas-oidc-wire.ts +116 -0
  88. package/src/cloudflare/saas-oidc.test.ts +201 -0
  89. package/src/cloudflare/saas-oidc.ts +107 -0
  90. package/src/talos/cluster-adapter-errors.ts +2 -1
  91. package/src/talos/cluster-adapter-move.test.ts +2 -0
  92. package/src/talos/cluster-adapter.ts +2 -1
  93. package/src/talos/mint-temp-file.test.ts +44 -0
  94. package/src/talos/mint-temp-file.ts +16 -4
  95. package/src/talos/talosctl-probe-kill.test.ts +56 -0
  96. package/src/talos/talosctl.test.ts +54 -1
  97. package/src/talos/talosctl.ts +34 -2
@@ -0,0 +1,201 @@
1
+ /**
2
+ * SaasOidcApplicationProvider against a fake Access API (fake-access.ts), its handlers called the
3
+ * way the engine calls them. Read, delete and adoption: saas-oidc-read.test.ts.
4
+ *
5
+ * ⛔ THE ASSERTIONS THAT MATTER MOST: a public PKCE client is created with
6
+ * `allow_pkce_without_client_secret` and the `authorization_code_with_pkce` grant, and the client
7
+ * secret — which the fake's create response DOES hand out, as the API does on POST — appears in
8
+ * no attribute, no log line and no request body.
9
+ */
10
+ import { afterEach, describe, expect, spyOn, test } from 'bun:test';
11
+ import type { StackServices } from 'alchemy/Stack';
12
+ import * as Effect from 'effect/Effect';
13
+ import type * as Layer from 'effect/Layer';
14
+ import { FAKE_CLIENT_SECRET, TEAM, fakeAccess } from './fake-access.ts';
15
+ import { FAKE_ACCOUNT } from './fake-mesh.ts';
16
+ import { providers } from './providers.ts';
17
+ import { LIVE_OIDC, publicClient, reconcile, run, writes } from './saas-oidc-harness.ts';
18
+ import { SaasOidcApplication } from './saas-oidc.ts';
19
+
20
+ // ⛔ Compile-time: a stack's `providers` must be `Layer<…, never, StackServices>`. A provider with
21
+ // an open error channel leaks it here, and `bun run types` catches that.
22
+ type Built = Layer.Success<ReturnType<typeof providers>>;
23
+ const _stackShaped: Layer.Layer<Built, never, StackServices> = providers();
24
+ void _stackShaped;
25
+
26
+ const postBody = (fake: ReturnType<typeof fakeAccess>) =>
27
+ fake.seen.find((s) => s.method === 'POST')?.body as Record<string, unknown>;
28
+
29
+ describe('SaasOidcApplication identity', () => {
30
+ test('the type id is the one the openbao state rows are keyed by', () => {
31
+ expect(SaasOidcApplication.Type).toBe('HomeFlare.Access.SaasOidcApplication');
32
+ });
33
+ });
34
+
35
+ describe('create', () => {
36
+ const debug = process.env['DISTILLED_DEBUG_HTTP'];
37
+ afterEach(() => {
38
+ if (debug === undefined) delete process.env['DISTILLED_DEBUG_HTTP'];
39
+ else process.env['DISTILLED_DEBUG_HTTP'] = debug;
40
+ });
41
+
42
+ test('a public client is sent as PKCE with no secret, and read back for its client id', async () => {
43
+ const fake = fakeAccess();
44
+ const spies = [spyOn(console, 'log'), spyOn(console, 'error'), spyOn(console, 'warn')];
45
+ const out = await run(fake, (p) => reconcile(p, publicClient()));
46
+ const logged = JSON.stringify(spies.map((spy) => spy.mock.calls));
47
+ for (const spy of spies) spy.mockRestore();
48
+ expect(postBody(fake)).toMatchObject({
49
+ type: 'saas',
50
+ name: 'Headlamp',
51
+ policies: ['policy-admin'],
52
+ saas_app: {
53
+ auth_type: 'oidc',
54
+ grant_types: ['authorization_code_with_pkce'],
55
+ allow_pkce_without_client_secret: true,
56
+ access_token_lifetime: '15m',
57
+ },
58
+ });
59
+ expect(writes(fake)).toEqual(['POST']);
60
+ expect(out).toMatchObject({ accountId: FAKE_ACCOUNT, teamDomain: TEAM, name: 'Headlamp' });
61
+ expect(out.issuer).toBe(`https://${TEAM}/cdn-cgi/access/sso/oidc/${out.clientId}`);
62
+ expect(out.jwksEndpoint).toBe(`${out.issuer}/jwks`);
63
+ // ⛔ The secret was in the create response. It is nowhere we control.
64
+ expect(JSON.stringify(out)).not.toContain(FAKE_CLIENT_SECRET);
65
+ expect(JSON.stringify(fake.seen)).not.toContain(FAKE_CLIENT_SECRET);
66
+ expect(logged).not.toContain(FAKE_CLIENT_SECRET);
67
+ expect(Object.keys(out)).not.toContain('clientSecret');
68
+ });
69
+
70
+ test('refuses while DISTILLED_DEBUG_HTTP is set, before any write', async () => {
71
+ process.env['DISTILLED_DEBUG_HTTP'] = '1';
72
+ const fake = fakeAccess();
73
+ // The SDK prints the name lookup's (secret-free) response while the variable is set.
74
+ const quiet = spyOn(console, 'error').mockImplementation(() => {});
75
+ const failure = await run(fake, (p) => Effect.flip(reconcile(p, publicClient())));
76
+ quiet.mockRestore();
77
+ expect(String(failure)).toContain('DISTILLED_DEBUG_HTTP');
78
+ expect(writes(fake)).toEqual([]);
79
+ });
80
+
81
+ test('an invalid declaration fails before any request', async () => {
82
+ const fake = fakeAccess();
83
+ const failure = await run(fake, (p) =>
84
+ Effect.flip(reconcile(p, publicClient({ teamDomain: 'https://x.test' }))),
85
+ );
86
+ expect(String(failure)).toContain('teamDomain');
87
+ expect(fake.seen).toEqual([]);
88
+ });
89
+ });
90
+
91
+ describe('reconcile against a live app', () => {
92
+ test('makes zero writes when nothing drifted', async () => {
93
+ const fake = fakeAccess();
94
+ const [first, second] = await run(fake, (p) =>
95
+ Effect.gen(function* () {
96
+ const created = yield* reconcile(p, publicClient());
97
+ return [created, yield* reconcile(p, publicClient(), created)] as const;
98
+ }),
99
+ );
100
+ expect(second).toEqual(first);
101
+ expect(writes(fake)).toEqual(['POST']);
102
+ });
103
+
104
+ test('a changed redirect URI is one PUT, and the client id survives', async () => {
105
+ const fake = fakeAccess();
106
+ const base = publicClient();
107
+ const changed = {
108
+ ...base,
109
+ saasApp: { ...base.saasApp, redirectUris: ['https://headlamp.example.test/new'] },
110
+ };
111
+ const [first, second] = await run(fake, (p) =>
112
+ Effect.gen(function* () {
113
+ const created = yield* reconcile(p, base);
114
+ return [created, yield* reconcile(p, changed, created)] as const;
115
+ }),
116
+ );
117
+ expect(writes(fake)).toEqual(['POST', 'PUT']);
118
+ expect(second.redirectUris).toEqual(['https://headlamp.example.test/new']);
119
+ expect(second.clientId).toBe(first.clientId);
120
+ expect(second.applicationId).toBe(first.applicationId);
121
+ expect(JSON.stringify(fake.seen)).not.toContain(FAKE_CLIENT_SECRET);
122
+ });
123
+
124
+ test('a reordered policy list is one PUT that carries the new order', async () => {
125
+ const fake = fakeAccess();
126
+ const base = publicClient({ policies: ['policy-a', 'policy-b'] });
127
+ const swapped = { ...base, policies: ['policy-b', 'policy-a'] };
128
+ const second = await run(fake, (p) =>
129
+ Effect.gen(function* () {
130
+ const created = yield* reconcile(p, base);
131
+ return yield* reconcile(p, swapped, created);
132
+ }),
133
+ );
134
+ expect(second.applicationId).toBeDefined();
135
+ expect(writes(fake)).toEqual(['POST', 'PUT']);
136
+ expect(fake.seen.filter((s) => s.method === 'PUT')[0]?.body).toMatchObject({
137
+ policies: ['policy-b', 'policy-a'],
138
+ });
139
+ // The fake holds the new order, so the next read sees it in sync.
140
+ expect(fake.apps.get(second.applicationId)?.policies).toEqual(['policy-b', 'policy-a']);
141
+ });
142
+
143
+ test("a declared team that is not the app's team is refused before any write", async () => {
144
+ const fake = fakeAccess({ team: 'real.cloudflareaccess.com' });
145
+ const live = fake.seed({ name: 'Headlamp' });
146
+ const failure = await run(fake, (p) =>
147
+ Effect.flip(
148
+ reconcile(
149
+ p,
150
+ publicClient({ applicationId: live.id, teamDomain: 'other.cloudflareaccess.com' }),
151
+ ),
152
+ ),
153
+ );
154
+ expect(String(failure)).toContain('real.cloudflareaccess.com');
155
+ expect(writes(fake)).toEqual([]);
156
+ });
157
+
158
+ test('an owned row whose app was deleted out of band never adopts a same-named app', async () => {
159
+ const fake = fakeAccess();
160
+ const made = await run(fake, (p) => reconcile(p, publicClient()));
161
+ fake.apps.delete(made.applicationId);
162
+ const impostor = fake.seed({
163
+ name: 'Headlamp',
164
+ policies: ['policy-admin'],
165
+ saas_app: LIVE_OIDC,
166
+ });
167
+ const before = fake.seen.length;
168
+ const out = await run(fake, (p) => reconcile(p, publicClient(), made));
169
+ // A fresh app is created; the same-named one is neither adopted nor written.
170
+ expect(out.applicationId).not.toBe(impostor.id);
171
+ expect(out.applicationId).not.toBe(made.applicationId);
172
+ expect(
173
+ fake.seen
174
+ .slice(before)
175
+ .filter((r) => r.method !== 'GET')
176
+ .map((r) => r.method),
177
+ ).toEqual(['POST']);
178
+ expect(fake.apps.get(impostor.id)?.saas_app?.['client_id']).toBe(
179
+ impostor.saas_app?.['client_id'],
180
+ );
181
+ });
182
+
183
+ test('an id that is not a saas app is refused, never rewritten', async () => {
184
+ const fake = fakeAccess();
185
+ const selfHosted = fake.seed({ name: 'Headlamp', type: 'self_hosted' });
186
+ const failure = await run(fake, (p) =>
187
+ Effect.flip(reconcile(p, publicClient({ applicationId: selfHosted.id }))),
188
+ );
189
+ expect(String(failure)).toContain('not "saas"');
190
+ expect(writes(fake)).toEqual([]);
191
+ });
192
+
193
+ test('two saas apps with the declared name are refused, not guessed between', async () => {
194
+ const fake = fakeAccess();
195
+ fake.seed({ name: 'Headlamp' });
196
+ fake.seed({ name: 'Headlamp' });
197
+ const failure = await run(fake, (p) => Effect.flip(reconcile(p, publicClient())));
198
+ expect(String(failure)).toContain('matches 2 live apps');
199
+ expect(writes(fake)).toEqual([]);
200
+ });
201
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `HomeFlare.Access.SaasOidcApplication` — a Cloudflare Access SaaS application that makes Access an
3
+ * OIDC identity provider for one relying party (OpenBao's JWT/OIDC auth, Headlamp, a Kubernetes
4
+ * API server). Usage and the secret rules: docs/saas-oidc.md.
5
+ *
6
+ * 🔴 WHY THIS EXISTS ALONGSIDE `Cloudflare.Access.Application`. Alchemy beta.81's Application has
7
+ * no `saas_app` field (its source mentions `saas` only as a type and in two comments), and
8
+ * `@distilled.cloud/cloudflare` rc.13 decodes a saas app's GET and create response WITHOUT it, so
9
+ * the client id cannot be read back through either. See saas-oidc-api.ts.
10
+ *
11
+ * ⛔ THE TYPE ID IS A STATE KEY, AND THIS ONE WAS MOVED, NOT RENAMED. It is the string the
12
+ * homeflare-openbao stack's live `OpenBaoOidcSaas` row was written under. A different string would
13
+ * make the next plan see a new resource and an orphaned one: a second OIDC app, with a new
14
+ * client id and `aud`, and a delete of the live one unless it were retained. `tests` pin it.
15
+ *
16
+ * ★ `retain` BY DEFAULT, like every kit resource whose deletion breaks its consumers: deleting the
17
+ * app deletes the client id every relying party is configured with. The openbao declaration
18
+ * already pipes `RemovalPolicy.retain()`; `delete` stays fully implemented for a declaration
19
+ * that opts in with `.pipe(RemovalPolicy.destroy())`.
20
+ */
21
+ import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
22
+ import * as Cloudflare from 'alchemy/Cloudflare';
23
+ import * as Provider from 'alchemy/Provider';
24
+ import { Resource, type ResourceClass } from 'alchemy/Resource';
25
+ import * as Context from 'effect/Context';
26
+ import * as Effect from 'effect/Effect';
27
+ import * as HttpClient from 'effect/http/HttpClient';
28
+ import type { Providers } from './providers.ts';
29
+ import { deleteSaasOidc, readSaasOidc, reconcileSaasOidc } from './saas-oidc-lifecycle.ts';
30
+ import type { SaasOidcApplicationAttributes, SaasOidcApplicationProps } from './saas-oidc-form.ts';
31
+
32
+ export interface SaasOidcApplication extends Resource<
33
+ 'HomeFlare.Access.SaasOidcApplication',
34
+ SaasOidcApplicationProps,
35
+ SaasOidcApplicationAttributes,
36
+ never,
37
+ Providers
38
+ > {}
39
+
40
+ export const SaasOidcApplication: ResourceClass<SaasOidcApplication> =
41
+ Resource<SaasOidcApplication>('HomeFlare.Access.SaasOidcApplication', {
42
+ defaultRemovalPolicy: 'retain',
43
+ });
44
+
45
+ const accountId = Effect.gen(function* () {
46
+ const resolved = yield* yield* Cloudflare.CloudflareEnvironment;
47
+ return resolved.accountId;
48
+ });
49
+
50
+ /**
51
+ * ★ `Provider.effect`, CAPTURING THE THREE SERVICES AT BUILD, as MeshNodeProvider does
52
+ * (mesh-node.ts): correct whether or not the stack also merges `Cloudflare.providers()`.
53
+ */
54
+ export const SaasOidcApplicationProvider = () =>
55
+ Provider.effect(
56
+ SaasOidcApplication,
57
+ Effect.gen(function* () {
58
+ const services = Context.make(
59
+ Cloudflare.CloudflareEnvironment,
60
+ yield* Cloudflare.CloudflareEnvironment,
61
+ ).pipe(
62
+ Context.add(Credentials, yield* Credentials),
63
+ Context.add(HttpClient.HttpClient, yield* HttpClient.HttpClient),
64
+ );
65
+ // ★ Only the three captured services are provided; `createPhysicalName` still takes
66
+ // InstanceId, Stack and Stage from the engine at call time, as in every Alchemy provider.
67
+ const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
68
+ Effect.provideContext(effect, services);
69
+
70
+ return SaasOidcApplication.Provider.of({
71
+ /**
72
+ * ★ THE ISSUER AND EVERY ENDPOINT ARE STABLE. They depend only on the team domain and the
73
+ * client id, and `checkTeam` refuses any team other than the app's own host, so no update
74
+ * of this app can move them. Alchemy's plan (Plan.ts `withStables`, beta.81) keeps ONLY
75
+ * the listed attributes resolved for consumers of an updated resource; without them, a
76
+ * redirect-URI change would leave `app.issuer` unresolved and re-plan every consumer
77
+ * (the Talos KubeAuthenticationConfig, so a kube-apiserver restart on each node).
78
+ * ⚠️ `teamDomain` is NOT listed: state written by the openbao copy has no such attribute,
79
+ * so a consumer would read `undefined` on the first plan after the move.
80
+ */
81
+ stables: [
82
+ 'applicationId',
83
+ 'aud',
84
+ 'clientId',
85
+ 'accountId',
86
+ 'issuer',
87
+ 'authorizationEndpoint',
88
+ 'tokenEndpoint',
89
+ 'jwksEndpoint',
90
+ 'userinfoEndpoint',
91
+ 'configurationEndpoint',
92
+ ],
93
+ /**
94
+ * ⛔ EMPTY, AND `nuke.skip`. Alchemy's own `Cloudflare.Access.Application` already lists
95
+ * every Access application in the account for `alchemy unsafe nuke` (its `list`, saas
96
+ * apps included); a second listing here would make nuke delete each app twice.
97
+ */
98
+ list: () => Effect.succeed([]),
99
+ nuke: { skip: true },
100
+ read: ({ id, olds, output }) =>
101
+ run(Effect.flatMap(accountId, (account) => readSaasOidc(account, id, olds, output))),
102
+ reconcile: ({ id, news, output }) =>
103
+ run(Effect.flatMap(accountId, (account) => reconcileSaasOidc(account, id, news, output))),
104
+ delete: ({ output }) => run(deleteSaasOidc(output)),
105
+ });
106
+ }),
107
+ );
@@ -155,7 +155,8 @@ export class TalosClusterIdentityTimeout extends Data.TaggedError('TalosClusterI
155
155
  override get message(): string {
156
156
  return (
157
157
  `talos-openbao cluster '${this.cluster}': no answer to the kube-system uid read within ` +
158
- `${this.seconds}s. Refused (failed closed) rather than waiting on a silent apiserver.`
158
+ `${this.seconds}s (the identity read's own deadline, inside the 10 s connect deadline). ` +
159
+ 'Refused (failed closed) rather than waiting on a silent apiserver.'
159
160
  );
160
161
  }
161
162
  }
@@ -97,6 +97,8 @@ test('a silent apiserver fails closed with TalosClusterIdentityTimeout', async (
97
97
  Effect.runPromise(Effect.provideService(read, spawn, fakeSpawner(vault))),
98
98
  );
99
99
  expect(message).toContain('TalosClusterIdentityTimeout');
100
+ // The text names the identity read's own deadline inside the 10 s connect deadline.
101
+ expect(message).toContain("the identity read's own deadline, inside the 10 s connect deadline");
100
102
  } finally {
101
103
  api.restore();
102
104
  }
@@ -33,7 +33,8 @@
33
33
  * (`TalosClusterMoved`); a move is a NEW identity resource per physical cluster, so the old
34
34
  * rows keep their old uid and their cleanup reaches only the old cluster.
35
35
  * ⛔ ONE 10 s DEADLINE covers the vault read AND the uid GET (`withConnectDeadline`): a hung `bao`
36
- * or silent apiserver fails with `TalosOpenBaoConnectTimeout` and the `bao` child is killed.
36
+ * fails with `TalosOpenBaoConnectTimeout` and the `bao` child is killed. The uid GET has its own
37
+ * 5 s deadline inside it, so a silent apiserver fails first with `TalosClusterIdentityTimeout`.
37
38
  * ⚠️ The in-flight HTTPS GET is NOT aborted: upstream `readObject` takes no signal
38
39
  * (`internal/client.ts:75`), so the socket lives until the OS gives up. Left for the upstream ask.
39
40
  *
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `mintKvTempFile` when the write fails AFTER the exclusive 0600 create (disk full): the release
3
+ * is registered only once the acquire step succeeds, so the file must be removed inside the step.
4
+ * Fails on 0.48.0, which left the partial file behind.
5
+ */
6
+ import * as fs from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { expect, spyOn, test } from 'bun:test';
9
+ import * as Effect from 'effect/Effect';
10
+ import { mintKvTempFile } from './mint-temp-file.ts';
11
+
12
+ const leftovers = (label: string) =>
13
+ fs.readdirSync(tmpdir()).filter((name) => name.startsWith(`hf-talos-${label}-`));
14
+
15
+ test('a write that fails after the exclusive create leaves no temp file behind', async () => {
16
+ const label = `write-fail-${process.pid}`;
17
+ // A non-string body makes writeFileSync throw after openSync already created the file: a real
18
+ // failure on the real code path, with no stub of node:fs.
19
+ const failure = await Effect.runPromise(
20
+ Effect.flip(Effect.scoped(mintKvTempFile(42 as unknown as string, label))),
21
+ );
22
+ expect(failure.message).toContain(`writing ${label} temp file`);
23
+ expect(leftovers(label)).toEqual([]);
24
+ });
25
+
26
+ test('a close that fails after a good write leaves no temp file behind', async () => {
27
+ const label = `close-fail-${process.pid}`;
28
+ const realClose = fs.closeSync;
29
+ // EIO/EDQUOT surface at close(2): close for real (no leaked fd), then report the failure.
30
+ const spy = spyOn(fs, 'closeSync').mockImplementation((fd: number) => {
31
+ realClose(fd);
32
+ throw new Error('EIO: simulated close failure');
33
+ });
34
+ try {
35
+ const failure = await Effect.runPromise(
36
+ Effect.flip(Effect.scoped(mintKvTempFile('credential\n', label))),
37
+ );
38
+ expect(failure.message).toContain('EIO: simulated close failure');
39
+ expect(spy).toHaveBeenCalled();
40
+ } finally {
41
+ spy.mockRestore();
42
+ }
43
+ expect(leftovers(label)).toEqual([]);
44
+ });
@@ -3,7 +3,7 @@
3
3
  * credentials.ts (2026-10-06, red team PR 355) once the Node-runtime fix pushed that file past the
4
4
  * 250-line cap; the split mirrors credentials-write.ts, which was split out for its own header.
5
5
  */
6
- import { closeSync, openSync, writeFileSync } from 'node:fs';
6
+ import { closeSync, openSync, unlinkSync, writeFileSync } from 'node:fs';
7
7
  import { unlink } from 'node:fs/promises';
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { tmpdir } from 'node:os';
@@ -39,10 +39,22 @@ export const mintKvTempFile = (
39
39
  Effect.try({
40
40
  try: () => {
41
41
  const fd = openSync(path, 'wx', 0o600);
42
+ // ⛔ The release below registers only after this step succeeds, so ANY failure here (a
43
+ // disk-full write, or a close that errors: EIO/EDQUOT surface at close on a quota'd
44
+ // tmpdir) would leave the 0600 file, possibly holding part of the credential, behind.
42
45
  try {
43
- writeFileSync(fd, raw);
44
- } finally {
45
- closeSync(fd);
46
+ try {
47
+ writeFileSync(fd, raw);
48
+ } finally {
49
+ closeSync(fd);
50
+ }
51
+ } catch (cause) {
52
+ try {
53
+ unlinkSync(path);
54
+ } catch {
55
+ // nothing more to remove
56
+ }
57
+ throw cause;
46
58
  }
47
59
  },
48
60
  catch: (cause) => new Error(`writing ${label} temp file: ${String(cause)}`),
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The version probe's deadline against a REAL hanging child: the claim in talosctl.ts that a timed-out
3
+ * probe kills the child (and its process group) is proved here with `process.kill(pid, 0)`, not with a
4
+ * spawner that never starts anything. The script lives in a private os.tmpdir() directory removed on
5
+ * every exit path, and sleeps 60 s so a surviving child is unmistakable.
6
+ */
7
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { afterAll, expect, test } from 'bun:test';
11
+ import * as Effect from 'effect/Effect';
12
+ import * as NodeServices from '@effect/platform-node/NodeServices';
13
+ import { trustBoundaryForTests } from './trust-boundary.seam.ts';
14
+ import { TalosBinaryRefused, talosctl } from './talosctl.ts';
15
+
16
+ const dir = mkdtempSync(join(tmpdir(), 'hf-probe-kill-'));
17
+ chmodSync(dir, 0o755);
18
+ trustBoundaryForTests(dir);
19
+ afterAll(() => rmSync(dir, { force: true, recursive: true }));
20
+
21
+ const pidFile = join(dir, 'pids');
22
+ const script = join(dir, 'talosctl');
23
+ // The shell and its `sleep` grandchild both record their pid: the grandchild only dies if the whole
24
+ // process group is killed, not just the direct child.
25
+ writeFileSync(script, `#!/bin/sh\nsleep 60 &\necho "$$ $!" > '${pidFile}'\nwait\n`, {
26
+ mode: 0o755,
27
+ });
28
+ chmodSync(script, 0o755);
29
+
30
+ const alive = (pid: number) => {
31
+ try {
32
+ process.kill(pid, 0);
33
+ return true;
34
+ } catch (cause) {
35
+ return (cause as NodeJS.ErrnoException).code !== 'ESRCH';
36
+ }
37
+ };
38
+
39
+ test('a probe that hangs is refused and its real child and process group are gone', async () => {
40
+ const outcome = await Effect.runPromise(
41
+ Effect.flip(
42
+ talosctl(['version'], {
43
+ binary: script,
44
+ talosconfigPath: 'unused.yaml',
45
+ versionProbeTimeout: '1 seconds',
46
+ }).pipe(Effect.provide(NodeServices.layer)),
47
+ ),
48
+ );
49
+ expect(outcome).toBeInstanceOf(TalosBinaryRefused);
50
+ const pids = readFileSync(pidFile, 'utf8').trim().split(' ').map(Number);
51
+ expect(pids).toHaveLength(2);
52
+ // SIGKILL follows SIGTERM after `forceKillAfter` (1 s), so allow it to land.
53
+ const deadline = Date.now() + 5000;
54
+ while (pids.some(alive) && Date.now() < deadline) await Bun.sleep(100);
55
+ expect(pids.map(alive)).toEqual([false, false]);
56
+ });
@@ -6,11 +6,17 @@ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { join } from 'node:path';
8
8
  import { afterAll, expect, test } from 'bun:test';
9
+ import * as Duration from 'effect/Duration';
9
10
  import * as Effect from 'effect/Effect';
10
11
  import * as ChildProcessSpawner from 'effect/process/ChildProcessSpawner';
11
12
  import { type FakeCall, fakeSpawner } from './fake-process.ts';
12
13
  import { trustBoundaryForTests } from './trust-boundary.seam.ts';
13
- import { TALOSCTL_BINARY_ENV, talosctl } from './talosctl.ts';
14
+ import {
15
+ TALOSCTL_BINARY_ENV,
16
+ TalosBinaryRefused,
17
+ VERSION_PROBE_TIMEOUT,
18
+ talosctl,
19
+ } from './talosctl.ts';
14
20
 
15
21
  // ⚠️ A real, vetted file in a private directory: the system `sh` or `bun` may be a symlink or live
16
22
  // in a writable directory, which the vetting (correctly) refuses.
@@ -108,3 +114,50 @@ test('a default talosctl that is not on PATH is refused, never run by bare name'
108
114
  }),
109
115
  );
110
116
  });
117
+
118
+ test('a version probe that never answers is refused with a typed error, not awaited forever', async () => {
119
+ await withEnv(fixture, async () => {
120
+ const started = Date.now();
121
+ const outcome = await Effect.runPromise(
122
+ Effect.flip(
123
+ Effect.provideService(
124
+ // The default is 10 s (asserted below); a short override keeps the suite fast.
125
+ talosctl(['version'], {
126
+ talosconfigPath: 'unused.yaml',
127
+ versionProbeTimeout: '2 seconds',
128
+ }),
129
+ ChildProcessSpawner.ChildProcessSpawner,
130
+ ChildProcessSpawner.make(() => Effect.never),
131
+ ),
132
+ ),
133
+ );
134
+ expect(outcome).toBeInstanceOf(TalosBinaryRefused);
135
+ expect(outcome.message).toContain('did not answer within');
136
+ expect(Date.now() - started).toBeLessThan(4000);
137
+ });
138
+ });
139
+
140
+ test('the probe deadline is overridable per call and still fails closed', async () => {
141
+ await withEnv(fixture, async () => {
142
+ const started = Date.now();
143
+ const outcome = await Effect.runPromise(
144
+ Effect.flip(
145
+ Effect.provideService(
146
+ talosctl(['version'], {
147
+ talosconfigPath: 'unused.yaml',
148
+ versionProbeTimeout: '1 seconds',
149
+ }),
150
+ ChildProcessSpawner.ChildProcessSpawner,
151
+ ChildProcessSpawner.make(() => Effect.never),
152
+ ),
153
+ ),
154
+ );
155
+ expect(outcome).toBeInstanceOf(TalosBinaryRefused);
156
+ expect(outcome.message).toContain('did not answer within 1s');
157
+ expect(Date.now() - started).toBeLessThan(5000);
158
+ });
159
+ });
160
+
161
+ test('the default probe deadline is 10 s', () => {
162
+ expect(Duration.toSeconds(VERSION_PROBE_TIMEOUT)).toBe(10);
163
+ });
@@ -78,6 +78,13 @@ export type TalosRunOptions = {
78
78
  * since insecure mode ignores the file's certs rather than requiring their absence.
79
79
  */
80
80
  readonly insecure?: boolean;
81
+ /**
82
+ * Deadline for the `version --client` probe of the binary; past it the binary is refused (fails
83
+ * closed, never skipped). A cold first run of the Go binary under CI load can exceed a tight
84
+ * limit, so a caller may raise it.
85
+ * @default {@link VERSION_PROBE_TIMEOUT} (10 s)
86
+ */
87
+ readonly versionProbeTimeout?: Duration.Input;
81
88
  };
82
89
 
83
90
  const capture = (binary: string, argv: readonly string[]) =>
@@ -117,6 +124,16 @@ const capture = (binary: string, argv: readonly string[]) =>
117
124
  );
118
125
  });
119
126
 
127
+ /**
128
+ * ⛔ THE VERSION PROBE IS BOUNDED. `talosctl version --client` talks to no cluster and answers in
129
+ * milliseconds, so a probe silent for this long is a hung or hostile binary: it is refused
130
+ * (failed closed, typed) instead of parking the caller forever. Interrupting the scope kills the
131
+ * child with its process group (proved against a real hanging child by talosctl-probe-kill.test.ts).
132
+ * ⚠️ 10 s, not 3 s: a cold first run of the Go binary under CI load can take seconds, and a
133
+ * refusal there is a false alarm. Override per call with `TalosRunOptions.versionProbeTimeout`.
134
+ */
135
+ export const VERSION_PROBE_TIMEOUT = Duration.seconds(10);
136
+
120
137
  /** ⚠️ EXACT TOKEN MATCH: `includes` would accept v1.14.20 for v1.14.2. */
121
138
  const reportsPinnedVersion = (stdout: string) =>
122
139
  stdout.split(/[^\w.+-]+/).includes(TALOSCTL_PINNED_VERSION);
@@ -125,8 +142,23 @@ const reportsPinnedVersion = (stdout: string) =>
125
142
  * ⛔ AN OVERRIDE MUST REPORT THE PINNED CLIENT VERSION (`version --client`, no talosconfig, so
126
143
  * nothing secret reaches an untrusted binary before it is vetted).
127
144
  */
128
- const checkOverrideVersion = (binary: string, source: string) =>
145
+ const checkOverrideVersion = (
146
+ binary: string,
147
+ source: string,
148
+ timeout: Duration.Input = VERSION_PROBE_TIMEOUT,
149
+ ) =>
129
150
  capture(binary, ['version', '--client']).pipe(
151
+ Effect.timeoutOrElse({
152
+ duration: timeout,
153
+ orElse: () =>
154
+ Effect.fail(
155
+ new TalosBinaryRefused(
156
+ 'version --client',
157
+ `${source} did not answer within ${String(Duration.toSeconds(timeout))}s`,
158
+ binary,
159
+ ),
160
+ ),
161
+ }),
130
162
  Effect.flatMap((out) =>
131
163
  out.exitCode === 0 && reportsPinnedVersion(out.stdout)
132
164
  ? Effect.void
@@ -155,7 +187,7 @@ export const talosctl = (args: readonly string[], options: TalosRunOptions) =>
155
187
  : TALOSCTL_BINARY_ENV;
156
188
  // ⛔ Launch exactly the vetted canonical path, for the probe and the credentialed call alike.
157
189
  const binary = yield* checkBinaryPath(resolved, source);
158
- yield* checkOverrideVersion(binary, source);
190
+ yield* checkOverrideVersion(binary, source, options.versionProbeTimeout);
159
191
  const argv = [
160
192
  ...args,
161
193
  '--talosconfig',