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
4 changes: 3 additions & 1 deletion apps/server/scripts/evaluate-thread-titles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";
import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";
import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";
import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";
import * as ServerSettings from "../src/serverSettings.ts";
import * as VcsProcess from "../src/vcs/VcsProcess.ts";
import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts";
import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts";
Expand Down Expand Up @@ -155,7 +156,8 @@ await Effect.runPromise(
GitLabCli.layer,
ForgejoCli.layer,
AzureDevOpsCli.layer,
BitbucketApi.layer,
// No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.
BitbucketApi.layer.pipe(Layer.provide(ServerSettings.layerTest())),
),
),
Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))),
Expand Down
136 changes: 136 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ const makeServerSettingsLayer = () =>
),
);

/** Like `makeServerSettingsLayer`, but also exposes the secret store for assertions. */
const makeServerSettingsLayerWithSecrets = () =>
ServerSettingsModule.layer.pipe(
Layer.provideMerge(ServerSecretStore.layer),
Layer.provideMerge(Layer.fresh(SqlitePersistenceMemory)),
Layer.provideMerge(
Layer.fresh(
ServerConfig.layerTest(process.cwd(), {
prefix: "t3code-server-settings-test-",
}),
),
),
);

const makeFailingSecretStoreLayer = (cause: ServerSecretStore.SecretStoreError) =>
Layer.succeed(
ServerSecretStore.ServerSecretStore,
Expand Down Expand Up @@ -1278,6 +1292,128 @@ it.layer(NodeServices.layer)("server settings", (it) => {
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect(
"keeps Bitbucket tokens in the secret store and tells clients only that one is set",
() =>
Effect.gen(function* () {
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
const secrets = yield* ServerSecretStore.ServerSecretStore;
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;

const saved = yield* serverSettings.updateSettings({
bitbucket: { email: "me@example.com", accessToken: "bb-access", apiToken: "bb-api" },
});
assert.deepEqual(saved.bitbucket, {
email: "me@example.com",
accessToken: "bb-access",
apiToken: "bb-api",
});

const raw = yield* fileSystem.readFileString(serverConfig.settingsPath);
assert.notInclude(raw, "bb-access");
assert.notInclude(raw, "bb-api");
assert.include(raw, "me@example.com");

const forClient = ServerSettingsModule.redactServerSettingsForClient(saved).bitbucket;
assert.equal(forClient.email, "me@example.com");
assert.notInclude(forClient.accessToken, "bb-access");
assert.notInclude(forClient.apiToken, "bb-api");
assert.isAbove(forClient.accessToken.length, 0);
assert.isAbove(forClient.apiToken.length, 0);

// A client echoing the redacted values back, or omitting them, keeps the saved tokens.
yield* serverSettings.updateSettings({ bitbucket: forClient });
yield* serverSettings.updateSettings({ bitbucket: { email: "other@example.com" } });
assert.deepEqual((yield* serverSettings.getSettings).bitbucket, {
email: "other@example.com",
accessToken: "bb-access",
apiToken: "bb-api",
});

const cleared = yield* serverSettings.updateSettings({ bitbucket: { accessToken: "" } });
assert.equal(cleared.bitbucket.accessToken, "");
assert.equal(cleared.bitbucket.apiToken, "bb-api");
assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token")));
assert.equal(
ServerSettingsModule.redactServerSettingsForClient(cleared).bitbucket.accessToken,
"",
);
}).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())),
);

it.effect("removes a Bitbucket secret once its token is cleared by hand in settings.json", () =>
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const secrets = yield* ServerSecretStore.ServerSecretStore;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
// A token was saved, then the user deleted it from settings.json directly.
yield* secrets.set("bitbucket-access-token", new TextEncoder().encode("stale-token"));
yield* fileSystem.writeFileString(serverConfig.settingsPath, "{}");

yield* serverSettings.updateSettings({ cursorKeychainUsageEnabled: true });

assert.isTrue(Option.isNone(yield* secrets.get("bitbucket-access-token")));
}).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())),
);

