Skip to content

Commit 1f9ff0f

Browse files
authored
fix(server): the batched 1Password read reaches op at all (#30)
2 parents a7cf32d + 01753c8 commit 1f9ff0f

2 files changed

Lines changed: 171 additions & 27 deletions

File tree

‎apps/server/src/provider/Layers/ProviderSecretResolverLive.test.ts‎

Lines changed: 146 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import * as Schema from "effect/Schema";
66
import * as Sink from "effect/Sink";
77
import * as Stream from "effect/Stream";
88
import { ChildProcessSpawner } from "effect/unstable/process";
9+
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
10+
import * as NodeFS from "node:fs";
911

1012
import { ProviderSecretResolverLive } from "./ProviderSecretResolverLive.ts";
1113
import { ProviderSecretResolver } from "../Services/ProviderSecretResolver.ts";
@@ -60,7 +62,13 @@ describe("ProviderSecretResolverLive", () => {
6062

6163
assert.deepStrictEqual(resolved, { variables: environment, unresolved: [] });
6264
assert.strictEqual(spawner.invocations.length, 0);
63-
}).pipe(Effect.provide(ProviderSecretResolverLive.pipe(Layer.provide(spawner.layer))));
65+
}).pipe(
66+
Effect.provide(
67+
ProviderSecretResolverLive.pipe(
68+
Layer.provide(Layer.merge(spawner.layer, NodeFileSystem.layer)),
69+
),
70+
),
71+
);
6472
});
6573

6674
it.effect("swaps a secret reference for the value 1Password returns", () => {
@@ -83,7 +91,13 @@ describe("ProviderSecretResolverLive", () => {
8391
],
8492
);
8593
assert.deepStrictEqual(spawner.invocations, [["read", "--no-newline", TOKEN_REFERENCE]]);
86-
}).pipe(Effect.provide(ProviderSecretResolverLive.pipe(Layer.provide(spawner.layer))));
94+
}).pipe(
95+
Effect.provide(
96+
ProviderSecretResolverLive.pipe(
97+
Layer.provide(Layer.merge(spawner.layer, NodeFileSystem.layer)),
98+
),
99+
),
100+
);
87101
});
88102

89103
it.effect("reads a reference once and holds it until the caller invalidates", () => {
@@ -103,7 +117,13 @@ describe("ProviderSecretResolverLive", () => {
103117
yield* resolver.invalidate;
104118
yield* resolver.resolve(environment);
105119
assert.strictEqual(spawner.invocations.length, 2);
106-
}).pipe(Effect.provide(ProviderSecretResolverLive.pipe(Layer.provide(spawner.layer))));
120+
}).pipe(
121+
Effect.provide(
122+
ProviderSecretResolverLive.pipe(
123+
Layer.provide(Layer.merge(spawner.layer, NodeFileSystem.layer)),
124+
),
125+
),
126+
);
107127
});
108128

109129
it.effect("drops a variable whose reference cannot be read", () => {
@@ -133,7 +153,13 @@ describe("ProviderSecretResolverLive", () => {
133153
// leaving it out would hand the provider whatever the server itself was
134154
// started with under that name.
135155
assert.deepStrictEqual(resolved.unresolved, ["CLAUDE_CODE_OAUTH_TOKEN"]);
136-
}).pipe(Effect.provide(ProviderSecretResolverLive.pipe(Layer.provide(spawner.layer))));
156+
}).pipe(
157+
Effect.provide(
158+
ProviderSecretResolverLive.pipe(
159+
Layer.provide(Layer.merge(spawner.layer, NodeFileSystem.layer)),
160+
),
161+
),
162+
);
137163
});
138164

139165
it.effect("holds a failed read too, so a locked vault prompts once", () => {
@@ -148,15 +174,26 @@ describe("ProviderSecretResolverLive", () => {
148174
yield* resolver.resolve(environment);
149175

150176
assert.strictEqual(spawner.invocations.length, 1);
151-
}).pipe(Effect.provide(ProviderSecretResolverLive.pipe(Layer.provide(spawner.layer))));
177+
}).pipe(
178+
Effect.provide(
179+
ProviderSecretResolverLive.pipe(
180+
Layer.provide(Layer.merge(spawner.layer, NodeFileSystem.layer)),
181+
),
182+
),
183+
);
152184
});
153185
});
154186

155187
const SECOND_REFERENCE = "op://Private/codex/credential";
156188

