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
29 changes: 29 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,35 @@ describe("ClaudeAdapterV2 runtime query policy", () => {
assert.include(options.settings, { showThinkingSummaries: true });
});

it("adds the settings env without dropping other flag settings", () => {
const settingsEnv = { OTEL_RESOURCE_ATTRIBUTES: "org.name=Acme,t3code.launched=true" };
const options = ClaudeAdapterV2.makeClaudeQueryOptions({
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
nativeThreadId: "settings-env-thread",
resume: false,
cwd: "/workspace",
sdkSettings: { env: { KEEP: "1" }, model: "kept" },
settingsEnv,
});
assert.deepEqual(options.settings, {
model: "kept",
env: { KEEP: "1", ...settingsEnv },
showThinkingSummaries: true,
});
});

it("leaves a settings string untouched", () => {
const options = ClaudeAdapterV2.makeClaudeQueryOptions({
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
nativeThreadId: "settings-env-thread",
resume: false,
cwd: "/workspace",
sdkSettings: "/etc/claude.json",
settingsEnv: { OTEL_RESOURCE_ATTRIBUTES: "t3code.launched=true" },
});
assert.equal(options.settings, "/etc/claude.json");
});

it("preserves an explicit omitted thinking display", () => {
const options = ClaudeAdapterV2.makeClaudeQueryOptions({
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
Expand Down
20 changes: 19 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ import * as ServerConfig from "../../config.ts";
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup";
import {
claudeSettingsLaunchedEnv,
claudeSignedOutMessage,
makeClaudeEnvironment,
} from "../../provider/Drivers/ClaudeHome.ts";
Expand Down Expand Up @@ -1104,6 +1105,7 @@ export function makeClaudeQueryOptions(input: {
readonly attachmentsDir?: string;
readonly settings?: ClaudeSettings;
readonly sdkSettings?: string | ClaudeSdkSettings;
readonly settingsEnv?: Readonly<Record<string, string>>;
readonly environment?: NodeJS.ProcessEnv;
readonly mcpServers?: ClaudeQueryOptions["mcpServers"];
readonly tools?: ClaudeAgentSdkQueryTools;
Expand Down Expand Up @@ -1141,13 +1143,21 @@ export function makeClaudeQueryOptions(input: {
: typeof input.sdkSettings === "object" && input.sdkSettings !== null
? ({ ...input.sdkSettings, ...selectionSettings } as ClaudeSdkSettings)
: selectionSettings;
const effectiveQuerySettings =
const compactedQuerySettings =
input.settings?.autoCompactWindow === undefined || input.settings.autoCompactWindow.length === 0
? querySettings
: ({
...(typeof querySettings === "object" && querySettings !== null ? querySettings : {}),
autoCompactWindow: Number(input.settings.autoCompactWindow),
} as ClaudeSdkSettings);
// A settings string is a path or JSON the caller owns, so it is left alone.
const effectiveQuerySettings =
input.settingsEnv === undefined || typeof compactedQuerySettings === "string"
? compactedQuerySettings
: ({
...compactedQuerySettings,
env: { ...compactedQuerySettings?.env, ...input.settingsEnv },
} as ClaudeSdkSettings);
const options: ClaudeAgentSdkQueryOptions = {
model: compiledSelection.apiModelId,
tools: claudeAgentSdkQueryToolsForSdk(selectedTools),
Expand Down Expand Up @@ -8613,6 +8623,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1;
const shouldResume =
resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn;
const settingsEnv = yield* claudeSettingsLaunchedEnv(
turnInput.runtimePolicy.cwd,
adapterOptions.environment,
).pipe(
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideService(Path.Path, path),
);
const queryOptions = makeClaudeQueryOptions({
modelSelection: turnInput.modelSelection,
nativeThreadId,
Expand All @@ -8622,6 +8639,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
attachmentsDir,
settings: adapterOptions.settings,
environment: adapterOptions.environment,
...(settingsEnv === undefined ? {} : { settingsEnv }),
tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS,
...(mcpOverrides.allowedTools === undefined
? {}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import * as NodeOS from "node:os";

import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
import * as Random from "effect/Random";
import { TestClock } from "effect/testing";

const DETERMINISTIC_HOME = `${NodeOS.tmpdir()}/t3-deterministic-home`;

export function provideDeterministicTestRuntime<A, E, R>(
effect: Effect.Effect<A, E, R>,
options: { readonly randomSeed?: number } = {},
) {
return effect.pipe(
Effect.provide(TestClock.layer()),
Random.withSeed(options.randomSeed ?? 0x1234_5678),
Effect.provideService(HostProcess.HomeDirectory, DETERMINISTIC_HOME),
);
}
122 changes: 122 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeHome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import * as NodeOS from "node:os";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";

import * as HostProcess from "@t3tools/shared/HostProcess";

import {
claudeSettingsLaunchedEnv,
claudeSignedOutMessage,
makeClaudeCapabilitiesCacheKey,
makeClaudeContinuationGroupKey,
Expand Down Expand Up @@ -85,4 +89,122 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => {
}),
);
});

describe("settings launched env", () => {
const LAUNCHED = "t3code.launched=true";
const setup = (files: {
readonly user?: string;
readonly project?: string;
readonly local?: string;
}) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped();
const configDir = path.join(root, "config");
const cwd = path.join(root, "workspace");
yield* fileSystem.makeDirectory(configDir, { recursive: true });
yield* fileSystem.makeDirectory(path.join(cwd, ".claude"), { recursive: true });
if (files.user !== undefined) {
yield* fileSystem.writeFileString(path.join(configDir, "settings.json"), files.user);
}
if (files.project !== undefined) {
yield* fileSystem.writeFileString(
path.join(cwd, ".claude", "settings.json"),
files.project,
);
}
if (files.local !== undefined) {
yield* fileSystem.writeFileString(
path.join(cwd, ".claude", "settings.local.json"),
files.local,
);
}
return { cwd, environment: { CLAUDE_CONFIG_DIR: configDir } };
});
const settingsWith = (attributes: string) =>
JSON.stringify({ env: { OTEL_RESOURCE_ATTRIBUTES: attributes } });

it.effect("extends the value of the highest precedence settings file", () =>
Effect.gen(function* () {
const { cwd, environment } = yield* setup({
user: settingsWith("from=user"),
project: settingsWith("from=project"),
local: settingsWith("from=local"),
});
expect(yield* claudeSettingsLaunchedEnv(cwd, environment)).toEqual({
OTEL_RESOURCE_ATTRIBUTES: `from=local,${LAUNCHED}`,
});
const withoutLocal = yield* setup({
user: settingsWith("from=user"),
project: settingsWith("from=project"),
});
expect(
yield* claudeSettingsLaunchedEnv(withoutLocal.cwd, withoutLocal.environment),
).toEqual({ OTEL_RESOURCE_ATTRIBUTES: `from=project,${LAUNCHED}` });
}).pipe(Effect.scoped, Effect.provideService(HostProcess.HomeDirectory, "/home/ada")),
);

it.effect("reads the user file when there is no workspace", () =>
Effect.gen(function* () {
const { environment } = yield* setup({ user: settingsWith("org.name=Acme") });
expect(yield* claudeSettingsLaunchedEnv(null, environment)).toEqual({
OTEL_RESOURCE_ATTRIBUTES: `org.name=Acme,${LAUNCHED}`,
});
}).pipe(Effect.scoped, Effect.provideService(HostProcess.HomeDirectory, "/home/ada")),
);

it.effect("changes nothing when no settings file overrides the variable", () =>
Effect.gen(function* () {
const none = yield* setup({});
expect(yield* claudeSettingsLaunchedEnv(none.cwd, none.environment)).toBeUndefined();
const unrelated = yield* setup({ user: JSON.stringify({ env: { OTHER: "1" } }) });
expect(
yield* claudeSettingsLaunchedEnv(unrelated.cwd, unrelated.environment),
).toBeUndefined();
}).pipe(Effect.scoped, Effect.provideService(HostProcess.HomeDirectory, "/home/ada")),
);

it.effect("resolves a relative config dir against the workspace", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const { cwd } = yield* setup({});
yield* fileSystem.makeDirectory(path.join(cwd, "claude-config"), { recursive: true });
yield* fileSystem.writeFileString(
path.join(cwd, "claude-config", "settings.json"),
settingsWith("org.name=Acme"),
);
expect(
yield* claudeSettingsLaunchedEnv(cwd, { CLAUDE_CONFIG_DIR: "claude-config" }),
).toEqual({ OTEL_RESOURCE_ATTRIBUTES: `org.name=Acme,${LAUNCHED}` });
}).pipe(Effect.scoped, Effect.provideService(HostProcess.HomeDirectory, "/home/ada")),
);

it.effect("skips an unreadable file and keeps looking", () =>
Effect.gen(function* () {
const { cwd, environment } = yield* setup({
user: settingsWith("from=user"),
local: "{ not json",
});
expect(yield* claudeSettingsLaunchedEnv(cwd, environment)).toEqual({
OTEL_RESOURCE_ATTRIBUTES: `from=user,${LAUNCHED}`,
});
}).pipe(Effect.scoped, Effect.provideService(HostProcess.HomeDirectory, "/home/ada")),
);

it.effect("respects a launched value the user chose", () =>
Effect.gen(function* () {
const inFile = yield* setup({ user: settingsWith("t3code.launched=false") });
expect(yield* claudeSettingsLaunchedEnv(inFile.cwd, inFile.environment)).toBeUndefined();
const optedOut = yield* setup({ user: settingsWith("from=user") });
expect(
yield* claudeSettingsLaunchedEnv(optedOut.cwd, {
...optedOut.environment,
OTEL_RESOURCE_ATTRIBUTES: "t3code.launched=false",
}),
).toBeUndefined();
}).pipe(Effect.scoped, Effect.provideService(HostProcess.HomeDirectory, "/home/ada")),
);
});
});
69 changes: 69 additions & 0 deletions apps/server/src/provider/Drivers/ClaudeHome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@ import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

import {
RESOURCE_ATTRIBUTES,
userLaunchedAttribute,
withLaunchedAttribute,
} from "@t3tools/provider-core/server/instanceEnvironment";
import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { fromLenientJson } from "@t3tools/shared/schemaJson";

const quotePath = Schema.encodeSync(Schema.fromJsonString(Schema.String));

Expand All @@ -27,6 +33,69 @@ export const resolveClaudeHomePath = Effect.fn("resolveClaudeHomePath")(function
return path.resolve(path.join(home, ".claude"));
});

const decodeSettingsEnv = Schema.decodeUnknownEffect(
fromLenientJson(
Schema.Struct({ env: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)) }),
),
);