it.effect("moves a hand-edited Bitbucket token into the secret store when settings load", () =>
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const secrets = yield* ServerSecretStore.ServerSecretStore;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
yield* fileSystem.writeFileString(
serverConfig.settingsPath,
'{"bitbucket":{"accessToken":"hand-edited-token"}}',
);

// Loading alone moves it: no settings update is needed.
const loaded = yield* serverSettings.getSettings;

assert.equal(loaded.bitbucket.accessToken, "hand-edited-token");
assert.notInclude(
yield* fileSystem.readFileString(serverConfig.settingsPath),
"hand-edited-token",
);
const stored = yield* secrets.get("bitbucket-access-token");
assert.equal(
Option.isSome(stored) ? new TextDecoder().decode(stored.value) : null,
"hand-edited-token",
);
}).pipe(Effect.provide(makeServerSettingsLayerWithSecrets())),
);

it.effect(
"moves a hand-edited Bitbucket token into the secret store when a client echoes the marker",
() =>
Effect.gen(function* () {
const serverConfig = yield* ServerConfig.ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
yield* fileSystem.writeFileString(
serverConfig.settingsPath,
'{"bitbucket":{"email":"me@example.com","apiToken":"hand-edited-token"}}',
);

// The form resends the redacted token when only the email changes.
const forClient = ServerSettingsModule.redactServerSettingsForClient(
yield* serverSettings.getSettings,
).bitbucket;
const updated = yield* serverSettings.updateSettings({
bitbucket: { email: "new@example.com", apiToken: forClient.apiToken },
});

assert.equal(updated.bitbucket.apiToken, "hand-edited-token");
assert.equal((yield* serverSettings.getSettings).bitbucket.apiToken, "hand-edited-token");
assert.notInclude(
yield* fileSystem.readFileString(serverConfig.settingsPath),
"hand-edited-token",
);
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("materializes provider secrets for terminal environment resolution", () =>
Effect.gen(function* () {
const serverSettings = yield* ServerSettingsModule.ServerSettingsService;
Expand Down
101 changes: 89 additions & 12 deletions apps/server/src/serverSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,16 +140,24 @@ function providerEnvironmentSecretName(input: {
}

/**
* On disk the hub key is replaced by this marker and the real value lives in
* the secret store, mirroring provider environment secrets. A client that
* sends the marker back means "keep what you have".
* On disk a hub key or Bitbucket token is replaced by this marker and the
* real value lives in the secret store, mirroring provider environment
* secrets. A client that sends the marker back means "keep what you have".
*/
const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022";
const SECRET_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022";

function usageLimitSourceSecretName(sourceId: string): string {
return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`;
}

const BITBUCKET_SECRET_NAMES = {
accessToken: "bitbucket-access-token",
apiToken: "bitbucket-api-token",
} as const;
const BITBUCKET_SECRET_FIELDS = ["accessToken", "apiToken"] as const;

const redactSecret = (value: string) => (value.length > 0 ? SECRET_REDACTED : "");

function redactProviderEnvironmentVariable(
variable: ProviderInstanceEnvironmentVariable,
): ProviderInstanceEnvironmentVariable {
Expand Down Expand Up @@ -182,11 +190,16 @@ export function redactServerSettingsForClient(settings: ServerSettings): ServerS
id,
{
...source,
managementKey: source.managementKey.length > 0 ? USAGE_LIMIT_SOURCE_KEY_REDACTED : "",
managementKey: redactSecret(source.managementKey),
},
]),
);
return { ...settings, providerInstances, usageLimitSources };
const bitbucket = {
...settings.bitbucket,
accessToken: redactSecret(settings.bitbucket.accessToken),
apiToken: redactSecret(settings.bitbucket.apiToken),
};
return { ...settings, providerInstances, usageLimitSources, bitbucket };
}

export class ServerSettingsService extends Context.Service<
Expand Down Expand Up @@ -555,6 +568,35 @@ const make = Effect.gen(function* () {
),
);

/**
* Moves Bitbucket tokens hand-edited into settings.json into the secret store as they load,
* so plaintext does not stay on disk. If the store is unavailable, the token keeps working
* from the file and the move is retried on the next load.
*/
const moveInlineBitbucketTokens = (settings: ServerSettings) =>
Effect.gen(function* () {
const bitbucket = { ...settings.bitbucket };
let moved = false;
for (const field of BITBUCKET_SECRET_FIELDS) {
const value = bitbucket[field];
if (value.length === 0 || value === SECRET_REDACTED) continue;
const stored = yield* secretStore
.set(BITBUCKET_SECRET_NAMES[field], textEncoder.encode(value))
.pipe(
Effect.as(true),
Effect.catch(() =>
Effect.logWarning("failed to move a Bitbucket token into the secret store", {
field,
}).pipe(Effect.as(false)),
),
);
if (!stored) continue;
bitbucket[field] = SECRET_REDACTED;
moved = true;
}
return moved ? { ...settings, bitbucket } : settings;
});

const loadSettingsFromDisk = Effect.gen(function* () {
let settings = DEFAULT_SERVER_SETTINGS;
let persisted: typeof PersistedOptionalProviderSettings.Type = {};
Expand Down Expand Up @@ -639,10 +681,12 @@ const make = Effect.gen(function* () {
const folded = settingsFileTrusted
? foldLegacyProjectSettings(loaded, legacyProjectRows)
: loaded;
if (folded !== loaded) {
yield* writeSettingsAtomically(folded);
// Only rewrite a file that decoded cleanly; an untrusted one stays for the user to repair.
const migrated = settingsFileTrusted ? yield* moveInlineBitbucketTokens(folded) : folded;
if (migrated !== loaded) {
yield* writeSettingsAtomically(migrated);
}
return folded;
return migrated;
});

const settingsCache = yield* Cache.make<typeof cacheKey, ServerSettings, ServerSettingsError>({
Expand Down Expand Up @@ -693,7 +737,7 @@ const make = Effect.gen(function* () {
}
const usageLimitSources: Record<string, UsageLimitSourceConfig> = {};
for (const [sourceId, source] of Object.entries(settings.usageLimitSources)) {
if (source.managementKey !== USAGE_LIMIT_SOURCE_KEY_REDACTED) {
if (source.managementKey !== SECRET_REDACTED) {
usageLimitSources[sourceId] = source;
continue;
}
Expand All @@ -709,10 +753,23 @@ const make = Effect.gen(function* () {
managementKey: Option.isSome(secret) ? textDecoder.decode(secret.value) : "",
};
}
const bitbucket = { ...settings.bitbucket };
for (const field of BITBUCKET_SECRET_FIELDS) {
if (bitbucket[field] !== SECRET_REDACTED) continue;
const secret = yield* secretStore
.get(BITBUCKET_SECRET_NAMES[field])
.pipe(
Effect.mapError(
(cause) => new ServerSettingsError({ settingsPath, operation: "read-secret", cause }),
),
);
bitbucket[field] = Option.isSome(secret) ? textDecoder.decode(secret.value) : "";
}
return {
...settings,
providerInstances: providerInstances as ServerSettings["providerInstances"],
usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
bitbucket,
};
});

Expand Down Expand Up @@ -829,7 +886,7 @@ const make = Effect.gen(function* () {
const usageLimitSources: Record<string, UsageLimitSourceConfig> = {};
for (const [sourceId, source] of Object.entries(next.usageLimitSources)) {
const secretName = usageLimitSourceSecretName(sourceId);
if (source.managementKey === USAGE_LIMIT_SOURCE_KEY_REDACTED) {
if (source.managementKey === SECRET_REDACTED) {
usageLimitSources[sourceId] = source;
continue;
}
Expand All @@ -843,7 +900,7 @@ const make = Effect.gen(function* () {
secretName,
value: textEncoder.encode(source.managementKey),
});
usageLimitSources[sourceId] = { ...source, managementKey: USAGE_LIMIT_SOURCE_KEY_REDACTED };
usageLimitSources[sourceId] = { ...source, managementKey: SECRET_REDACTED };
}
for (const sourceId of Object.keys(current.usageLimitSources)) {
if (sourceId in next.usageLimitSources) continue;
Expand All @@ -854,11 +911,31 @@ const make = Effect.gen(function* () {
});
}

const bitbucket = { ...next.bitbucket };
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
for (const field of BITBUCKET_SECRET_FIELDS) {
let value = bitbucket[field];
if (value === SECRET_REDACTED) {
// The marker keeps what is saved. A plaintext value hand-edited into settings.json
// is not in the secret store yet, so move it there instead of dropping it.
const inline = current.bitbucket[field];
if (inline === SECRET_REDACTED || inline.length === 0) continue;
value = inline;
}
const secretName = BITBUCKET_SECRET_NAMES[field];
if (value.length === 0) {
changes.push({ kind: "remove", secretName, operation: "remove-secret" });
continue;
}
changes.push({ kind: "write", secretName, value: textEncoder.encode(value) });
bitbucket[field] = SECRET_REDACTED;
}

return {
settings: {
...next,
providerInstances: providerInstances as ServerSettings["providerInstances"],
usageLimitSources: usageLimitSources as ServerSettings["usageLimitSources"],
bitbucket,
},
changes,
};
Expand Down
Loading
Loading