157189
/**
158190
* Spawner that answers each `op` invocation from `handler`, which is handed the
159-
* argv and, for `op inject`, the template that was piped to stdin.
191+
* argv and, for `op inject`, the template `op` would have read.
192+
*
193+
* The template is read back off disk through the `-i` path in the argv, which
194+
* is how `op` itself receives it. Reading it any other way would let a change
195+
* that stops writing the file pass, and that is the shape of the bug this
196+
* spawner exists to catch.
160197
*
161198
* The template matters: `prime` picks a random separator per call, so a test
162199
* cannot hard-code the output. Recovering the separator from the template is
@@ -169,20 +206,22 @@ function scriptedOpSpawner(
169206
) => { stdout: string; stderr: string; code: number },
170207
) {
171208
const invocations: Array<ReadonlyArray<string>> = [];
209+
const stdinUses: Array<boolean> = [];
172210
const layer = Layer.succeed(
173211
ChildProcessSpawner.ChildProcessSpawner,
174212
ChildProcessSpawner.make((command) =>
175-
Effect.gen(function* () {
213+
Effect.sync(() => {
176214
const cmd = command as unknown as {
177215
args: ReadonlyArray<string>;
178216
options?: { stdin?: Stream.Stream<Uint8Array> };
179217
};
180218
invocations.push(cmd.args);
181-
const stdin = cmd.options?.stdin;
182-
const chunks = stdin === undefined ? [] : yield* Stream.runCollect(stdin);
183-
const template = Array.from(chunks)
184-
.map((chunk) => new TextDecoder().decode(chunk))
185-
.join("");
219+
stdinUses.push(cmd.options?.stdin !== undefined);
220+
const inputPath = cmd.args[cmd.args.indexOf("-i") + 1];
221+
const template =
222+
cmd.args.includes("-i") && inputPath !== undefined
223+
? NodeFS.readFileSync(inputPath, "utf8")
224+
: "";
186225
const result = handler(cmd.args, template);
187226
return ChildProcessSpawner.makeHandle({
188227
pid: ChildProcessSpawner.ProcessId(1),
@@ -200,7 +239,7 @@ function scriptedOpSpawner(
200239
}),
201240
),
202241
);
203-
return { layer, invocations };
242+
return { layer, invocations, stdinUses };
204243
}
205244

206245
/** The separator `prime` chose, read back out of the template it built. */
@@ -227,7 +266,10 @@ describe("ProviderSecretResolverLive.prime", () => {
227266
yield* resolver.prime([TOKEN_REFERENCE, SECOND_REFERENCE]);
228267

229268
assert.strictEqual(spawner.invocations.length, 1);
230-
assert.deepStrictEqual(Array.from(spawner.invocations[0] ?? []), ["inject"]);
269+
assert.deepStrictEqual(Array.from(spawner.invocations[0] ?? []).slice(0, 2), [
270+
"inject",
271+
"-i",
272+
]);
231273

232274
// Both instances resolve out of the primed cache, so the fleet costs the
233275
// one authorization the batch already paid for.
@@ -243,7 +285,13 @@ describe("ProviderSecretResolverLive.prime", () => {
243285
assert.strictEqual(claude.variables?.[0]?.value, "sk-claude-token");
244286
assert.strictEqual(codex.variables?.[0]?.value, "sk-codex-token");
245287
assert.strictEqual(spawner.invocations.length, 1);
246-
}).pipe(Effect.provide(ProviderSecretResolverLive.pipe(Layer.provide(spawner.layer))));
288+
}).pipe(
289+
Effect.provide(
290+
ProviderSecretResolverLive.pipe(
291+
Layer.provide(Layer.merge(spawner.layer, NodeFileSystem.layer)),
292+
),
293+
),
294+
);
247295
});
248296

