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
69 changes: 69 additions & 0 deletions assistant/src/__tests__/conversation-workspace-cache-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,75 @@ describe("Conversation workspace cache state", () => {
expect(conversation.isWorkspaceTopLevelDirty()).toBe(false);
});

test("renders client-reported host env when set on the conversation", () => {
conversation.hostHomeDir = "/Users/alice";
conversation.hostUsername = "alice";
conversation.refreshWorkspaceTopLevelContextIfNeeded();

const block = conversation.getWorkspaceTopLevelContext();
expect(block).not.toBeNull();
expect(block!).toContain("Host home directory: /Users/alice");
expect(block!).toContain("Host username: alice");
});

test("falls back to daemon os info when client host env is absent", async () => {
const { homedir, userInfo } = await import("node:os");
conversation.refreshWorkspaceTopLevelContextIfNeeded();

const block = conversation.getWorkspaceTopLevelContext();
expect(block).not.toBeNull();
expect(block!).toContain(`Host home directory: ${homedir()}`);
expect(block!).toContain(`Host username: ${userInfo().username}`);
});

test("re-renders with updated host env after marking dirty", () => {
conversation.hostHomeDir = "/Users/alice";
conversation.hostUsername = "alice";
conversation.refreshWorkspaceTopLevelContextIfNeeded();
expect(conversation.getWorkspaceTopLevelContext()!).toContain(
"Host home directory: /Users/alice",
);

conversation.hostHomeDir = "/Users/bob";
conversation.hostUsername = "bob";
conversation.markWorkspaceTopLevelDirty();
conversation.refreshWorkspaceTopLevelContextIfNeeded();

const block = conversation.getWorkspaceTopLevelContext();
expect(block).not.toBeNull();
expect(block!).toContain("Host home directory: /Users/bob");
expect(block!).toContain("Host username: bob");
expect(block!).not.toContain("Host home directory: /Users/alice");
expect(block!).not.toContain("Host username: alice");
});

test("falls back to os info after clearing macOS host env (cross-interface reuse)", async () => {
const { homedir, userInfo } = await import("node:os");

// Simulate a macOS turn populating host env.
conversation.hostHomeDir = "/Users/alice";
conversation.hostUsername = "alice";
conversation.refreshWorkspaceTopLevelContextIfNeeded();
expect(conversation.getWorkspaceTopLevelContext()!).toContain(
"Host home directory: /Users/alice",
);

// Simulate a subsequent non-macOS turn (iOS, CLI, channel) on the same
// conversation clearing the host env — without the clear, the stale
// macOS paths would leak into the next render.
conversation.hostHomeDir = undefined;
conversation.hostUsername = undefined;
conversation.markWorkspaceTopLevelDirty();
conversation.refreshWorkspaceTopLevelContextIfNeeded();

const block = conversation.getWorkspaceTopLevelContext();
expect(block).not.toBeNull();
expect(block!).toContain(`Host home directory: ${homedir()}`);
expect(block!).toContain(`Host username: ${userInfo().username}`);
expect(block!).not.toContain("Host home directory: /Users/alice");
expect(block!).not.toContain("Host username: alice");
});