/**
* Claude Code copies the `env` of each settings file over its own environment
* at startup, so a user's `OTEL_RESOURCE_ATTRIBUTES` there replaces the one T3
* Code launched it with and drops `t3code.launched`. Flag settings outrank those
* files, so this returns the `env` to pass there: the value the files would
* leave in effect, plus the attribute. It returns nothing when no file sets the
* variable, the file value already carries the attribute, or the launch
* environment holds a value the user chose, such as `false`.
*/
export const claudeSettingsLaunchedEnv = Effect.fn("claudeSettingsLaunchedEnv")(function* (
cwd: string | null,
environment: NodeJS.ProcessEnv,
): Effect.fn.Return<
Readonly<Record<string, string>> | undefined,
never,
FileSystem.FileSystem | Path.Path
> {
const launched = userLaunchedAttribute(environment[RESOURCE_ATTRIBUTES] ?? "");
if (launched !== undefined && launched !== "true") {
return undefined;
}
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const inheritedConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? "";
const configDir =
cwd !== null && inheritedConfigDir.length > 0 && !path.isAbsolute(inheritedConfigDir)
? path.resolve(cwd, inheritedConfigDir)
: yield* resolveClaudeHomePath({ homePath: "" }, environment);
const settingsPaths = [
...(cwd === null
? []
: [
path.join(cwd, ".claude", "settings.local.json"),
path.join(cwd, ".claude", "settings.json"),
]),
path.join(configDir, "settings.json"),
Comment thread
cursor[bot] marked this conversation as resolved.
];
for (const settingsPath of settingsPaths) {
const contents = yield* fileSystem
.readFileString(settingsPath)
.pipe(Effect.orElseSucceed(() => undefined));
if (contents === undefined) {
continue;
}
const attributes = (yield* decodeSettingsEnv(contents).pipe(
Effect.orElseSucceed(() => undefined),
))?.env?.[RESOURCE_ATTRIBUTES];
if (typeof attributes !== "string") {
continue;
}
return userLaunchedAttribute(attributes) === undefined
? { [RESOURCE_ATTRIBUTES]: withLaunchedAttribute(attributes) }
: undefined;
}
return undefined;
});

