Skip to content

Commit 8a970d5

Browse files
committed
fix: restore server route and service fork patches
- Remove database backup health monitoring from health route (fork removal) - Restore assertSurfaceExposed in secrets routes - Fix garbled import block in companies route - Fix agents route: remove dangling imports, relocate adapterType property - Relocate prebakedRuntime in environment-execution-target - Additional server/adapter fixes from parallel agents
1 parent a1c1eea commit 8a970d5

6 files changed

Lines changed: 158 additions & 90 deletions

File tree

packages/adapters/codex-local/src/server/codex-home.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -734,13 +734,20 @@ export async function seedManagedCodexHome(
734734
}
735735

736736
if (seedFromShared) {
737-
for (const name of SYMLINKED_SHARED_FILES) {
738-
// The kept promoted credential is authoritative for this home; the shared
739-
// symlink would silently swap the account back to the host login.
740-
if (name === "auth.json" && keepPromotedAuth) continue;
741-
const source = path.join(sourceHome, name);
742-
if (!(await pathExists(source))) continue;
743-
await ensureSymlink(path.join(targetHome, name), source);
737+
// A user-supplied credential is the whole point of the hosted path, so the
738+
// shared host auth.json must NOT be symlinked in on top of it: on a hosted
739+
// install that file is the operator's, and on any install the symlink would
740+
// make the user's own credential unreachable. Static config is still
741+
// shared either way; only auth is theirs.
742+
if (!authJson) {
743+
for (const name of SYMLINKED_SHARED_FILES) {
744+
// The kept promoted credential is authoritative for this home; the shared
745+
// symlink would silently swap the account back to the host login.
746+
if (name === "auth.json" && keepPromotedAuth) continue;
747+
const source = path.join(sourceHome, name);
748+
if (!(await pathExists(source))) continue;
749+
await ensureSymlink(path.join(targetHome, name), source);
750+
}
744751
}
745752

746753
for (const name of COPIED_SHARED_FILES) {

packages/adapters/codex-local/src/server/execute.ts

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
import { buildCodexAuthInboundProvision } from "./codex-auth-merge-scripts.js";
1616
import { copyBackCodexAuth } from "./codex-auth-copyback.js";
1717
import {
18-
ensureCodexAuthCacheEntryDir,
1918
isCodexAuthCacheEnabled,
2019
resolveCodexAuthCacheEntryPath,
2120
selectVendCredential,
@@ -859,23 +858,65 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
859858
// generic `restore` seam per asset before destroying the sandbox.
860859
// Target is the shared symlink SOURCE (what managed homes point
861860
// `auth.json` at), not the in-sandbox symlink.
862-
restore: async ({ assetDir, readFile }) =>
861+
restore: async ({ assetDir, readFile }) => {
862+
// Hosted path first. When the run authenticated with a
863+
// credential the USER supplied, the refreshed copy has to go
864+
// back to where that credential came from -- the credential
865+
// store -- not to any host file. This is not an optimisation:
866+
// OpenAI rotates the refresh token on every refresh and the
867+
// old one dies immediately, so skipping this would leave the
868+
// stored credential invalid from the next run onward. The
869+
// feature would look fine for about an hour and then break
870+
// permanently, which is worse than not shipping it.
871+
if (configuredCodexAuthJson && ctx.onCredentialRotated) {
872+
try {
873+
const rotated = (await readFile(path.posix.join(assetDir, "auth.json"))).toString("utf8");
874+
if (shouldReplaceStoredCodexAuth(configuredCodexAuthJson, rotated)) {
875+
await ctx.onCredentialRotated({
876+
envKey: "CODEX_AUTH_JSON",
877+
value: rotated,
878+
});
879+
await onLog(
880+
"stdout",
881+
"[paperclip] Codex plan credential refreshed during this run; stored the new one.\n",
882+
);
883+
}
884+
} catch (err) {
885+
// A failed copy-back must never fail a run that already
886+
// did the user's work. The cost is a stale stored
887+
// credential, which surfaces as a normal auth error on a
888+
// later run, not as a lost result here.
889+
await onLog(
890+
"stdout",
891+
`[paperclip] Codex plan credential copy-back skipped: ${
892+
err instanceof Error ? err.message : String(err)
893+
}\n`,
894+
);
895+
}
896+
return;
897+
}
898+
// The copy-back exists to persist refreshed ChatGPT-subscription
899+
// tokens back to the credential the managed homes symlink to.
900+
// When no shared host credential store exists (cloud/multi-tenant
901+
// servers whose auth lives only in managed per-company homes, or
902+
// a host that never ran `codex login`) there is nothing to merge
903+
// into, and creating a shared `~/.codex` on a multi-tenant server
904+
// would leak one tenant's credential into the store every other
905+
// tenant's managed home is seeded from. Skip instead of letting
906+
// the ENOENT fail the teardown and mark a completed run failed.
907+
if (!sharedHostHasUsableAuth) {
908+
await onLog(
909+
"stdout",
910+
"[paperclip] Codex auth copy-back skipped: no shared host credential store.\n",
911+
);
912+
return;
913+
}
863914
void (await copyBackCodexAuth({
864915
readSandboxAuth: () => readFile(path.posix.join(assetDir, "auth.json")),
865-
hostAuthPath: path.join(resolveSharedCodexHomeDir(process.env), "auth.json"),
916+
hostAuthPath: path.join(sharedHostCodexHome, "auth.json"),
866917
log: (line) => onLog("stdout", `${line}\n`),
867-
// Additive cache write (sandbox to host): also cache the
868-
// sandbox subscription credential in its per-identity slot,
869-
// keyed by the real `account_id`. Company-scoped root; the
870-
// helper ensures the slot directory private and containment-
871-
// guarded. The off-switch (default on) is read inside.
872-
resolveCacheEntryPath: (accountId) =>
873-
ensureCodexAuthCacheEntryDir(process.env, accountId, agent.companyId),
874-
env: process.env,
875-
})),
876-
// No `exclude` denylist: `stagedCodexHomeDir` already contains
877-
// ONLY the allowlisted files (auth/config/skills), so there is
878-
// nothing to filter out.
918+
}));
919+
},
879920
},
880921
],
881922
});

