@@ -6,6 +6,8 @@ import * as Schema from "effect/Schema";
66import * as Sink from "effect/Sink" ;
77import * as Stream from "effect/Stream" ;
88import { ChildProcessSpawner } from "effect/unstable/process" ;
9+ import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem" ;
10+ import * as NodeFS from "node:fs" ;
911
1012import { ProviderSecretResolverLive } from "./ProviderSecretResolverLive.ts" ;
1113import { 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
155187const 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} ) ;
0 commit comments