export const makeClaudeEnvironment = Effect.fn("makeClaudeEnvironment")(function* (
config: Pick<ClaudeSettings, "homePath">,
baseEnv?: NodeJS.ProcessEnv,
Expand Down
5 changes: 3 additions & 2 deletions docs/fork/0031-harness-telemetry-says-t3code-launched-it.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
- Filter the metrics, traces, and logs that Claude Code, Codex, or any other
harness exports to the sessions T3 Code drove, with
`t3code.launched=true`.
- Keep your own `OTEL_RESOURCE_ATTRIBUTES`, inherited or set on a provider
instance; the attribute is added after them, never in their place.
- Keep your own `OTEL_RESOURCE_ATTRIBUTES`, inherited, set on a provider
instance, or set in your Claude settings files; the attribute is added after
them, never in their place.
- Set `t3code.launched=false` on a provider instance to leave its harness
unmarked.

Expand Down
3 changes: 3 additions & 0 deletions docs/operations/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,9 @@ Every process T3 Code launches for a provider, such as Claude Code or Codex, inh
A harness that exports its own telemetry reports it on every signal, so its sessions driven from T3 Code
can be told apart from the ones started in a terminal. A value already set for `t3code.launched`,
in the environment or a provider instance's variables, is kept, so `false` opts a harness out.
Claude Code copies the `env` of its settings files over the environment it starts with, so when
one of them sets `OTEL_RESOURCE_ATTRIBUTES` T3 Code passes the value those files would leave in
effect, plus the attribute, as flag settings, which outrank them.
Integrated terminals do not add it, though they keep one already in those variables.

If the OTLP URLs are unset, local tracing still works, metrics stay in-process only, and logs stay
Expand Down
Loading
Loading