[Bug]: Updating LAN access corrupts quoted multiline service.env values and drops the setting #35944
Send to my agentSummary#
Changing the background service's network setting can modify an unrelated quoted multiline environment value and fail to save the requested setting. The updater mistakes assignment-shaped text inside the quoted value for a real variable declaration, then removes the real declaration as a duplicate. An isolated reproduction using the production updater and launcher reader confirms both outcomes; unrelated values should retain their contents and the requested setting should remain a separate entry.
Before submitting#
- I searched existing issues and did not find a duplicate. Related history is listed under References.
- I included enough detail to reproduce or investigate the problem.
- Removed credentials, tokens, private paths, and sensitive data.
Area#
apps/server
Actual behavior#
At commit 59e64eef881632a23f89c4138bcf37f23d92bf93, calling the production upsertServiceEnv with { T3CODE_LAN: "false" } on valid dotenv text containing CUSTOM="first followed by a physical line T3CODE_LAN=example inside the quotes changes that line to T3CODE_LAN=false. It does not append a real top-level T3CODE_LAN declaration. When a real T3CODE_LAN=true declaration already follows the quoted value, it is deleted. Reading the resulting file through the actual launcher readServiceEnvironment returns the modified custom string and no T3CODE_LAN property.
The CLI reaches this updater through writeServiceEnv: t3 up writes the selected LAN value in apps/server/src/cli/up.ts#L321-L329, and new-service reconciliation writes the default in apps/server/src/cli/service.ts#L104-L110. Those entry-point connections are source evidence; the CLI and OS service manager were not run.
Steps to reproduce#
- Use a checkout of the commit listed below.
- Run the script below. It loads the production updater function directly from its source slice, strips TypeScript annotations with Node's own module API, writes its output to a disposable service.env, and imports the real launcher reader to observe the resulting environment.
- Compare a normal single-line control with four quoted multiline variants, including absent and existing top-level LAN declarations and single quotes with an export prefix.
All four multiline cases failed in one completed run. The single-line control and the escaped-newline workaround control passed. An earlier harness attempt was inconclusive because it incorrectly imported stripTypeScriptTypes from node:util and stopped with util.stripTypeScriptTypes is not a function; the completed script imports it from node:module.
Save this as repro.mjs outside the checkout and run node repro.mjs /path/to/t3code. It requires Node with TypeScript stripping support and no package installation. It creates its fixtures and disposable HOME, USERPROFILE, LOCALAPPDATA, XDG directories, TMPDIR, and T3CODE_HOME under a temporary directory in the checkout, removes that directory on exit, and never calls an OS service manager. Exit 1 means the defect reproduced, 0 means both preservation and the requested setting worked, and 2 means setup or a control failed.
Reproduction script: repro.mjs
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { stripTypeScriptTypes } from 'node:module';
import { pathToFileURL } from 'node:url';
let scratch;
let status = 2;
try {
console.log(`Node ${process.version}; ${process.platform}/${process.arch}`);
const checkout = path.resolve(process.argv[2] ?? '.');
scratch = await fs.mkdtemp(path.join(checkout, '.dotenv-repro-'));
for (const key of ['HOME', 'USERPROFILE', 'LOCALAPPDATA', 'XDG_CONFIG_HOME',
'XDG_RUNTIME_DIR', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'TMPDIR', 'T3CODE_HOME']) {
process.env[key] = path.join(scratch, key.toLowerCase());
await fs.mkdir(process.env[key], { recursive: true });
}
const source = await fs.readFile(path.join(checkout, 'apps/server/src/cli/service.ts'), 'utf8');
const start = source.indexOf('const SERVICE_ENV_KEY =');
const end = source.indexOf('/** `service.env` under', start);
if (start < 0 || end < 0) throw new Error('Cannot locate production upsertServiceEnv');
const js = stripTypeScriptTypes(source.slice(start, end));
const { upsertServiceEnv } = await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`);
const { readServiceEnvironment } = await import(pathToFileURL(path.join(checkout, 'apps/server/src/serviceLauncher.ts')));
const baseDir = process.env.T3CODE_HOME;
const readWritten = async (text) => {
await fs.writeFile(path.join(baseDir, 'service.env'), text);
return readServiceEnvironment(baseDir, {});
};
const control = '# custom settings\nCUSTOM="space # literal"\nT3CODE_LAN=true\n';
const controlEnv = await readWritten(upsertServiceEnv(control, { T3CODE_LAN: 'false' }));
if (controlEnv.CUSTOM !== 'space # literal' || controlEnv.T3CODE_LAN !== 'false') {
throw new Error('Single-line control failed');
}
console.log('CONTROL: single-line value preserved; T3CODE_LAN="false"');
const variants = [
['append missing key', 'CUSTOM="first\nT3CODE_LAN=example\nlast"\n'],
['update existing key', 'CUSTOM="first\nT3CODE_LAN=example\nlast"\nT3CODE_LAN=true\n'],
['single-quoted value', "CUSTOM='first\nexport T3CODE_LAN=example\nlast'\nT3CODE_LAN=true\n"],
['comment continuation', '# comment\nCUSTOM="first\nT3CODE_LAN=example\nlast"\nT3CODE_PORT=3773\n'],
];
let failures = 0;
for (const [label, input] of variants) {
const before = await readWritten(input);
const output = upsertServiceEnv(input, { T3CODE_LAN: 'false' });
const after = await readWritten(output);
const matches = after.CUSTOM === before.CUSTOM && after.T3CODE_LAN === 'false'
&& after.T3CODE_PORT === before.T3CODE_PORT;
console.log(`${label}: ${matches ? 'PASS' : 'FAIL'}`);
console.log(` before: ${JSON.stringify(before)}`);
console.log(` after: ${JSON.stringify(after)}`);
if (!matches) failures++;
}
// Workaround control: the same value encoded with a double-quoted \\n escape.
const escaped = 'CUSTOM="first\\nT3CODE_LAN=example\\nlast"\nT3CODE_LAN=true\n';
const workaround = await readWritten(upsertServiceEnv(escaped, { T3CODE_LAN: 'false' }));
if (workaround.CUSTOM !== 'first\nT3CODE_LAN=example\nlast' || workaround.T3CODE_LAN !== 'false') {
throw new Error('Escaped-newline workaround control failed');
}
console.log('WORKAROUND: double-quoted escaped newlines preserve CUSTOM and write T3CODE_LAN="false"');
console.log(`Failed ${failures}/${variants.length} multiline cases`);
status = failures ? 1 : 0;
} catch (error) {
console.log(`Setup/control failure: ${error.message}`);
} finally {
if (scratch) await fs.rm(scratch, { recursive: true, force: true });
}
console.log(status === 0 ? 'RESULT: expected behavior' : status === 1 ? 'RESULT: defect reproduced' : 'RESULT: inconclusive');
process.exitCode = status;
Expected behavior#
Changing T3CODE_LAN should preserve the parsed value of every other variable, even when it contains a physical line resembling an assignment. The resulting file should contain a separate top-level T3CODE_LAN=false entry that the launcher reads as the string "false".
Node explicitly permits quoted variables to span multiple lines in its dotenv variable-value grammar. T3's launcher uses util.parseEnv in apps/server/src/serviceLauncher.ts#L252-L273. The updater's own contract promises to keep other lines as written in apps/server/src/cli/service.ts#L41-L63, and the user guide documents persistent custom environment configuration in docs/user/background-service.md#L60-L77.
Impact#
Minor bug or occasional failure.
Impact: P2 - A bounded valid-input case corrupts an unrelated custom environment value and loses the requested LAN setting. A verified workaround preserves both values.
Affected input: a quoted multiline custom value with a physical line that begins with the key the CLI is updating, with optional whitespace or an export prefix. The reproduction verifies data transformation and launcher parsing on macOS; it does not establish a running server's resulting bind address or observe a full service restart. The relevant updater and reader are platform-independent source, but Linux and Windows execution were not tested.
Workaround verified in the script: encode physical newlines as \n inside a double-quoted dotenv value. Node then produces the same custom string, while the updater correctly handles the separate LAN declaration. No real credentials appear in the fixtures.
Version or commit#
Server package 0.1.9, source commit 59e64eef881632a23f89c4138bcf37f23d92bf93. This report targets that pinned commit; later source revisions were not reproduced.
Environment#
Node v26.12.1, macOS 27.2, Darwin 27.2.0, arm64. The package declares Node ^22.16 || ^23.11 || >=24.10 in apps/server/package.json#L104-L106. No third-party dependencies are needed by the reproduction; it only runs Node built-ins and the standalone launcher module.
Interface#
CLI: t3 up and new-service environment setup, reproduced at their shared upsertServiceEnv writer boundary and the actual launcher reader.
Earliest failure stage#
State or storage: rewriting service.env.
Evidence#
The completed script produced the following output; Node's experimental TypeScript-stripping warning is omitted because it is unrelated to the result.
Node v26.12.1; darwin/arm64
CONTROL: single-line value preserved; T3CODE_LAN="false"
append missing key: FAIL
before: {"CUSTOM":"first\nT3CODE_LAN=example\nlast"}
after: {"CUSTOM":"first\nT3CODE_LAN=false\nlast"}
update existing key: FAIL
before: {"CUSTOM":"first\nT3CODE_LAN=example\nlast","T3CODE_LAN":"true"}
after: {"CUSTOM":"first\nT3CODE_LAN=false\nlast"}
single-quoted value: FAIL
before: {"CUSTOM":"first\nexport T3CODE_LAN=example\nlast","T3CODE_LAN":"true"}
after: {"CUSTOM":"first\nT3CODE_LAN=false\nlast"}
comment continuation: FAIL
before: {"CUSTOM":"first\nT3CODE_LAN=example\nlast","T3CODE_PORT":"3773"}
after: {"CUSTOM":"first\nT3CODE_LAN=false\nlast","T3CODE_PORT":"3773"}
WORKAROUND: double-quoted escaped newlines preserve CUSTOM and write T3CODE_LAN="false"
Failed 4/4 multiline cases
RESULT: defect reproduced
Restoration check#
The reproduction should print RESULT: expected behavior and exit 0 after every multiline variant retains its original CUSTOM value and the launcher reads T3CODE_LAN as "false". The single-line and escaped-newline controls should continue to pass.
Analysis or suspected cause#
Source evidence: the updater splits the file on every physical newline and applies the assignment regex without tracking whether the line is inside a quoted value. It marks the embedded text as the written key, then drops the actual later declaration as a duplicate. The writer persists that text without another grammar check in apps/server/src/cli/service.ts#L76-L85.
References#
- https://github.com/pingdotgg/t3code/issues/12626 — covered custom environment entries being lost when unit or plist files were regenerated; this report concerns the later dotenv text updater misreading supported multiline values.
- https://github.com/pingdotgg/t3code/pull/12633 — proposed the persistent environment-file reader; its discussion does not address multiline text rewriting by the CLI updater.
- https://github.com/pingdotgg/t3code/pull/19486 — delivered the persistent environment-file reader; that reader correctly reads the input used here before the CLI text updater changes it.
- https://github.com/pingdotgg/t3code/pull/27996 — introduced the setup command and LAN-setting writer path; the existing preservation test covers separate single-line declarations.
tanstack-source added labelsbug
juliusmarminge closed this
Sign in to join this conversation.