249297
it.effect("falls back to one read at a time when the batch fails", () => {
@@ -261,7 +309,7 @@ describe("ProviderSecretResolverLive.prime", () => {
261309
yield* resolver.prime([TOKEN_REFERENCE, SECOND_REFERENCE]);
262310

263311
// The batch is still attempted; it is the recovery that is per reference.
264-
assert.deepStrictEqual(Array.from(spawner.invocations[0] ?? []), ["inject"]);
312+
assert.strictEqual(spawner.invocations[0]?.[0], "inject");
265313

266314
// A batch that cannot be trusted leaves the cache cold rather than
267315
// caching a failure for every reference in it, so the good reference
@@ -278,7 +326,81 @@ describe("ProviderSecretResolverLive.prime", () => {
278326
assert.strictEqual(claude.variables?.[0]?.value, "sk-claude-token");
279327
assert.deepStrictEqual(Array.from(claude.unresolved), []);
280328
assert.deepStrictEqual(Array.from(codex.unresolved), ["CODEX_TOKEN"]);
281-
}).pipe(Effect.provide(ProviderSecretResolverLive.pipe(Layer.provide(spawner.layer))));
329+
}).pipe(
330+
Effect.provide(
331+
ProviderSecretResolverLive.pipe(
332+
Layer.provide(Layer.merge(spawner.layer, NodeFileSystem.layer)),
333+
),
334+
),
335+
);
336+
});
337+
338+
it.effect("hands the template to a file `op` will actually read", () => {
339+
let seenTemplate = "";
340+
const spawner = scriptedOpSpawner((args, template) => {
341+
if (args.includes("inject")) {
342+
seenTemplate = template;
343+
return {
344+
stdout: ["sk-claude-token", "sk-codex-token"].join(separatorOf(template)),
345+
stderr: "",
346+
code: 0,
347+
};
348+
}
349+
return { stdout: "should-not-be-read-one-at-a-time", stderr: "", code: 0 };
350+
});
351+
return Effect.gen(function* () {
352+
const resolver = yield* ProviderSecretResolver;
353+
354+
yield* resolver.prime([TOKEN_REFERENCE, SECOND_REFERENCE]);
355+
356+
// `op` only reads piped input from a named pipe, and Node hands a child
357+
// a socket pair, so a template offered on stdin is never seen and the
358+
// batch fails every time. The `-i` path is the delivery that works.
359+
const args = Array.from(spawner.invocations[0] ?? []);
360+
assert.deepStrictEqual(args.slice(0, 2), ["inject", "-i"]);
361+
assert.isTrue((args[2] ?? "").length > 0);
362+
assert.deepStrictEqual(spawner.stdinUses, [false]);
363+
364+
// The file `op` was pointed at held both references and nothing else,
365+
// so a secret never reaches the disk.
366+
assert.isTrue(seenTemplate.includes(TOKEN_REFERENCE));
367+
assert.isTrue(seenTemplate.includes(SECOND_REFERENCE));
368+
}).pipe(
369+
Effect.provide(
370+
ProviderSecretResolverLive.pipe(
371+
Layer.provide(Layer.merge(spawner.layer, NodeFileSystem.layer)),
372+
),
373+
),
374+
);
375+
});
376+
377+
it.effect("leaves no template behind once the batch is done", () => {
378+
let templatePath = "";
379+
const spawner = scriptedOpSpawner((args, template) => {
380+
if (args.includes("inject")) {
381+
templatePath = args[args.indexOf("-i") + 1] ?? "";
382+
return {
383+
stdout: ["sk-claude-token", "sk-codex-token"].join(separatorOf(template)),
384+
stderr: "",
385+
code: 0,
386+
};
387+
}
388+
return { stdout: "", stderr: "", code: 0 };
389+
});
390+
return Effect.gen(function* () {
391+
const resolver = yield* ProviderSecretResolver;
392+
393+
yield* resolver.prime([TOKEN_REFERENCE, SECOND_REFERENCE]);
394+
395+
assert.isTrue(templatePath.length > 0);
396+
assert.isFalse(NodeFS.existsSync(templatePath));
397+
}).pipe(
398+
Effect.provide(
399+
ProviderSecretResolverLive.pipe(
400+
Layer.provide(Layer.merge(spawner.layer, NodeFileSystem.layer)),
401+
),
402+
),
403+
);
282404
});
283405

284406
it.effect("does not spawn a batch for a single reference", () => {
@@ -291,6 +413,12 @@ describe("ProviderSecretResolverLive.prime", () => {
291413
// One reference is one prompt either way, and `op read` names the
292414
// reference it could not resolve.
293415
assert.strictEqual(spawner.invocations.length, 0);
294-
}).pipe(Effect.provide(ProviderSecretResolverLive.pipe(Layer.provide(spawner.layer))));
416+
}).pipe(
417+
Effect.provide(
418+
ProviderSecretResolverLive.pipe(
419+
Layer.provide(Layer.merge(spawner.layer, NodeFileSystem.layer)),
420+
),
421+
),
422+
);
295423
});
296424
});