test("workspace hints follow the resolved legacy directory when canonical is absent", () => {
const workspaceRoot = mkdtempSync(
join(tmpdir(), "conversation-workspace-cache-state-"),
Expand Down
67 changes: 67 additions & 0 deletions assistant/src/__tests__/top-level-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,71 @@ describe("renderWorkspaceTopLevelContext", () => {
);
expect(result).not.toContain("Current conversation folder");
});

test("prefers client-reported host env over daemon os.homedir()", () => {
const snapshot: TopLevelSnapshot = {
rootPath: "/sandbox",
directories: ["src"],
files: ["package.json"],
truncated: false,
};

const result = renderWorkspaceTopLevelContext(snapshot, {
hostHomeDir: "/Users/alice",
hostUsername: "alice",
});

expect(result).toContain("Host home directory: /Users/alice");
expect(result).toContain("Host username: alice");
// Fallback values must NOT appear when client values are provided.
expect(result).not.toContain(`Host home directory: ${homedir()}`);
expect(result).not.toContain(`Host username: ${userInfo().username}`);
});

test("falls back to daemon os info when host env options omitted", () => {
const snapshot: TopLevelSnapshot = {
rootPath: "/sandbox",
directories: ["src"],
files: ["package.json"],
truncated: false,
};

const result = renderWorkspaceTopLevelContext(snapshot, {});

expect(result).toContain(`Host home directory: ${homedir()}`);
expect(result).toContain(`Host username: ${userInfo().username}`);
});

test("falls back to daemon os info when host env options are undefined", () => {
const snapshot: TopLevelSnapshot = {
rootPath: "/sandbox",
directories: ["src"],
files: ["package.json"],
truncated: false,
};

const result = renderWorkspaceTopLevelContext(snapshot, {
hostHomeDir: undefined,
hostUsername: undefined,
});

expect(result).toContain(`Host home directory: ${homedir()}`);
expect(result).toContain(`Host username: ${userInfo().username}`);
});

test("uses client home dir but falls back to os username when only home is provided", () => {
const snapshot: TopLevelSnapshot = {
rootPath: "/sandbox",
directories: ["src"],
files: ["package.json"],
truncated: false,
};

const result = renderWorkspaceTopLevelContext(snapshot, {
hostHomeDir: "/Users/alice",
});

expect(result).toContain("Host home directory: /Users/alice");
expect(result).toContain(`Host username: ${userInfo().username}`);
});
});
33 changes: 33 additions & 0 deletions assistant/src/daemon/conversation-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,20 @@ export interface ProcessConversationContext {
forceCompact(): Promise<ContextWindowResult>;
/** Set transport-derived hints for the conversation. */
setTransportHints(hints: string[] | undefined): void;
/**
* Client-reported host home directory from macOS transport metadata.
* Used by the workspace block renderer so platform-managed daemons show
* the user's actual Mac home instead of the container's `os.homedir()`.
*/
hostHomeDir?: string;
/** Client-reported host username. See `hostHomeDir`. */
hostUsername?: string;
/**
* Workspace top-level cache dirty flag. The queue-drain path sets this
* when client-reported host env changes so the next render picks up the
* new values.
*/
workspaceTopLevelDirty: boolean;
}

