Skip to content
Source
Sign inSign up

[Bug]: Copied 1Password references with edge spaces are rewritten or rejected #35931

Send to my agent
Opencoygeek(GitHub profile, opens in a new tab) opened this issue 0 comments

Summary

References copied from 1Password do not preserve field selection when the field label starts or ends with a space. A trailing space is removed before validation, allowing a different field to be accepted and read; a leading-space field reference is rejected. A focused reproduction with the real reference schemas and 1Password CLI demonstrated both cases using synthetic fields served by a local Connect fixture. Copied references should continue to identify their original fields.

Before submitting

  • Related issues: searched open and closed issues and PRs; related value-whitespace work is listed under Additional context, and no exact duplicate was found.
  • Removed credentials, tokens, private paths, and sensitive data.

Area

apps/web

Observed behavior

For synthetic fields named credential, credential , and credential, op item get --format json emits distinct references. Direct op read returns the matching dummy values PLAIN, TRAILING, and LEADING. Decoding the trailing-space reference through SecretReference removes the space; the 1Password definition accepts that altered reference, and the resulting CLI read returns PLAIN instead of TRAILING. The leading-space reference remains unchanged by the schema but fails definition.isReference, so the web input would reject it.

The schema and CLI lookup behavior were reproduced. The web UI behavior is source-established: its commit handler decodes before provider validation and sends decoded.value to onSecretChange at apps/web/src/components/settings/SecretSourceInput.tsx#L279-L291. No browser or full T3 server was launched.

Reproduction

  1. Check out the commit listed below with its pinned Effect dependency installed and put 1Password CLI on PATH.
  2. Save the script below as repro-edge-spaces.mjs and run node repro-edge-spaces.mjs /path/to/checkout.
  3. Compare the references produced by the CLI, their direct lookup results, and the results after the same schema decode and provider validation used by SecretSourceInput.

The completed script run reproduced both variants deterministically once. A plain-field control and field-ID workaround controls succeeded. An earlier accountless parser probe first stopped on broad permissions in its disposable configuration directory; after setting that directory to mode 0700 it reached parsing, and the completed script creates its directory with that mode.

The script requires Node with stripTypeScriptTypes, the checkout's installed effect dependency, Git, and op. It creates a disposable HOME, configuration directory, and loopback-only HTTP fixture, then removes them on exit. It imports the actual schema and definition modules after stripping TypeScript and redirecting their workspace imports to their narrow dependencies; it does not change their validation expressions. It supplies synthetic Connect data and a dummy fixture token, requires no account or desktop approval, and leaves the checkout unchanged. Exit 1 means the defect reproduced; 0 means the copied references kept their field selection; 2 means setup or a control failed.

Reproduction script: repro-edge-spaces.mjs
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
import { join, resolve, dirname } from "node:path";
import { tmpdir } from "node:os";
import { pathToFileURL } from "node:url";
import { createRequire, stripTypeScriptTypes } from "node:module";
import { execFileSync, spawn } from "node:child_process";
import { createServer } from "node:http";
import { once } from "node:events";
import { mkdirSync } from "node:fs";

