Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions lib/identity/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,19 @@ var PROVIDER_TIMEOUT_MS = 15000;
// { timedOut } it did not answer within `ms`
// When the limit wins, onTimeout(pending) runs so the caller can cancel the
// provider and claim whatever it yields later.
function askProvider(call, ms, onTimeout) {
function askProvider(call, ms, onTimeout, timers) {
var pending = new Promise(function (resolve) { resolve(call()); });
return new Promise(function (resolve) {
var settled = false;
function settle(outcome) {
if (settled) return;
settled = true;
clearTimeout(timer);
timers.clearTimeout(timer);
resolve(outcome);
}
// The limit is decided before onTimeout runs: cancelling the provider can
// settle its own promise synchronously, and that must not outrank the timeout.
var timer = setTimeout(function () {
var timer = timers.setTimeout(function () {
settle({ timedOut: true });
onTimeout(pending);
}, ms);
Expand Down Expand Up @@ -90,8 +90,12 @@ function declaredLease(origin, result) {
// test can make a provider settle late, and nothing in the console sets it.
// options.releaseTimeoutMs bounds how long a provider's release is waited on for a
// warning; it exists for the same reason.
// options.timers ({ setTimeout, clearTimeout }) replaces the clock that decides the
// provider limit, so a test can fire the limit at a chosen moment instead of racing
// a provider against wall-clock time; nothing in the console sets it.
function createIdentity(options) {
var warn = options.warn || console.warn;
var timers = options.timers || { setTimeout: setTimeout, clearTimeout: clearTimeout };
var providerTimeoutMs = options.providerTimeoutMs > 0 ? options.providerTimeoutMs : PROVIDER_TIMEOUT_MS;
var current = { key: null, cfg: null, provider: null };

Expand Down Expand Up @@ -175,7 +179,8 @@ function createIdentity(options) {
holder.end(null);
warn("[identity] the provider failed after the console stopped waiting: " + (err && err.message));
});
}
},
timers
);
var result;
if (answer.timedOut) {
Expand Down
101 changes: 84 additions & 17 deletions test/identity-surface.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -613,11 +613,31 @@ test("a provider claiming a human origin for a turn nobody typed is refused and

// --- A provider that settles after the console stopped waiting ---

function lateIdentityFor(raw, cfgDir, warnings) {
// The late-settle tests fix the order of events instead of racing a provider
// against a wall-clock limit: the identity surface is given a clock that only the
// test advances, so the limit fires exactly when the test says and a provider that
// is still working then is late by construction, however fast or loaded the host.
function manualTimers() {
var pending = null;
return {
setTimeout: function (fn) { pending = fn; return fn; },
clearTimeout: function (handle) { if (pending === handle) pending = null; },
// Fires the provider limit; it throws if no limit is armed, so a test cannot
// silently skip the timeout it is about.
fire: function () {
assert.ok(pending, "the provider limit is armed");
var fn = pending;
pending = null;
fn();
},
};
}

function lateIdentityFor(raw, cfgDir, warnings, timers) {
return createIdentity({
getConfig: function () { return raw; },
configDir: cfgDir,
providerTimeoutMs: 100,
timers: timers,
warn: function (message) { warnings.push(message); },
});
}
Expand All @@ -626,59 +646,106 @@ function pause(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}

// Waits for something a late settlement does, without assuming how long it takes.
// The bound only turns a hang into a failure; it is never what the test waits for.
async function until(condition, what) {
for (var i = 0; i < 2000; i++) {
if (condition()) return;
await pause(5);
}
assert.fail("never happened: " + what);
}

// A gate a module provider parks on: `reached` resolves when the provider arrives
// at it, `open` lets it go on.
function installGate() {
var open;
var reached;
var gate = {
reached: new Promise(function (resolve) { reached = resolve; }),
open: function () { open(); },
};
globalThis.__identityTestGate = new Promise(function (resolve) { open = resolve; });
globalThis.__identityTestReached = reached;
return gate;
}

var LATE_REQUEST = { definition: null, initiator: INITIATORS.LOOP, rt: "claude-code" };

// Allocates its directory before the timeout, answers after it.
var LATE_AFTER_ALLOCATE = MODULE_SIGNER
.replace("return { dir,", "await new Promise((r) => setTimeout(r, 400)); return { dir,");
.replace("return { dir,", "globalThis.__identityTestReached(); await globalThis.__identityTestGate; return { dir,");

// Waits past the timeout, then asks for its directory.
var LATE_BEFORE_ALLOCATE = MODULE_SIGNER
.replace("const dir = ctx.allocateDir();", "await new Promise((r) => setTimeout(r, 400)); const dir = ctx.allocateDir();");
.replace("const dir = ctx.allocateDir();", "globalThis.__identityTestReached(); await globalThis.__identityTestGate; const dir = ctx.allocateDir();");

test("a module provider that answers after the timeout has its directory removed and its release called", async function () {
var cfgDir = scratch();
var warnings = [];
var lease = await lateIdentityFor({ provider_module: writeModule(LATE_AFTER_ALLOCATE) }, cfgDir, warnings).begin(LATE_REQUEST);
var timers = manualTimers();
var gate = installGate();
var asking = lateIdentityFor({ provider_module: writeModule(LATE_AFTER_ALLOCATE) }, cfgDir, warnings, timers).begin(LATE_REQUEST);
await gate.reached;
assert.equal(entries(path.join(cfgDir, "identity", "spawn")).length, 1, "the directory exists while the provider is still working");
timers.fire();
var lease = await asking;
assert.equal(lease.dir, null, "the child is given no CLAGENTIC_IDENTITY_DIR");
assert.equal(lease.record.unavailable, "provider_timeout");
await pause(700);
gate.open();
await until(function () { return globalThis.__identityTestLog.some(function (e) { return e.released; }); }, "the provider's release");
assert.deepEqual(entries(path.join(cfgDir, "identity", "spawn")), [], "no directory remains under the spawn root");
assert.deepEqual(globalThis.__identityTestLog.filter(function (e) { return e.released; }), [{ released: "spiffe-client" }]);
});

test("a module provider that asks for a directory after the timeout is refused one", async function () {
var cfgDir = scratch();
var warnings = [];
var lease = await lateIdentityFor({ provider_module: writeModule(LATE_BEFORE_ALLOCATE) }, cfgDir, warnings).begin(LATE_REQUEST);
var timers = manualTimers();
var gate = installGate();
var asking = lateIdentityFor({ provider_module: writeModule(LATE_BEFORE_ALLOCATE) }, cfgDir, warnings, timers).begin(LATE_REQUEST);
await gate.reached;
timers.fire();
var lease = await asking;
assert.equal(lease.dir, null);
await pause(700);
gate.open();
await until(function () { return warnings.some(function (w) { return /no longer waits/.test(w); }); }, "the refusal of the late directory request");
assert.deepEqual(entries(path.join(cfgDir, "identity", "spawn")), [], "no directory was made for a session nobody waits for");
assert.ok(warnings.some(function (w) { return /no longer waits/.test(w); }), warnings.join("|"));
});

test("the jwks-self signer settling after the timeout leaves no directory and gives the child none", async function () {
var cfgDir = scratch();
var identity = createIdentity({
getConfig: function () { return { provider: "jwks-self" }; },
configDir: cfgDir,
providerTimeoutMs: 1,
warn: function () {},
});
var lease = await identity.begin(LATE_REQUEST);
var warnings = [];
var timers = manualTimers();
var identity = lateIdentityFor({ provider: "jwks-self" }, cfgDir, warnings, timers);
// begin() arms the limit and starts the real signer in its first synchronous
// step, and the signer cannot finish before the next microtask: firing the limit
// here makes the signer late, whatever the host's speed.
var asking = identity.begin(LATE_REQUEST);
timers.fire();
var lease = await asking;
assert.equal(lease.dir, null);
assert.equal(lease.record.unavailable, "provider_timeout");
await pause(500);
await until(function () { return warnings.some(function (w) { return /no longer waits/.test(w); }); }, "the signer's late request for a directory");
assert.deepEqual(entries(path.join(cfgDir, "identity", "spawn")), [], "no directory remains under the spawn root");
});

test("a gatekeeper session still pending at the timeout is cancelled: no issued directory remains and no variable is passed", async function () {
var issuer = installFakeIssuer();
try {
var warnings = [];
var lease = await lateIdentityFor(gatekeeperConfig(issuer, "late.sock"), scratch(), warnings).begin(LATE_REQUEST);
var timers = manualTimers();
var asking = lateIdentityFor(gatekeeperConfig(issuer, "late.sock"), scratch(), warnings, timers).begin(LATE_REQUEST);
// The limit fires only once the client is running, which it records first
// thing; under load it can otherwise be cancelled before it ever starts.
await until(function () { return issuer.inheritedDirs().length > 0; }, "the issuer's client to start");
timers.fire();
var lease = await asking;
assert.equal(lease.dir, null);
assert.equal(lease.record.unavailable, "provider_timeout");
// The client would issue its session 700 ms after it started; this is the
// window in which a client that was not cancelled would do so.
await pause(1200);
assert.deepEqual(issuer.issued(), [], "the issuer's late session never came into being or was removed");
assert.deepEqual(issuer.inheritedDirs(), ["undefined"], "the client was not handed an identity directory");
Expand Down
Loading