function resolveQueuedTurnContext(
Expand Down Expand Up @@ -306,6 +320,25 @@ export async function drainQueue(
// environment context for internal turns.
if (next.transport) {
conversation.setTransportHints(buildTransportHints(next.transport));
// Mirror applyTransportMetadata: populate client-reported host env for
// macOS transports, and clear it for non-macOS transports so a
// conversation reused across interfaces doesn't keep rendering stale
// Mac paths in its `<workspace>` block.
const prevHomeDir = conversation.hostHomeDir;
const prevUsername = conversation.hostUsername;
if (next.transport.interfaceId === "macos") {
conversation.hostHomeDir = next.transport.hostHomeDir;
conversation.hostUsername = next.transport.hostUsername;
} else {
conversation.hostHomeDir = undefined;
conversation.hostUsername = undefined;
}
if (
prevHomeDir !== conversation.hostHomeDir ||
prevUsername !== conversation.hostUsername
) {
conversation.workspaceTopLevelDirty = true;
}
}

// Non-interactive queued messages (channel requests) must not execute tools
Expand Down
11 changes: 11 additions & 0 deletions assistant/src/daemon/conversation-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ export interface WorkspaceConversationContext {
workingDir: string;
workspaceTopLevelContext: string | null;
workspaceTopLevelDirty: boolean;
/**
* Client-reported host home directory, populated from macOS transport
* metadata. Used to render the `<workspace>` block correctly for
* platform-managed daemons where `os.homedir()` would return the
* container's home instead of the user's actual Mac.
*/
hostHomeDir?: string;
/** Client-reported host username. See `hostHomeDir`. */
hostUsername?: string;
}

/** Refresh workspace top-level directory context if needed. */
Expand All @@ -36,6 +45,8 @@ export function refreshWorkspaceTopLevelContextIfNeeded(
conversationAttachmentsPath: currentConversationPath
? `${currentConversationPath}attachments/`
: null,
hostHomeDir: ctx.hostHomeDir,
hostUsername: ctx.hostUsername,
});
ctx.workspaceTopLevelDirty = false;
}
14 changes: 14 additions & 0 deletions assistant/src/daemon/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,20 @@ export class Conversation {
}> = [];
/** @internal */ workspaceTopLevelContext: string | null = null;
/** @internal */ workspaceTopLevelDirty = true;
/**
* Host home directory reported by the client (macOS `NSHomeDirectory()`).
* Populated from `MacosTransportMetadata` when a message arrives. Consumed
* by the `<workspace>` block renderer so platform-managed (containerized)
* daemons show the user's actual Mac home dir instead of the container's.
* @internal
*/
hostHomeDir?: string;
/**
* Host username reported by the client (macOS `NSUserName()`). See
* `hostHomeDir`.
* @internal
*/
hostUsername?: string;
public readonly traceEmitter: TraceEmitter;
/** @internal */ hasSystemPromptOverride: boolean;
public memoryPolicy: ConversationMemoryPolicy;
Expand Down
28 changes: 27 additions & 1 deletion assistant/src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ import {
getWorkspacePromptPath,
} from "../util/platform.js";
import { registerDaemonCallbacks } from "../work-items/work-item-runner.js";
import { AppSourceWatcher, setEnsureAppSourceWatcher } from "./app-source-watcher.js";
import {
AppSourceWatcher,
setEnsureAppSourceWatcher,
} from "./app-source-watcher.js";
import { ConfigWatcher } from "./config-watcher.js";
import {
Conversation,
Expand Down Expand Up @@ -384,6 +387,29 @@ export class DaemonServer {
"Transport metadata received",
);
conversation.setTransportHints(buildTransportHints(transport));
// Client-reported host env flows into the `<workspace>` block so
// platform-managed (containerized) daemons show the user's actual
// Mac paths instead of the container's `os.homedir()`. Non-macOS
// transports (iOS, CLI, channels) carry no host env, so we clear
// any previously-stored macOS values — otherwise a conversation
// reused across interfaces would keep rendering stale macOS paths.
// Invalidate the cached workspace block when the values change so
// the next render picks them up.
const prevHomeDir = conversation.hostHomeDir;
const prevUsername = conversation.hostUsername;
if (transport.interfaceId === "macos") {
Comment thread
noanflaherty marked this conversation as resolved.
conversation.hostHomeDir = transport.hostHomeDir;
conversation.hostUsername = transport.hostUsername;
} else {
conversation.hostHomeDir = undefined;
conversation.hostUsername = undefined;
}
if (
prevHomeDir !== conversation.hostHomeDir ||
prevUsername !== conversation.hostUsername
) {
conversation.workspaceTopLevelDirty = true;
}
}

constructor() {
Expand Down
20 changes: 17 additions & 3 deletions assistant/src/workspace/top-level-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ import type { TopLevelSnapshot } from "./top-level-scanner.js";

export interface WorkspaceTopLevelRenderOptions {
conversationAttachmentsPath?: string | null;
/**
* Host home directory on the client machine. When provided, takes
* precedence over the daemon's own `os.homedir()`. This matters for
* platform-managed (containerized) daemons where `os.homedir()` returns
* the container's home, not the user's actual Mac.
*/
hostHomeDir?: string;
/**
* Host username on the client machine. When provided, takes precedence
* over the daemon's own `os.userInfo().username`. See `hostHomeDir`.
*/
hostUsername?: string;
}

/**
Expand All @@ -21,13 +33,15 @@ export function renderWorkspaceTopLevelContext(
lines.push(`Directories: ${snapshot.directories.join(", ")}`);
lines.push(`Files: ${snapshot.files.join(", ")}`);
if (options.conversationAttachmentsPath) {
lines.push(`Current conversation attachments: ${options.conversationAttachmentsPath}`);
lines.push(
`Current conversation attachments: ${options.conversationAttachmentsPath}`,
);
}
if (snapshot.truncated) {
lines.push("(list truncated — more entries exist)");
}
lines.push(`Host home directory: ${homedir()}`);
lines.push(`Host username: ${userInfo().username}`);
lines.push(`Host home directory: ${options.hostHomeDir ?? homedir()}`);
lines.push(`Host username: ${options.hostUsername ?? userInfo().username}`);
lines.push("</workspace>");
return lines.join("\n");
}
Loading