Repository navigation
Description
Summary
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
- [Bug]:
t3 update/t3 service installre-render the service unit and drop every user environment variable, including the documented Bitbucket credentials #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. - fix(server): merge T3-home service.env into the background launcher #12633 — proposed the persistent environment-file reader; its discussion does not address multiline text rewriting by the CLI updater.
- fix(server): preserve service environment across updates #19486 — delivered the persistent environment-file reader; that reader correctly reads the input used here before the CLI text updater changes it.
- feat(cli): t3 up asks how your other devices reach this machine #27996 — introduced the setup command and LAN-setting writer path; the existing preservation test covers separate single-line declarations.
Activity