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
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,10 @@ public Selection select() {
AND (rate_limited_until IS NULL OR rate_limited_until <= now())
ORDER BY exhausted_at NULLS FIRST, last_used_at NULLS FIRST
LIMIT 1)
-- Checked again here, after any wait for the row: a member switched off or erased
-- while this pick waited must not be returned, or its missing key would be read as
-- a corrupt one and the member marked refused (review of PR #179).
AND enabled AND erased_at IS NULL
RETURNING id, label, type, base_url, api_key
""";
UUID chosen = null;
Expand Down Expand Up @@ -384,7 +388,7 @@ public List<MemberView> list() {
String sql = """
SELECT id, label, type, base_url, enabled, rate_limited_until, rejected_at, last_used_at,
auth_mode, auth_mode <> 'SUBSCRIPTION' OR account_ref IS NOT NULL AS identified
FROM harness_credential ORDER BY label
FROM harness_credential WHERE erased_at IS NULL ORDER BY label
""";
List<MemberView> members = new ArrayList<>();
try (Connection c = dataSource.getConnection(); PreparedStatement ps = c.prepareStatement(sql);
Expand Down Expand Up @@ -444,7 +448,7 @@ INSERT INTO harness_credential (id, label, type, base_url, api_key)
* person to their phone. Finding out afterwards means a completed sign-in with nowhere to go.
*/
public boolean hasLabel(Connection c, String label) throws SQLException {
try (PreparedStatement ps = c.prepareStatement("SELECT 1 FROM harness_credential WHERE label = ?")) {
try (PreparedStatement ps = c.prepareStatement("SELECT 1 FROM harness_credential WHERE label = ? AND erased_at IS NULL")) {
ps.setString(1, label);
try (ResultSet rs = ps.executeQuery()) { return rs.next(); }
}
Expand Down Expand Up @@ -555,7 +559,7 @@ private int identifySeats(Connection c) throws SQLException {
int switchedOff = 0;
try (PreparedStatement ps = c.prepareStatement("""
SELECT id, type, api_key FROM harness_credential
WHERE auth_mode = 'SUBSCRIPTION' AND account_ref IS NULL ORDER BY updated_at DESC
WHERE auth_mode = 'SUBSCRIPTION' AND account_ref IS NULL AND erased_at IS NULL ORDER BY updated_at DESC
"""); ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
UUID id = rs.getObject("id", UUID.class);
Expand Down Expand Up @@ -607,10 +611,88 @@ public boolean remove(UUID id) {
id) == 1;
}

/** What {@link #delete} did. */
public enum Deletion {
/** No run used it, so the row is gone. */
DELETED,
/** A run used it: its secret is erased and it is hidden, but the row stays so the run can name it. */
ERASED,
/** It is still switched on. Switch it off first, so no build is picking it while it goes. */
STILL_ON,
NOT_FOUND
}

/**
* Delete a switched-off member (operator feedback, 2026-09-27).
*
* <p>Switching off was the only way to remove one, and it kept the key or the person's sign-in file
* stored for ever. A member no run used is now deleted outright. One a run used cannot be — the run's
* attribution points at it — so its secret is erased and it leaves the pool screen, keeping only the
* name a finished run shows. Either way the secret is gone.
*/
public Deletion delete(UUID id) {
try (Connection c = dataSource.getConnection()) {
c.setAutoCommit(false);
try {
Deletion outcome = delete(c, id);
c.commit();
return outcome;
} catch (SQLException | RuntimeException failure) {
c.rollback();
throw failure;
}
} catch (SQLException e) {
throw new IllegalStateException("The harness credential " + id + " could not be deleted", e);
}
}

private Deletion delete(Connection c, UUID id) throws SQLException {
try (PreparedStatement ps = c.prepareStatement(
"SELECT enabled FROM harness_credential WHERE id = ? AND erased_at IS NULL FOR UPDATE")) {
ps.setObject(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) return Deletion.NOT_FOUND;
if (rs.getBoolean("enabled")) return Deletion.STILL_ON;
}
}
// Used by a run, or picked lately: a dispatch picks a member and registers its run a moment later,
// outside one transaction, so a member picked in the last hour may be about to be named by a run
// not written yet. Erasing keeps the row that run will point at; deleting would fail its insert.
boolean used;
try (PreparedStatement ps = c.prepareStatement("""
SELECT EXISTS (SELECT 1 FROM factory_run WHERE harness_credential_id = ?)
OR EXISTS (SELECT 1 FROM harness_credential WHERE id = ? AND last_used_at > now() - interval '1 hour')
""")) {
ps.setObject(1, id);
ps.setObject(2, id);
try (ResultSet rs = ps.executeQuery()) { rs.next(); used = rs.getBoolean(1); }
}
if (used) {
try (PreparedStatement ps = c.prepareStatement("""
UPDATE harness_credential SET api_key = NULL, account_ref = NULL, erased_at = now(), updated_at = now()
WHERE id = ?
""")) {
ps.setObject(1, id);
ps.executeUpdate();
}
return Deletion.ERASED;
}
// The sign-in that produced a seat is history; it keeps its row and forgets the member.
try (PreparedStatement ps = c.prepareStatement("UPDATE harness_sign_in SET credential_id = NULL WHERE credential_id = ?")) {
ps.setObject(1, id);
ps.executeUpdate();
}
try (PreparedStatement ps = c.prepareStatement("DELETE FROM harness_credential WHERE id = ?")) {
ps.setObject(1, id);
ps.executeUpdate();
}
return Deletion.DELETED;
}

/** Bring a disabled member back, because disabling is not deletion and must not be one-way. */
public boolean enable(UUID id) {
try (Connection c = dataSource.getConnection(); PreparedStatement ps = c.prepareStatement(
"UPDATE harness_credential SET enabled = TRUE, updated_at = now() WHERE id = ? AND NOT enabled")) {
"UPDATE harness_credential SET enabled = TRUE, updated_at = now() WHERE id = ? AND NOT enabled AND erased_at IS NULL")) {
ps.setObject(1, id);
return ps.executeUpdate() == 1;
} catch (SQLException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,25 @@ public Response disable(@PathParam("id") String id) {
return Response.noContent().build();
}

/**
* Delete a switched-off member: outright when no run used it, otherwise by erasing its secret and hiding
* it, keeping the name a finished run shows. A member still switched on is refused (409): a build could
* be picking it at that moment.
*/
@POST
@Path("/{id}/delete")
@Consumes(MediaType.WILDCARD)
public Response delete(@PathParam("id") String id) {
HarnessCredentialPool.Deletion outcome = pool.delete(uuid(id));
switch (outcome) {
case NOT_FOUND -> throw new NotFoundException("no such harness credential: " + id);
case STILL_ON -> throw new ClientErrorException(Response.status(Response.Status.CONFLICT)
.entity("harness_credential_still_on").type(MediaType.TEXT_PLAIN).build());
case DELETED, ERASED -> LOG.warnf("harness credential %s was deleted by an operator (%s)", id, outcome);
}
return Response.ok(java.util.Map.of("outcome", outcome.name())).build();
}

/** Return a disabled member to the pool. Disabling is not deletion, so it is not one-way. */
@POST
@Path("/{id}/enable")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Deleting a harness key or subscription seat (operator feedback, 2026-09-27).
--
-- A member no run used is deleted outright. A member a run used cannot be: factory_run points at it,
-- and that attribution is the record of who paid. So such a member is ERASED instead: its stored key or
-- sign-in file is removed, it can never be switched on again, and it disappears from the pool screen.
-- The row stays only so a finished run can still name what paid for it.
ALTER TABLE harness_credential ALTER COLUMN api_key DROP NOT NULL;
ALTER TABLE harness_credential ADD COLUMN erased_at TIMESTAMPTZ;

-- A live member has a secret; an erased one has none, is off, and names no account.
ALTER TABLE harness_credential ADD CONSTRAINT harness_credential_secret_unless_erased
CHECK (erased_at IS NOT NULL OR api_key IS NOT NULL);
ALTER TABLE harness_credential ADD CONSTRAINT harness_credential_erased_holds_nothing
CHECK (erased_at IS NULL OR (api_key IS NULL AND NOT enabled AND account_ref IS NULL));

-- An erased member's name is free again, so the operator can reuse it. It stays on the erased row, which
-- is what a finished run shows.
ALTER TABLE harness_credential DROP CONSTRAINT harness_credential_label_key;
CREATE UNIQUE INDEX harness_credential_label_live ON harness_credential (label) WHERE erased_at IS NULL;
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package dev.codespire.orchestrator.factory;

import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.UUID;

import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.*;

/**
* Deleting a harness key or seat (operator feedback, 2026-09-27): switching off kept the key or the
* person's sign-in file stored for ever. Placeholder values throughout.
*/
@QuarkusTest
class HarnessCredentialDeleteTest {

@Inject HarnessCredentialPool pool;
@Inject FactoryRunProjection runs;
@Inject HarnessSignIns signIns;
@Inject dev.codespire.encryption.EncryptionService encryption;
@Inject DataSource dataSource;

private static String label() {
return "TEST-delete-" + UUID.randomUUID();
}

private UUID switchedOffKey(String label) {
UUID id = pool.add(label, "openai", "https://api.openai.com", "TEST-key-to-delete").id();
pool.remove(id);
return id;
}

private long rows(String sql, Object id) throws SQLException {
try (Connection c = dataSource.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) {
ps.setObject(1, id);
try (ResultSet rs = ps.executeQuery()) { rs.next(); return rs.getLong(1); }
}
}

private boolean listed(UUID id) {
return pool.list().stream().anyMatch(member -> member.id().equals(id));
}

/** A member no run used is gone, and its name is free again. */
@Test
void aMemberNoRunUsedIsDeletedOutright() throws SQLException {
String label = label();
UUID id = switchedOffKey(label);

assertEquals(HarnessCredentialPool.Deletion.DELETED, pool.delete(id));

assertEquals(0, rows("SELECT count(*) FROM harness_credential WHERE id = ?", id));
UUID again = pool.add(label, "openai", "https://api.openai.com", "TEST-key-again").id();
pool.remove(again);
}

/**
* A member a run used keeps the row the run points at, but loses its secret, leaves the screen and can
* never be switched on again.
*/
@Test
void aMemberARunUsedIsErasedAndKeptOnlyForTheRun() throws SQLException {
String label = label();
UUID id = switchedOffKey(label);
String runId = "run::github:TEST-acme/app:subject-" + UUID.randomUUID() + ":1";
assertTrue(runs.queued(new FactoryRunProjection.QueuedRun(runId, "codex", "TEST-model", "main",
"abc1234", "spire/TEST-delete", "TEST-bot", id), null, null));

assertEquals(HarnessCredentialPool.Deletion.ERASED, pool.delete(id));

assertEquals(1, rows("SELECT count(*) FROM harness_credential WHERE id = ? AND api_key IS NULL AND erased_at IS NOT NULL", id),
"the secret is gone; the row stays for the run's attribution");
assertFalse(listed(id), "an erased member is not on the pool screen");
assertFalse(pool.enable(id), "an erased member has nothing to switch on");
HarnessSignIns.Started signIn = signIns.start(label, "codex", "TEST-operator");
assertNull(signIn.refusal(), "an erased member's name is free for a new sign-in");
signIns.cancel(signIn.view().id(), "TEST: only the name was checked");
UUID again = pool.add(label, "openai", "https://api.openai.com", "TEST-key-again").id();
pool.remove(again);
}

/**
* A dispatch picks a member and writes its run a moment later; a member picked lately may be about to
* be named by a run not written yet, so it is erased rather than deleted (review of PR #179).
*/
@Test
void aMemberPickedLatelyIsErasedRatherThanDeleted() throws SQLException {
UUID id = switchedOffKey(label());
try (Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("UPDATE harness_credential SET last_used_at = now() WHERE id = ?")) {
ps.setObject(1, id);
ps.executeUpdate();
}

assertEquals(HarnessCredentialPool.Deletion.ERASED, pool.delete(id));
assertEquals(1, rows("SELECT count(*) FROM harness_credential WHERE id = ?", id), "the row a pending run needs stays");
}

/**
* A key switched off and erased while a pick waits for it is not handed out, and not mistaken for a
* corrupt key and marked refused (review of PR #179). Every other usable key is held and switched off
* in the same transaction, so whichever the pick chose, it has to wait and re-check.
*/
@Test
void aKeyErasedWhileAPickWaitsIsNotReturnedNorMarkedRefused() throws Exception {
UUID id = pool.add(label(), "openai", "https://api.openai.com", "TEST-key-erased-in-flight").id();
java.util.List<UUID> others = new java.util.ArrayList<>();
try (Connection other = dataSource.getConnection()) {
other.setAutoCommit(false);
try (PreparedStatement ps = other.prepareStatement("""
UPDATE harness_credential SET enabled = FALSE
WHERE enabled AND auth_mode = 'API_KEY' AND rejected_at IS NULL AND id <> ?
RETURNING id
""")) {
ps.setObject(1, id);
try (ResultSet rs = ps.executeQuery()) { while (rs.next()) others.add(rs.getObject(1, UUID.class)); }
}
try (PreparedStatement ps = other.prepareStatement(
"UPDATE harness_credential SET enabled = FALSE, api_key = NULL, erased_at = now() WHERE id = ?")) {
ps.setObject(1, id);
ps.executeUpdate();
}
java.util.concurrent.CompletableFuture<HarnessCredentialPool.Selection> picking =
java.util.concurrent.CompletableFuture.supplyAsync(pool::select);

assertThrows(java.util.concurrent.TimeoutException.class,
() -> picking.get(1, java.util.concurrent.TimeUnit.SECONDS), "the pick waits for the held rows");
other.commit();
HarnessCredentialPool.Selection selection = picking.get(30, java.util.concurrent.TimeUnit.SECONDS);

assertFalse(selection instanceof HarnessCredentialPool.Selection.Chosen, "every candidate was switched off meanwhile");
assertEquals(0, rows("SELECT count(*) FROM harness_credential WHERE id = ? AND rejected_at IS NOT NULL", id),
"an erased key is not a refused one");
} finally {
try (Connection c = dataSource.getConnection();
PreparedStatement ps = c.prepareStatement("UPDATE harness_credential SET enabled = TRUE WHERE id = ANY (?)")) {
ps.setArray(1, c.createArrayOf("uuid", others.toArray()));
ps.executeUpdate();
}
}
}

/** A member still switched on could be picked by a build at this moment, so it must be switched off first. */
@Test
void aMemberStillSwitchedOnIsRefused() {
UUID id = pool.add(label(), "openai", "https://api.openai.com", "TEST-key-on").id();
try {
assertEquals(HarnessCredentialPool.Deletion.STILL_ON, pool.delete(id));
assertTrue(listed(id));
} finally {
pool.remove(id);
}
}

/** A seat's sign-in record stays as history and forgets the seat. */
@Test
void aSeatsSignInRecordForgetsTheDeletedSeat() throws SQLException {
String label = "TEST-delete-seat-" + UUID.randomUUID();
HarnessSignIns.Started started = signIns.start(label, "codex", "TEST-operator");
assertNull(started.refusal(), started.refusal());
UUID signIn = started.view().id();
String file = "{\"auth_mode\":\"TEST-chatgpt\",\"tokens\":{\"account_id\":\"TEST-account-" + UUID.randomUUID() + "\"}}";
signIns.completed(new dev.codespire.contract.event.HarnessSignInResult.Completed(signIn.toString(),
encryption.encryptString(file, dev.codespire.contract.event.HarnessSignInResult.sealedAad(signIn.toString())),
"TEST-chatgpt", "TEST-chatgpt"));
UUID seat = signIns.get(signIn).orElseThrow().credentialId();
pool.remove(seat);

assertEquals(HarnessCredentialPool.Deletion.DELETED, pool.delete(seat));

assertNull(signIns.get(signIn).orElseThrow().credentialId());
}

@Test
@TestSecurity(user = "op", roles = "spire-admin")
void theScreenIsToldToSwitchItOffFirst() {
UUID id = pool.add(label(), "openai", "https://api.openai.com", "TEST-key-rest").id();
try {
given().when().post("/api/harness-credentials/" + id + "/delete")
.then().statusCode(409).body(org.hamcrest.Matchers.containsString("harness_credential_still_on"));
pool.remove(id);
given().when().post("/api/harness-credentials/" + id + "/delete")
.then().statusCode(200).body("outcome", org.hamcrest.Matchers.is("DELETED"));
} finally {
pool.remove(id);
}
}
}
7 changes: 7 additions & 0 deletions spire-ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1732,6 +1732,13 @@ export const enableHarnessCredential = (id: string) => credentialAction(id, '/en
export const clearHarnessCredentialRejection = (id: string) => credentialAction(id, '/clear-rejection', 'POST', 'Failed to clear the rejection');
export const restHarnessCredential = (id: string) => credentialAction(id, '/rest', 'POST', 'Failed to rest the credential');

/** DELETED: gone. ERASED: a run used it, so its secret is erased and only its name stays, for that run. */
export async function deleteHarnessCredential(id: string): Promise<'DELETED' | 'ERASED'> {
const res = await apiFetch(`/api/harness-credentials/${encodeURIComponent(id)}/delete`, { method: 'POST' });
if (!res.ok) return throwResponse(res, 'Failed to delete the credential');
return (await res.json()).outcome;
}

/**
* A subscription sign-in in progress (M3.5 part F).
*
Expand Down
Loading
Loading