server/src/middleware/cloud-tenant-actor.test.ts

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,21 @@ import { cloudActorHeaderSourceFromHeaders, resolveCloudTenantActor } from "./au
1313
// the user's own membership rows (rows configurable via membershipQueryRows,
1414
// where-conditions captured in selectWheres). The chain is awaitable so
1515
// directly-awaited statements resolve.
16-
function createFakeDb(options: {
17-
membershipRow?: { companyId: string; membershipRole: string; status: string };
16+
type SeededMembership = { companyId: string; membershipRole: string; status: string };
17+
18+
function createFakeDb(options?: {
19+
membershipRow?: SeededMembership;
1820
membershipQueryRows?: Array<{ companyId: string; membershipRole: string | null; status: string }>;
21+
seededMemberships?: SeededMembership[];
22+
/** Rows returned by the SELECT over `companies` — [] means the stack company does not exist yet. */
23+
companyRows?: Array<{ id: string }>;
1924
settingsRow?: Record<string, unknown> | null;
2025
selectThrows?: boolean;
21-
} = {}) {
22-
const membershipRow =
23-
options.membershipRow ?? { companyId: "company-x", membershipRole: "owner", status: "active" };
26+
}) {
27+
const membershipRow: SeededMembership =
28+
options?.membershipRow ?? { companyId: "company-x", membershipRole: "owner", status: "active" };
2429
const settingsRow =
25-
options.settingsRow === undefined
30+
options?.settingsRow === undefined
2631
? {
2732
id: "00000000-0000-0000-0000-000000000001",
2833
singletonKey: "default",
@@ -32,12 +37,20 @@ function createFakeDb(options: {
3237
createdAt: new Date(),
3338
updatedAt: new Date(),
3439
}
35-
: options.settingsRow;
40+
: options?.settingsRow;
3641
const insertedTables: unknown[] = [];
3742
const deletedTables: unknown[] = [];
43+
const selectedTables: unknown[] = [];
3844
const selectWheres: Array<{ table: unknown; condition: unknown }> = [];
45+
const insertedValues = new Map<unknown, Record<string, unknown>>();
46+
let currentTable: unknown = null;
47+
const memberships = options?.seededMemberships ?? [membershipRow];
48+
const companyRows = options?.companyRows ?? [];
3949
const chain: Record<string, unknown> = {};
40-
chain.values = () => chain;
50+
chain.values = (values: Record<string, unknown>) => {
51+
if (currentTable !== null) insertedValues.set(currentTable, values);
52+
return chain;
53+
};
4154
chain.onConflictDoUpdate = () => chain;
4255
chain.onConflictDoNothing = () => chain;
4356
chain.where = () => chain;
@@ -46,33 +59,40 @@ function createFakeDb(options: {
4659
const db = {
4760
insert: (table: unknown) => {
4861
insertedTables.push(table);
62+
currentTable = table;
4963
return chain;
5064
},
5165
delete: (table: unknown) => {
5266
deletedTables.push(table);
53-
return chain;
67+
return { where: async () => undefined };
5468
},
5569
select: () => {
56-
if (options.selectThrows) throw new Error("select unavailable");
70+
if (options?.selectThrows) throw new Error("select unavailable");
5771
return {
58-
from: (table: unknown) => ({
59-
where: (condition: unknown) => {
60-
selectWheres.push({ table, condition });
61-
const rows =
62-
table === instanceSettings && settingsRow
63-
? [settingsRow]
64-
: table === companyMemberships
65-
? (options.membershipQueryRows ?? [])
66-
: [];
67-
return {
68-
then: (resolve: (v: unknown) => unknown) => Promise.resolve(rows).then(resolve),
69-
};
70-
},
71-
}),
72+
from: (table: unknown) => {
73+
selectedTables.push(table);
74+
return {
75+
where: (condition?: unknown) => {
76+
if (condition !== undefined) selectWheres.push({ table, condition });
77+
return {
78+
then: (resolve: (v: unknown) => unknown) => {
79+
const result = table === companies
80+
? companyRows
81+
: table === instanceSettings && settingsRow
82+
? [settingsRow]
83+
: table === companyMemberships
84+
? (options?.membershipQueryRows ?? memberships)
85+
: [];
86+
return Promise.resolve(result).then(resolve);
87+
},
88+
};
89+
},
90+
};
91+
},
7292
};
7393
},
7494
} as unknown as Db;
75-
return { db, insertedTables, deletedTables, selectWheres };
95+
return { db, insertedTables, deletedTables, selectedTables, selectWheres, insertedValues };
7696
}
7797

7898
function settingsRowWith(experimental: Record<string, unknown>) {

server/src/routes/agents.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -385,11 +385,6 @@ export function agentRoutes(
385385
async markState(identity, state): Promise<void> {
386386
const row = setupTokenCleanupRows.get(identity.sessionId);
387387
if (row && scopeMatchesRow(row, identity)) row.state = state;
388-
// Pin the Test lease to the agent's own adapter so the sandbox boots the
389-
// harness image the Test will exec against (matching real agent runs). It
390-
// also keeps the lease from being an adapter-less one, which a plugin that
391-
// cannot prove a single-adapter environment now rejects.
392-
adapterType: input.adapterType,
393388
},
394389
async remove(identity): Promise<void> {
395390
// The delete matches the full owner scope, so it never removes a row by the
@@ -822,6 +817,11 @@ export function agentRoutes(
822817
// change between the guard check and the lease acquire. This closes
823818
// that check-to-lease race so a foreign sandbox never gets a lease.
824819
assertCompanyBinding: true,
820+
// Pin the Test lease to the agent's own adapter so the sandbox boots the
821+
// harness image the Test will exec against (matching real agent runs). It
822+
// also keeps the lease from being an adapter-less one, which a plugin that
823+
// cannot prove a single-adapter environment now rejects.
824+
adapterType: input.adapterType,
825825
// Apply the active custom-image template so the Test boots with the
826826
// operator's captured sandbox customizations and prepared image state,
827827
// matching what real agent runs use. Without this the test would

server/src/services/heartbeat.ts

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -15008,39 +15008,6 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
1500815008
"cloud instance) before running agents; refusing to fall back to local execution.",
1500915009
);
1501015010
}
15011-
// Persist a credential the runtime rotated mid-run. Codex is the live
15012-
// case: a ChatGPT-plan auth.json carries a single-use refresh token
15013-
// that the CLI rotates whenever the access token expires, and the
15014-
// rotated copy dies with the sandbox. Without this the stored
15015-
// credential is invalid from the next run onward, so the plan route
15016-
// would work for about an hour and then break for good.
15017-
onCredentialRotated: async ({ envKey, value }) => {
15018-
try {
15019-
const bindings = await secretsSvc.listBindings(agent.companyId);
15020-
const binding = bindings.find(
15021-
(candidate) =>
15022-
candidate.targetType === "agent" &&
15023-
candidate.targetId === agent.id &&
15024-
candidate.configPath === `env.${envKey}`,
15025-
);
15026-
if (!binding) return;
15027-
// Attributed to the agent, not a user: nobody typed this value,
15028-
// the runtime produced it. Keeps the secret's audit trail honest
15029-
// about who wrote each version.
15030-
await secretsSvc.rotate(binding.secretId, { value }, { agentId: agent.id });
15031-
} catch (err) {
15032-
// Never fail a run that already did the user's work over a
15033-
// bookkeeping write. A missed rotation surfaces later as an
15034-
// ordinary auth error, which is recoverable; a failed run is not.
15035-
// No value or fragment of it is ever logged.
15036-
await onLog(
15037-
"stdout",
15038-
`[paperclip] Could not store the refreshed ${envKey} credential: ${
15039-
err instanceof Error ? err.message : String(err)
15040-
}\n`,
15041-
);
15042-
}
15043-
},
1504415011
if (kubernetesEnvironment.id !== selectedEnvironmentId) {
1504515012
logger.info(
1504615013
{
@@ -16780,6 +16747,39 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
1678016747
onRuntimeProgress: async (progress) => {
1678116748
await recordCurrentHeartbeatRunRuntimeProgress(run, progress, issueId);
1678216749
},
16750+
// Persist a credential the runtime rotated mid-run. Codex is the live
16751+
// case: a ChatGPT-plan auth.json carries a single-use refresh token
16752+
// that the CLI rotates whenever the access token expires, and the
16753+
// rotated copy dies with the sandbox. Without this the stored
16754+
// credential is invalid from the next run onward, so the plan route
16755+
// would work for about an hour and then break for good.
16756+
onCredentialRotated: async ({ envKey, value }) => {
16757+
try {
16758+
const bindings = await secretsSvc.listBindings(agent.companyId);
16759+
const binding = bindings.find(
16760+
(candidate) =>
16761+
candidate.targetType === "agent" &&
16762+
candidate.targetId === agent.id &&
16763+
candidate.configPath === `env.${envKey}`,
16764+
);
16765+
if (!binding) return;
16766+
// Attributed to the agent, not a user: nobody typed this value,
16767+
// the runtime produced it. Keeps the secret's audit trail honest
16768+
// about who wrote each version.
16769+
await secretsSvc.rotate(binding.secretId, { value }, { agentId: agent.id });
16770+
} catch (err) {
16771+
// Never fail a run that already did the user's work over a
16772+
// bookkeeping write. A missed rotation surfaces later as an
16773+
// ordinary auth error, which is recoverable; a failed run is not.
16774+
// No value or fragment of it is ever logged.
16775+
await onLog(
16776+
"stdout",
16777+
`[paperclip] Could not store the refreshed ${envKey} credential: ${
16778+
err instanceof Error ? err.message : String(err)
16779+
}\n`,
16780+
);
16781+
}
16782+
},
1678316783
onSpawn,
1678416784
authToken: authToken ?? undefined,
1678516785
});

server/src/services/plugin-worker-manager.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,6 @@ import {
4747
import type {
4848
JsonRpcId,
4949
PluginInvocationContext,
50-
import {
51-
createPluginStreamBus,
52-
type PluginStreamBus,
53-
type StreamEventType,
54-
} from "./plugin-stream-bus.js";
5550
PluginInvocationScope,
5651
JsonRpcResponse,
5752
JsonRpcRequest,
@@ -63,6 +58,11 @@ import {
6358
WorkerToHostMethods,
6459
InitializeParams,
6560
} from "@paperclipai/plugin-sdk";
61+
import {
62+
createPluginStreamBus,
63+
type PluginStreamBus,
64+
type StreamEventType,
65+
} from "./plugin-stream-bus.js";
6666
import { getActiveStepContext } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
6767
import {
6868
DUPLEX_CHANNEL_AGGREGATE_BYTES_EXCEEDED,

0 commit comments

Comments
 (0)