‎apps/server/src/provider/Layers/ProviderSecretResolverLive.ts‎

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ import * as Cache from "effect/Cache";
2929
import * as Duration from "effect/Duration";
3030
import * as Effect from "effect/Effect";
3131
import * as Layer from "effect/Layer";
32+
import * as FileSystem from "effect/FileSystem";
3233
import * as Option from "effect/Option";
33-
import * as Stream from "effect/Stream";
3434
import * as NodeCrypto from "node:crypto";
3535
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3636
import { resolveSpawnCommand } from "@t3tools/shared/shell";
@@ -72,18 +72,31 @@ const SECRET_CACHE_CAPACITY = 64;
7272
* it. The caller treats that as "not primed" and reads one at a time, which is
7373
* both the per-variable failure isolation and the way the user finds out which
7474
* reference is the broken one.
75+
*
76+
* The template goes to `op` through a scoped temp file rather than stdin.
77+
* `op` only reads piped input from a named pipe, and a child spawned from Node
78+
* is handed a socket pair, so a piped template is never seen and the batch
79+
* fails with "expected data on stdin but none found" every single time. The
80+
* file holds only `op://` references, which already sit in settings in plain
81+
* text; the secrets themselves come back on stdout and never touch disk.
7582
*/
7683
const readSecretsTogether = Effect.fn("readSecretsTogether")(function* (
7784
references: ReadonlyArray<string>,
7885
) {
86+
const fileSystem = yield* FileSystem.FileSystem;
7987
const separator = `__t3-secret-${NodeCrypto.randomUUID()}__`;
8088
const template = references.map((reference) => `{{ ${reference} }}`).join(separator);
81-
const spawnCommand = yield* resolveSpawnCommand(ONE_PASSWORD_BINARY, ["inject"]);
89+
const templatePath = yield* fileSystem.makeTempFileScoped({ prefix: "t3code-op-inject-" });
90+
yield* fileSystem.writeFileString(templatePath, template);
91+
const spawnCommand = yield* resolveSpawnCommand(ONE_PASSWORD_BINARY, [
92+
"inject",
93+
"-i",
94+
templatePath,
95+
]);
8296
const result = yield* spawnAndCollect(
8397
ONE_PASSWORD_BINARY,
8498
ChildProcess.make(spawnCommand.command, spawnCommand.args, {
8599
shell: spawnCommand.shell,
86-
stdin: Stream.make(new TextEncoder().encode(template)),
87100
}),
88101
);
89102
if (result.code !== 0) {
@@ -137,14 +150,16 @@ const readSecret = Effect.fn("readSecret")(function* (reference: string) {
137150
export const ProviderSecretResolverLive: Layer.Layer<
138151
ProviderSecretResolver,
139152
never,
140-
ChildProcessSpawner.ChildProcessSpawner
153+
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem
141154
> = Layer.effect(
142155
ProviderSecretResolver,
143156
Effect.gen(function* () {
144-
// The service tag declares `prime` as `Effect<void>`, so the spawner it
145-
// needs is captured here rather than asked of the caller, the same way
146-
// the cache's own lookup captures it.
147-
const spawnerContext = yield* Effect.context<ChildProcessSpawner.ChildProcessSpawner>();
157+
// The service tag declares `prime` as `Effect<void>`, so the spawner and
158+
// the filesystem it needs are captured here rather than asked of the
159+
// caller, the same way the cache's own lookup captures the spawner.
160+
const primeContext = yield* Effect.context<
161+
ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem
162+
>();
148163

149164
const cache = yield* Cache.make({
150165
capacity: SECRET_CACHE_CAPACITY,
@@ -201,6 +216,7 @@ export const ProviderSecretResolverLive: Layer.Layer<
201216
return;
202217
}
203218
const values = yield* readSecretsTogether(wanted).pipe(
219+
Effect.scoped,
204220
Effect.timeoutOption(SECRET_READ_TIMEOUT),
205221
Effect.map(Option.getOrUndefined),
206222
Effect.catch((error) =>
@@ -220,7 +236,7 @@ export const ProviderSecretResolverLive: Layer.Layer<
220236
discard: true,
221237
},
222238
);
223-
}).pipe(Effect.provideContext(spawnerContext));
239+
}).pipe(Effect.provideContext(primeContext));
224240

225241
return { resolve, prime, invalidate: Cache.invalidateAll(cache) };
226242
}),

0 commit comments

Comments
 (0)