const checkout = resolve(process.argv[2] ?? ".");
let scratch;
let result = 2;
try {
  console.log(`node ${process.version}`);
  console.log(execFileSync("git", ["--version"], { encoding: "utf8" }).trim());
  console.log(`commit ${execFileSync("git", ["-C", checkout, "rev-parse", "HEAD"], { encoding: "utf8" }).trim()}`);
  const require = createRequire(join(checkout, "packages/contracts/package.json"));
  const effectManifest = require.resolve("effect/package.json");
  console.log(`effect ${JSON.parse(readFileSync(effectManifest, "utf8")).version}`);
  scratch = mkdtempSync(join(tmpdir(), "reference-repro-"));
  process.env.HOME = scratch;
  process.env.TMPDIR = scratch;
  process.env.T3CODE_HOME = scratch;
  const effectImport = (specifier) => pathToFileURL(require.resolve(specifier)).href;
  const copy = (name, relative, replacements = []) => {
    let source = readFileSync(join(checkout, relative), "utf8");
    for (const [before, after] of replacements) source = source.replace(before, after);
    source = stripTypeScriptTypes(source);
    source = source.replace(/from "(effect\/[^"\n]+)"/g, (_, specifier) => `from "${effectImport(specifier)}"`);
    const destination = join(scratch, name);
    writeFileSync(destination, source);
    return pathToFileURL(destination).href;
  };
  copy("baseSchemas.mjs", "packages/contracts/src/baseSchemas.ts");
  copy("secretReference.mjs", "packages/contracts/src/secretReference.ts", [['"./baseSchemas.ts"', '"./baseSchemas.mjs"']]);
  copy("definitionCore.mjs", "packages/secret-provider-core/src/shared/definition.ts");
  const settings = readFileSync(join(checkout, "packages/contracts/src/settings.ts"), "utf8");
  const kind = settings.match(/^export const SecretProviderKind = .*;$/m)?.[0];
  if (!kind) throw new Error("Cannot find provider-kind schema");
  writeFileSync(join(scratch, "contracts.mjs"), `import * as Schema from "${effectImport("effect/Schema")}";\nimport { SecretProviderSlug } from "./secretReference.mjs";\n${kind}\n`);
  const definitionURL = copy("definition.mjs", "packages/secret-provider-onepassword/src/shared/definition.ts", [
    ['"@t3tools/contracts"', '"./contracts.mjs"'],
    ['"@t3tools/secret-provider-core/shared/definition"', '"./definitionCore.mjs"'],
  ]);
  const Schema = await import(effectImport("effect/Schema"));
  const { SecretReference } = await import(pathToFileURL(join(scratch, "secretReference.mjs")).href);
  const { definition } = await import(definitionURL);
  const decodes = (reference) => Schema.is(SecretReference)({ provider: "op", reference });
  mkdirSync(join(scratch, "op-config"), { mode: 0o700 });
  const vault = "a".repeat(26);
  const item = "b".repeat(26);
  const fixture = {
    id: item, title: "item", vault: { id: vault }, category: "API_CREDENTIAL", version: 1,
    fields: [
      { id: "plain", type: "STRING", label: "credential", value: "PLAIN" },
      { id: "trailing", type: "STRING", label: "credential ", value: "TRAILING" },
      { id: "leading", type: "STRING", label: " credential", value: "LEADING" },
    ],
  };
  const server = createServer((request, response) => {
    const path = new URL(request.url, "http://127.0.0.1").pathname;
    let data;
    if (path === "/v1/vaults") data = [{ id: vault, name: "vault" }];
    else if (path === `/v1/vaults/${vault}`) data = { id: vault, name: "vault" };
    else if (path === `/v1/vaults/${vault}/items`) data = [fixture];
    else if (path === `/v1/vaults/${vault}/items/${item}`) data = fixture;
    else { response.writeHead(404).end(); return; }
    response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(data));
  });
  server.listen(0, "127.0.0.1");
  await once(server, "listening");
  const env = {
    PATH: process.env.PATH, HOME: scratch, TMPDIR: scratch, T3CODE_HOME: scratch,
    OP_CONFIG_DIR: join(scratch, "op-config"), OP_BIOMETRIC_UNLOCK_ENABLED: "false",
    OP_CONNECT_HOST: `http://127.0.0.1:${server.address().port}`, OP_CONNECT_TOKEN: "fixture",
  };
  const op = async (args) => {
    const child = spawn("op", args, { env, stdio: ["ignore", "pipe", "pipe"], timeout: 10000 });
    let stdout = "";
    let stderr = "";
    child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += chunk; });
    child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; });
    const [code] = await once(child, "close");
    if (code !== 0) throw new Error(`op failed with status ${code}`);
    return stdout;
  };
  let failures = 0;
  try {
    console.log(`op ${(await op(["--version"])).trim()}`);
    const fetched = JSON.parse(await op(["--cache=false", "item", "get", item, "--vault", vault, "--format", "json"]));
    const decode = Schema.decodeUnknownSync(SecretReference);
    const read = (reference) => op(["--cache=false", "read", "--account=fixture", "--no-newline", reference]);
    const plain = fetched.fields.find((field) => field.id === "plain").reference;
    if (!definition.isReference(plain) || await read(plain) !== "PLAIN") throw new Error("plain reference control failed");
    console.log("plain-reference control: PLAIN");
    for (const [id, expected] of [["trailing", "TRAILING"], ["leading", "LEADING"]]) {
      const reference = fetched.fields.find((field) => field.id === id).reference;
      const originalValue = await read(reference);
      if (originalValue !== expected) throw new Error("direct reference control failed");
      const decoded = decode({ provider: "op", reference }).reference;
      const accepted = definition.isReference(decoded);
      const actual = accepted ? await read(decoded) : "REJECTED";
      console.log(`${id} copied reference: ${JSON.stringify(reference)}`);
      console.log(`${id}: direct=${originalValue}, normalized=${decoded !== reference}, accepted=${accepted}, after-input=${actual}`);
      if (!accepted || decoded !== reference || actual !== expected) failures++;
      const byId = `op://${vault}/item/${id}`;
      if (!definition.isReference(byId) || await read(byId) !== expected) throw new Error("ID workaround control failed");
      console.log(`${id} ID workaround: ${expected}`);
    }
    result = failures === 0 ? 0 : 1;
  } finally {
    await new Promise((done) => server.close(done));
    server.closeAllConnections();
  }
} catch (error) {
  console.log(`setup: ${error instanceof Error ? error.message.replaceAll(checkout, "<checkout>") : "unknown error"}`);
} finally {
  if (scratch) rmSync(scratch, { recursive: true, force: true });
}
console.log(result === 0 ? "RESULT: expected behavior" : result === 1 ? "RESULT: defect reproduced" : "RESULT: inconclusive");
process.exitCode = result;

