Skip to content

Commit d5fecb6

Browse files
feat: add configurable temp directory to resolve Windows access denied errors (#629)
1 parent 699ff32 commit d5fecb6

11 files changed

Lines changed: 551 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
### Added
1212

1313
- `apm install` now automatically discovers and deploys local `.apm/` primitives (skills, instructions, agents, prompts, hooks, commands) to target directories, with local content taking priority over dependencies on collision (#626, #644)
14+
- Add `temp-dir` configuration key (`apm config set temp-dir PATH`) to override the system temporary directory, resolving `[WinError 5] Access is denied` in corporate Windows environments (#629)
1415

1516
### Fixed
1617

docs/src/content/docs/reference/cli-commands.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,6 +1345,7 @@ apm config
13451345
- Global configuration
13461346
- APM CLI version
13471347
- `auto-integrate` setting
1348+
- `temp-dir` setting (when configured)
13481349

13491350
**Examples:**
13501351
```bash
@@ -1363,6 +1364,7 @@ apm config get [KEY]
13631364
**Arguments:**
13641365
- `KEY` (optional) - Configuration key to retrieve. Supported keys:
13651366
- `auto-integrate` - Whether to automatically integrate `.prompt.md` files into AGENTS.md
1367+
- `temp-dir` - Custom temporary directory for clone/download operations
13661368

13671369
If `KEY` is omitted, displays all configuration values.
13681370

@@ -1386,6 +1388,7 @@ apm config set KEY VALUE
13861388
**Arguments:**
13871389
- `KEY` - Configuration key to set. Supported keys:
13881390
- `auto-integrate` - Enable/disable automatic integration of `.prompt.md` files
1391+
- `temp-dir` - Set a custom temporary directory path
13891392
- `VALUE` - Value to set. For boolean keys, use: `true`, `false`, `yes`, `no`, `1`, `0`
13901393

13911394
**Configuration Keys:**
@@ -1411,6 +1414,30 @@ apm config set auto-integrate yes
14111414
apm config set auto-integrate 1
14121415
```
14131416

1417+
**`temp-dir`** - Override the system temporary directory
1418+
- **Type:** String (directory path)
1419+
- **Default:** System temp directory (not stored)
1420+
- **Description:** Set a custom temporary directory for clone and download operations. Useful in corporate Windows environments where endpoint security software restricts access to `%TEMP%`, causing `[WinError 5] Access is denied`.
1421+
- **Resolution order:** `APM_TEMP_DIR` environment variable > `temp_dir` in `~/.apm/config.json` > system default.
1422+
- **Use Cases:**
1423+
- Set when the default system temp directory is restricted or unavailable
1424+
- Use the `APM_TEMP_DIR` environment variable for CI pipelines or per-session overrides
1425+
1426+
**Examples:**
1427+
```bash
1428+
# Set a custom temp directory (Windows)
1429+
apm config set temp-dir C:\apm-temp
1430+
1431+
# Set a custom temp directory (macOS/Linux)
1432+
apm config set temp-dir /tmp/apm-work
1433+
1434+
# Check the current temp-dir setting
1435+
apm config get temp-dir
1436+
1437+
# Or use the environment variable instead
1438+
export APM_TEMP_DIR=/tmp/apm-work
1439+
```
1440+
14141441
## Runtime Management (Experimental)
14151442

14161443
### `apm runtime` (Experimental) - Manage AI runtimes

packages/apm-guide/.apm/skills/apm-usage/commands.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,6 @@
8181
| Command | Purpose | Key flags |
8282
|---------|---------|-----------|
8383
| `apm config` | Show current configuration | -- |
84-
| `apm config get [KEY]` | Get a config value | -- |
85-
| `apm config set KEY VALUE` | Set a config value | -- |
84+
| `apm config get [KEY]` | Get a config value (`auto-integrate`, `temp-dir`) | -- |
85+
| `apm config set KEY VALUE` | Set a config value (`auto-integrate`, `temp-dir`) | -- |
8686
| `apm update` | Update APM itself | `--check` only check |

src/apm_cli/bundle/unpacker.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@ def unpack_bundle(
5656
# 1. If archive, extract to temp dir
5757
cleanup_temp = False
5858
if bundle_path.is_file() and bundle_path.name.endswith(".tar.gz"):
59-
temp_dir = Path(tempfile.mkdtemp(prefix="apm-unpack-"))
59+
from ..config import get_apm_temp_dir
60+
temp_dir = Path(tempfile.mkdtemp(prefix="apm-unpack-", dir=get_apm_temp_dir()))
6061
cleanup_temp = True
6162
try:
6263
with tarfile.open(bundle_path, "r:gz") as tar:

src/apm_cli/commands/config.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ def config(ctx):
8484

8585
config_table.add_row("Global", "APM CLI Version", get_version())
8686

87+
from ..config import get_temp_dir as _get_temp_dir
88+
89+
_temp_dir_val = _get_temp_dir()
90+
if _temp_dir_val:
91+
config_table.add_row("", "Temp Directory", _temp_dir_val)
92+
8793
console.print(config_table)
8894

8995
except (ImportError, NameError):
@@ -105,6 +111,12 @@ def config(ctx):
105111
click.echo(f"\n{HIGHLIGHT}Global:{RESET}")
106112
click.echo(f" APM CLI Version: {get_version()}")
107113

114+
from ..config import get_temp_dir as _get_temp_dir_fb
115+
116+
_temp_dir_fb = _get_temp_dir_fb()
117+
if _temp_dir_fb:
118+
click.echo(f" Temp Directory: {_temp_dir_fb}")
119+
108120

109121
@config.command(help="Set a configuration value")
110122
@click.argument("key")
@@ -116,7 +128,7 @@ def set(key, value):
116128
apm config set auto-integrate false
117129
apm config set auto-integrate true
118130
"""
119-
from ..config import set_auto_integrate
131+
from ..config import set_auto_integrate, set_temp_dir
120132

121133
logger = CommandLogger("config set")
122134
if key == "auto-integrate":
@@ -129,9 +141,17 @@ def set(key, value):
129141
else:
130142
logger.error(f"Invalid value '{value}'. Use 'true' or 'false'.")
131143
sys.exit(1)
144+
elif key == "temp-dir":
145+
try:
146+
set_temp_dir(value)
147+
from ..config import get_temp_dir
148+
logger.success(f"Temporary directory set to: {get_temp_dir()}")
149+
except ValueError as exc:
150+
logger.error(str(exc))
151+
sys.exit(1)
132152
else:
133153
logger.error(f"Unknown configuration key: '{key}'")
134-
logger.progress("Valid keys: auto-integrate")
154+
logger.progress("Valid keys: auto-integrate, temp-dir")
135155
logger.progress(
136156
"This error may indicate a bug in command routing. Please report this issue."
137157
)
@@ -147,16 +167,22 @@ def get(key):
147167
apm config get auto-integrate
148168
apm config get
149169
"""
150-
from ..config import get_auto_integrate
170+
from ..config import get_auto_integrate, get_temp_dir
151171

152172
logger = CommandLogger("config get")
153173
if key:
154174
if key == "auto-integrate":
155175
value = get_auto_integrate()
156176
click.echo(f"auto-integrate: {value}")
177+
elif key == "temp-dir":
178+
value = get_temp_dir()
179+
if value is None:
180+
click.echo("temp-dir: Not set (using system default)")
181+
else:
182+
click.echo(f"temp-dir: {value}")
157183
else:
158184
logger.error(f"Unknown configuration key: '{key}'")
159-
logger.progress("Valid keys: auto-integrate")
185+
logger.progress("Valid keys: auto-integrate, temp-dir")
160186
logger.progress(
161187
"This error may indicate a bug in command routing. Please report this issue."
162188
)
@@ -167,3 +193,5 @@ def get(key):
167193
# have not been written yet (e.g. auto_integrate on a fresh install).
168194
logger.progress("APM Configuration:")
169195
click.echo(f" auto-integrate: {get_auto_integrate()}")
196+
temp_dir = get_temp_dir()
197+
click.echo(f" temp-dir: {temp_dir if temp_dir is not None else 'Not set (using system default)'}")

src/apm_cli/commands/update.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,10 @@ def update(check):
117117
response.raise_for_status()
118118

119119
# Create temporary file for install script
120+
from ..config import get_apm_temp_dir
120121
with tempfile.NamedTemporaryFile(
121-
mode="w", suffix=_get_update_installer_suffix(), delete=False
122+
mode="w", suffix=_get_update_installer_suffix(), delete=False,
123+
dir=get_apm_temp_dir()
122124
) as f:
123125
temp_script = f.name
124126
f.write(response.text)

src/apm_cli/config.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,57 @@ def set_auto_integrate(enabled: bool) -> None:
9292
enabled: Whether to enable auto-integration.
9393
"""
9494
update_config({"auto_integrate": enabled})
95+
96+
97+
def get_temp_dir() -> Optional[str]:
98+
"""Get the configured temporary directory.
99+
100+
Returns:
101+
The stored temp_dir config value, or None if not set.
102+
"""
103+
return get_config().get("temp_dir")
104+
105+
106+
def set_temp_dir(path: str) -> None:
107+
"""Set the temporary directory after validating it exists and is writable.
108+
109+
The path is normalised (``~`` expansion + absolute) before validation and
110+
storage so that relative or home-relative paths work predictably.
111+
112+
Args:
113+
path: Filesystem path to use as temporary directory.
114+
115+
Raises:
116+
ValueError: If the path does not exist, is not a directory, or is not
117+
writable.
118+
"""
119+
resolved = os.path.abspath(os.path.expanduser(path))
120+
if not os.path.exists(resolved):
121+
raise ValueError(f"Directory does not exist: {resolved}")
122+
if not os.path.isdir(resolved):
123+
raise ValueError(f"Path is not a directory: {resolved}")
124+
if not os.access(resolved, os.W_OK):
125+
raise ValueError(f"Directory is not writable: {resolved}")
126+
update_config({"temp_dir": resolved})
127+
128+
129+
def get_apm_temp_dir() -> Optional[str]:
130+
"""Return the effective temporary directory for APM operations.
131+
132+
Resolution order:
133+
1. ``APM_TEMP_DIR`` environment variable (escape-hatch override)
134+
2. ``temp_dir`` value from ``~/.apm/config.json``
135+
3. ``None`` (caller falls back to the system default)
136+
137+
Empty or whitespace-only values are treated as unset and skipped.
138+
139+
Returns:
140+
Directory path string, or None when the system default should be used.
141+
"""
142+
env_val = os.environ.get("APM_TEMP_DIR", "").strip()
143+
if env_val:
144+
return env_val
145+
config_val = (get_temp_dir() or "").strip()
146+
if config_val:
147+
return config_val
148+
return None

src/apm_cli/deps/github_downloader.py

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,9 @@ def _setup_git_environment(self) -> Dict[str, Any]:
195195
if sys.platform == 'win32':
196196
# 'NUL' fails on some Windows git versions; use an empty temp file.
197197
import tempfile
198-
empty_cfg = os.path.join(tempfile.gettempdir(), '.apm_empty_gitconfig')
198+
from ..config import get_apm_temp_dir
199+
temp_base = get_apm_temp_dir() or tempfile.gettempdir()
200+
empty_cfg = os.path.join(temp_base, '.apm_empty_gitconfig')
199201
with open(empty_cfg, 'w') as f:
200202
pass
201203
env['GIT_CONFIG_GLOBAL'] = empty_cfg
@@ -954,7 +956,8 @@ def resolve_git_reference(self, repo_ref: Union[str, "DependencyReference"]) ->
954956
# Create a temporary directory for Git operations
955957
temp_dir = None
956958
try:
957-
temp_dir = Path(tempfile.mkdtemp())
959+
from ..config import get_apm_temp_dir
960+
temp_dir = Path(tempfile.mkdtemp(dir=get_apm_temp_dir()))
958961

959962
if is_likely_commit:
960963
# For commit SHAs, clone full repository first, then checkout the commit
@@ -1775,8 +1778,10 @@ def download_subdirectory_package(self, dep_ref: DependencyReference, target_pat
17751778
# tempfile.TemporaryDirectory().__exit__ calls shutil.rmtree without our
17761779
# retry logic, which raises WinError 32 when git processes still hold
17771780
# handles at the end of the with-block.
1778-
temp_dir = tempfile.mkdtemp()
1781+
from ..config import get_apm_temp_dir
1782+
temp_dir = None
17791783
try:
1784+
temp_dir = tempfile.mkdtemp(dir=get_apm_temp_dir())
17801785
# Sparse checkout always targets "repo/". If it fails we clone into
17811786
# "repo_clone/" so we never have to rmtree a directory that may still
17821787
# have live git handles from the failed subprocess.
@@ -1886,8 +1891,32 @@ def download_subdirectory_package(self, dep_ref: DependencyReference, target_pat
18861891
if progress_obj and progress_task_id is not None:
18871892
progress_obj.update(progress_task_id, completed=90, total=100)
18881893

1894+
except PermissionError as exc:
1895+
exc_path = getattr(exc, 'filename', None)
1896+
# If temp_dir wasn't created (mkdtemp failed) or the error is within
1897+
# the temp tree, this is likely a restricted temp directory issue.
1898+
if temp_dir is None or (exc_path and str(exc_path).startswith(str(temp_dir))):
1899+
raise RuntimeError(
1900+
"Access denied in temporary directory"
1901+
+ (f" '{temp_dir}'" if temp_dir else "")
1902+
+ ". Corporate security may restrict this path. "
1903+
"Fix: apm config set temp-dir <WRITABLE_PATH>"
1904+
) from None
1905+
raise
1906+
except OSError as exc:
1907+
if getattr(exc, 'errno', None) == 13 or getattr(exc, 'winerror', None) == 5:
1908+
exc_path = getattr(exc, 'filename', None)
1909+
if temp_dir is None or (exc_path and str(exc_path).startswith(str(temp_dir))):
1910+
raise RuntimeError(
1911+
"Access denied in temporary directory"
1912+
+ (f" '{temp_dir}'" if temp_dir else "")
1913+
+ ". Corporate security may restrict this path. "
1914+
"Fix: apm config set temp-dir <WRITABLE_PATH>"
1915+
) from None
1916+
raise
18891917
finally:
1890-
_rmtree(temp_dir)
1918+
if temp_dir:
1919+
_rmtree(temp_dir)
18911920

18921921
# Validate the extracted package (after temp dir is cleaned up)
18931922
validation_result = validate_apm_package(target_path)
@@ -1938,6 +1967,7 @@ def _download_subdirectory_from_artifactory(
19381967
) -> PackageInfo:
19391968
"""Download an archive from Artifactory and extract a subdirectory."""
19401969
import tempfile
1970+
from ..config import get_apm_temp_dir
19411971
ref = dep_ref.reference or "main"
19421972
subdir_path = dep_ref.virtual_path
19431973
repo_parts = dep_ref.repo_url.split('/')
@@ -1947,7 +1977,7 @@ def _download_subdirectory_from_artifactory(
19471977
if progress_obj and progress_task_id is not None:
19481978
progress_obj.update(progress_task_id, completed=10, total=100)
19491979

1950-
with tempfile.TemporaryDirectory() as temp_dir:
1980+
with tempfile.TemporaryDirectory(dir=get_apm_temp_dir()) as temp_dir:
19511981
temp_path = Path(temp_dir) / "full_pkg"
19521982
self._download_artifactory_archive(host, prefix, owner, repo, ref, temp_path, scheme=scheme)
19531983
if progress_obj and progress_task_id is not None:

src/apm_cli/runtime/manager.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ def run_embedded_script(self, script_content: str, common_content: str,
105105
"""Execute an embedded setup script with common utilities."""
106106
script_args = script_args or []
107107

108-
with tempfile.TemporaryDirectory() as temp_dir:
108+
from ..config import get_apm_temp_dir
109+
with tempfile.TemporaryDirectory(dir=get_apm_temp_dir()) as temp_dir:
109110
temp_path = Path(temp_dir)
110111

111112
if self._is_windows:

0 commit comments

Comments
 (0)