Expected behavior

A reference copied from op item get --format json should continue to select the field it identifies, including a space belonging to the field label. The 1Password secret-reference documentation includes spaces among supported characters; its CLI copy instructions describe obtaining references from each field's JSON reference property. The local fixture establishes that CLI 2.42.0 both emits and resolves these exact references. T3's docs/user/secrets.md#L27-L33 instructs users to copy a reference from 1Password and enter it in the source picker.

Impact and scope

Impact: P2 - A valid copied reference can select the wrong credential without a validation error, or be rejected, for fields whose names have an edge space. Field IDs provide a safe workaround, which the script verified for both fixture fields.

This affects references to names with meaningful edge spaces. When a same-named field without the trailing space exists, the demonstrated result is the other field's value. When it does not exist, the altered reference can fail to resolve; that absence variant was not run. No real credentials, vaults, or accounts were accessed. Live-account prevalence and a browser interaction recording are NOT_ENOUGH_INFO: they were outside the disposable fixture run and would require appropriately scoped test data and a UI run.

Version

T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93; 1Password CLI 2.42.0; Effect 4.1.3 from the pinned lockfile. The installed CLI version matches the latest stable version on the official release page.

Runtime and platform

Node v26.12.1; Git 2.56.0; Darwin 27.2.0, arm64. The HTTP fixture runs in Node and models the field and item shapes from Connect OpenAPI 1.8.1. No browser or desktop build was run.

Interface

UI: settings SecretSourceInput; Library: SecretReference schema and the 1Password provider definition; CLI: op item get and op read against a local synthetic Connect fixture.

Earliest failure stage

Input parsing or validation

Relevant output or evidence

The exact script output was:

node v26.12.1
git version 2.56.0
commit 59e64eef881632a23f89c4138bcf37f23d92bf93
effect 4.1.3
op 2.42.0
plain-reference control: PLAIN
trailing copied reference: "op://aaaaaaaaaaaaaaaaaaaaaaaaaa/item/credential "
trailing: direct=TRAILING, normalized=true, accepted=true, after-input=PLAIN
trailing ID workaround: TRAILING
leading copied reference: "op://aaaaaaaaaaaaaaaaaaaaaaaaaa/item/ credential"
leading: direct=LEADING, normalized=false, accepted=false, after-input=REJECTED
leading ID workaround: LEADING
RESULT: defect reproduced

All names, IDs, token text, and returned values in the script are generated fixtures; the output contains no personal information. Node also printed its standard experimental warning for stripTypeScriptTypes on stderr.

The shared path schema uses TrimmedNonEmptyString at packages/contracts/src/secretReference.ts#L16-L26; its decode and encode trim strings at packages/contracts/src/baseSchemas.ts#L9-L29. The provider's component pattern requires a non-space character at each end at packages/secret-provider-onepassword/src/shared/definition.ts#L26-L38. The web handler linked above validates the decoded string and commits it. The resolver checks that already-decoded reference at packages/secret-provider-core/src/server/SecretResolver.ts#L66-L78, and the individual-read driver passes it as the CLI argument at packages/secret-provider-onepassword/src/server/driver.ts#L28-L43. These source links establish the downstream path; the full resolver and driver were not executed in this reproduction.

Additional context

Restoration check: the script should finish with RESULT: expected behavior, preserving both copied references and returning TRAILING and LEADING after the input validation path. Plain-field and ID controls must remain successful.

  • https://github.com/pingdotgg/t3code/pull/31722 — preserves whitespace in submitted secret values; its body and complete discussion leave provider references trimmed. This report concerns the field identity encoded by a copied reference, so it has different closure criteria.
  1. tanstack-source added labelsbug

Sign in to join this conversation.