Running a sync on a schedule
monitor and monitor-with-permissions stay running and pick up changes from file-system notifications. On a local disk that is dependable. On a network share it is not: the notifications only arrive if the SMB server sends them and the connection that carries them survives, and a dropped notification is silent — the file stays out of the workspace until something else makes the connector look at that folder again.
Scheduling the one-shot commands avoids that. upload-folder-with-permissions (or upload-folder where ACLs don't matter) re-walks the tree on every run and compares it against the workspace, so nothing depends on a notification arriving.
This page is a worked example of that setup for a Windows file server: one Task Scheduler task per share, started at staggered minutes, each run writing its own log.
An example, not a shipped feature
Nothing on this page is part of the CLI. The script below is a starting point to read, adapt and own — it is not installed by Curiosity.CLI, not updated with it, and not supported as part of it.
Scheduled runs versus a monitor service
Scheduled upload-folder-with-permissions |
monitor-with-permissions as a service |
|
|---|---|---|
| Finds changes by | re-walking the share every run | file-system change notifications |
| A missed change | is picked up by the next run | stays missed |
| Latency | up to one interval | seconds |
| Process | starts, finishes, exits | runs until stopped |
| Load on the file server | bounded, and can be moved off-hours | continuous, low |
| Restart after a reboot | the task's next trigger | needs a service supervisor |
| Set up with | Task Scheduler | NSSM or sc.exe — see Running it as a service |
Use the monitor where the files are local to the machine running the CLI and the workspace should see a change within seconds. Use scheduled runs for network shares, and for any share large enough that a full pass is the thing you want to control the timing of.
Why the watcher is unreliable over SMB
monitor and monitor-with-permissions do one full pass and then hand over to FileSystemWatcher, a thin wrapper over the Win32 ReadDirectoryChangesW API. On a local disk the filesystem driver hands it change records directly. Over a share those records have to be produced by the server and pushed to the client over SMB — a best-effort feature, with failure modes that do not exist locally:
| A connection blip stops the watcher, silently | If the SMB session drops for a moment — VPN hiccup, laptop sleep, server reboot, session or credential expiry, idle disconnect — the directory handle becomes invalid. The watcher raises an Error (often Win32 error 64, the specified network name is no longer available), stops raising change events, and the process carries on as if the folder had gone quiet. This is the common one. |
| The event buffer overflows | Events queue in a kernel buffer (8 KB by default), each carrying a filename. A burst — a build output, a sync tool, a large copy — overflows it and everything in that window is lost. Network latency widens the gap between reads, so overflow is far likelier than locally. Raising the buffer is a mitigation, not a fix: it is per-watcher non-paged pool memory and caps at 64 KB. |
| The server may implement change notification poorly, or not at all | Windows Server over SMB2/3 is reliable. Samba, consumer NAS boxes, DFS namespaces and anything fronting the storage with a second protocol are hit-or-miss — a NAS translating SMB change-notify from inotify typically misses writes made over NFS, FTP or its own web UI. An NFS mount has no notification mechanism at all, so the watcher simply never fires. Offline Files / client-side caching intercepts and distorts events. |
| Timing and event content differ | Write caching and oplocks mean a client may not flush until the file is closed, so LastWrite arrives late or in a clump; servers coalesce duplicate events; a move between shares usually arrives as delete + create rather than a rename. Delivery is not the only thing that is less trustworthy — so is what each event says. |
| A mapped drive letter is per-logon-session | Z:\data does not exist for a service or scheduled task running as another account. Always point the CLI at the UNC path. |
Two of those matter especially for how the CLI uses the watcher: it does not re-create a watcher that has stopped, and after the initial pass nothing re-reads the tree. So a monitor that lost its notifications keeps running, logs nothing unusual, and the workspace drifts until somebody restarts the process — because it is the restart that does a full pass again.
A scheduled upload-folder-with-permissions is that reconciliation, done on purpose and on a schedule you choose, instead of whenever the process happens to be restarted.
Choosing
- The files are on a local disk on the machine running the CLI, and seconds matter →
monitor/monitor-with-permissions. - The files are on a network share — SMB, DFS, a NAS, an NFS mount, anything behind a VPN or on a laptop that sleeps → scheduled runs, as below. Pick the interval from how stale the workspace is allowed to be, not from how often the share changes.
- You need both seconds of latency and a guarantee, and you control the file server → have that end push its changes, rather than polling it from outside: a Data Connector running on the file server reports what changed instead of inferring it from SMB notifications.
What the setup looks like
- One task per share, not one task per file server. Each has its own log, its own exit code and its own permissions cache, and a share that fails does not hold up the others.
- Staggered start minutes. The first share starts at
:00, the second at:10, and so on, so a file server is never asked for several full walks at once. - One permissions cache per share (
cache\<share>.json). Resolving a Windows SID to a workspace user or group is the expensive part of a permissioned ingest, so keeping the path stable across runs is what keeps the second run cheap. See Why a cache file? - No secret in the script or its config. The Library Token is stored once in the service account's own profile with
store-token, against theSERVERit belongs to, and used as--token auto; the account's password is either typed once at theinstallprompt or, for a gMSA, held by Active Directory. - One log per share per day, pruned by age after every run, so the setup needs no cleanup task of its own.
Prerequisites
Put the CLI where every account can see it
Download curiosity-cli.exe from the releases page and save it to a fixed path — E:\CuriosityCLI\curiosity-cli.exe in the examples below. It is self-contained, so the machine needs no .NET SDK and no .NET runtime.
Put the script in that folder, or point CLIPATH at the executable.
Don't install the CLI as a dotnet tool for this setup. dotnet tool install --global installs into the invoking user's profile (%USERPROFILE%\.dotnet\tools), which the service account cannot read, and a dotnet-tool install gives you a launcher that runs the tool through dotnet rather than a standalone curiosity-cli.exe — so the script's tasklist and taskkill /im curiosity-cli.exe checks don't see the running sync. See Running under a service account.
Keep the whole path free of spaces
A task's arguments are one string, not a list, so every path in it depends on getting the quoting right — and a path with a space in it registers fine and then fails when the task runs. check reports a space in the script folder as a failure for that reason. Use a path like E:\CuriosityCLI rather than C:\Program Files\....
Pick the account the tasks run as
A domain account that can read the shares, or a gMSA. It needs:
| Resource | Access |
|---|---|
| Every configured share | Read — both the share permission and the NTFS permission; effective access is the more restrictive of the two. |
| The security descriptors on those files | READ_CONTROL, which read access already implies on a normal share. |
| Active Directory | Standard authenticated-user read, to resolve the SIDs in the ACLs. No extra grant in a default AD. |
| The script folder | Write, for logs\ and cache\. |
| Local policy | Log on as a batch job — secpol.msc → Local Policies → User Rights Assignment, or a GPO where one manages that right. check warns when the account does not hold it. |
Without it the task registers fine and every run fails immediately with 0x80070569 (2147943785) — Logon failure: the user has not been granted the requested logon type at this computer. The script only reports the right; granting it is a policy decision, and on a domain-managed machine a local grant is overwritten at the next refresh anyway.
Decide whether the files are indexed in place
INPLACE=true passes --in-place: the workspace stores each file's URL and reads the bytes from the share itself, so the workspace also needs its own read access to the UNC path. INPLACE=false copies the contents into the workspace instead. Both keep --sync-file-url, which --fetch-server-state true requires in order to remove files from the workspace after they are deleted from the share.
The script
Save this as curiosity-sync.ps1 next to curiosity-cli.exe, and run it from an elevated PowerShell prompt. It uses the ScheduledTasks module to register, enable, disable and query the tasks, secedit for the logon right, and .NET's NTAccount.Translate to resolve the account to its SID.
The registered tasks run powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File …, so the machine's execution policy does not have to be relaxed for the scheduled runs. Running the script by hand needs a policy that allows it (RemoteSigned is enough for a local file), or the same -ExecutionPolicy Bypass form.
The first line opens a PowerShell block comment that is also valid cmd, so a copy saved as .bat or .cmd — the usual accident when replacing the older .\curiosity-sync.ps1 — prints a one-screen warning and exits 1 instead of running a few lines of the script and leaving half a setup behind.
<# 2>nul
@echo off
rem ==========================================================================
rem cmd.exe guard. The first line of this file opens a PowerShell block
rem comment, so PowerShell skips this section entirely and only cmd ever
rem gets here - which happens when the file was saved with a .bat or .cmd
rem extension, usually by pasting it over the older curiosity-sync.bat.
rem ==========================================================================
echo(
echo curiosity-sync.ps1 is a PowerShell script. cmd.exe cannot run it.
echo(
echo Save this file as curiosity-sync.ps1 - not .bat or .cmd - and run it
echo from an elevated PowerShell prompt:
echo(
echo .\curiosity-sync.ps1 check
echo(
echo Or, from cmd:
echo(
echo powershell -NoProfile -ExecutionPolicy Bypass -File curiosity-sync.ps1 check
echo(
exit /b 1
#>
#Requires -Version 5.1
<#
curiosity-sync.ps1 - scheduled network-share sync for the Curiosity CLI.
Registers one Task Scheduler task per share, each running a one-shot
"curiosity-cli upload-folder-with-permissions" over that share, staggered
so two shares never start at the same minute.
Usage: .\curiosity-sync.ps1 <action> [argument]
init write a curiosity-sync.config.txt template
check verify everything, including as the service account
store-token <t> store the Library Token AS the service account
install register one staggered task per share
uninstall remove those tasks
start re-enable all tasks
stop disable all tasks, wait for the current run to finish
force disable all tasks and kill curiosity-cli.exe now
status state / last result / next run per task (default)
run <share> sync one share - the scheduled-task entry point
prune delete logs older than LOGKEEPDAYS
probe internal: the self-test that check runs
store-token-now internal: the store-token task's entry point
help the full checklist
Every action reports what failed and sets a non-zero exit code, so a
scheduled run records a result the status table can show.
The tasks are registered to run powershell.exe with -ExecutionPolicy
Bypass, so the machine's execution policy does not have to be relaxed for
the scheduled runs. Running the script by hand needs a policy that allows
it, or the -ExecutionPolicy Bypass form above.
No secret lives in this file or in the config: the Library Token is kept
by "store-token" in the service account's own profile, against the SERVER
it was stored for, and used with --token auto. The account's password is
either typed at the install prompt (held in memory for that one run) or
held by Active Directory (a gMSA).
#>
[CmdletBinding()]
param(
[Parameter(Position = 0)]
[ValidateSet('init', 'check', 'store-token', 'install', 'uninstall', 'start', 'stop',
'force', 'status', 'run', 'prune', 'probe', 'store-token-now', 'help')]
[string] $Action = 'status',
[Parameter(Position = 1)]
[string] $Argument
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ([System.Environment]::OSVersion.Platform -ne [System.PlatformID]::Win32NT) {
Write-Host 'curiosity-sync.ps1 runs on Windows only: it drives Task Scheduler, secedit and'
Write-Host 'the permissioned CLI commands, none of which exist on other platforms.'
exit 1
}
$Script:ScriptPath = $PSCommandPath
$Script:ScriptName = Split-Path -Leaf $PSCommandPath
$Script:Dir = Split-Path -Parent $PSCommandPath
$Script:CfgPath = Join-Path $Script:Dir 'curiosity-sync.config.txt'
$Script:LogDir = Join-Path $Script:Dir 'logs'
$Script:CacheDir = Join-Path $Script:Dir 'cache'
$Script:ProbeOut = Join-Path $Script:Dir 'probe-result.txt'
$Script:ProbeAlt = Join-Path $env:WINDIR 'Temp\curiosity-sync-probe.txt'
$Script:PSExe = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe'
$Script:Password = $null
$Script:ExitCode = 0
function Write-Fail {
param([string] $Message)
Write-Host $Message
$Script:ExitCode = 1
}
#=========================================================== configuration ===
function Read-SyncConfig {
if (-not (Test-Path -LiteralPath $Script:CfgPath)) {
throw "config not found: $Script:CfgPath`nRun: .\$Script:ScriptName init"
}
$values = @{}
$shares = [System.Collections.Generic.List[object]]::new()
foreach ($line in Get-Content -LiteralPath $Script:CfgPath) {
$text = $line.Trim()
if ($text.Length -eq 0 -or $text.StartsWith('#')) { continue }
$split = $text.IndexOf('=')
if ($split -lt 1) { continue }
$key = $text.Substring(0, $split).Trim().ToUpperInvariant()
$value = $text.Substring($split + 1).Trim()
if ($key -eq 'SHARE') {
$parts = $value.Split(':', 2)
$name = $parts[0].Trim()
$stem = if ($parts.Count -gt 1 -and $parts[1].Trim()) { $parts[1].Trim() } else { $name }
if ($name) { $shares.Add([pscustomobject]@{ Name = $name; Stem = $stem }) }
}
else {
$values[$key] = $value
}
}
foreach ($key in 'SERVER', 'SOURCE', 'UNCBASE', 'ACCOUNT', 'TASKFOLDER', 'TASKPREFIX', 'STAGGER', 'EVERYHOURS') {
if (-not $values.ContainsKey($key) -or -not $values[$key]) {
throw "$key is missing from $Script:CfgPath"
}
}
if ($shares.Count -eq 0) { throw "no SHARE= lines in $Script:CfgPath" }
# Optional settings, so a config written before they existed still loads.
if (-not $values.ContainsKey('LOGKEEPDAYS')) { $values['LOGKEEPDAYS'] = '7' }
if (-not $values.ContainsKey('INPLACE')) { $values['INPLACE'] = 'true' }
if (-not $values.ContainsKey('CLIPATH')) { $values['CLIPATH'] = Join-Path $Script:Dir 'curiosity-cli.exe' }
# A trailing backslash would double the separator in every path built below.
$values['UNCBASE'] = $values['UNCBASE'].TrimEnd('\')
[pscustomobject]@{
Server = $values['SERVER']
Source = $values['SOURCE']
UncBase = $values['UNCBASE']
Account = $values['ACCOUNT']
TaskFolder = $values['TASKFOLDER']
TaskPrefix = $values['TASKPREFIX']
Stagger = [int] $values['STAGGER']
EveryHours = [int] $values['EVERYHOURS']
LogKeepDays = [int] $values['LOGKEEPDAYS']
InPlace = ($values['INPLACE'] -ieq 'true')
Exe = $values['CLIPATH']
Extensions = $(if ($values.ContainsKey('EXTENSIONS')) { $values['EXTENSIONS'] } else { $null })
Bandwidth = $(if ($values.ContainsKey('BANDWIDTH')) { $values['BANDWIDTH'] } else { $null })
Timeout = $(if ($values.ContainsKey('TIMEOUT')) { $values['TIMEOUT'] } else { $null })
Shares = $shares
# A trailing $ means a group managed service account: Active Directory
# holds its password, so it is registered without one.
IsManaged = $values['ACCOUNT'].EndsWith('$')
}
}
function Invoke-Init {
if (Test-Path -LiteralPath $Script:CfgPath) {
Write-Host "Config already exists: $Script:CfgPath"
Write-Host 'Delete it first for a fresh template.'
return
}
$template = @'
# curiosity-sync configuration. KEY=value, no quotes, no trailing spaces.
# Holds no secret: the token is stored per-account by ".\curiosity-sync.ps1 store-token".
#
# Workspace URL. The CLI normalises it to end in /api/, and the token is
# stored against it - change it and the token has to be stored again.
SERVER=http://localhost:8080/
# Source label written on every file entry, and the first folder in the workspace.
SOURCE=CompanyData
# UNC root the shares live under, without a trailing backslash.
UNCBASE=\\CORPSERVER.corp.example.com\
# DOMAIN\account the tasks run as. A group managed service account is DOMAIN\name$.
ACCOUNT=CORP\svc-curiosity$
# Task Scheduler folder and task-name prefix.
TASKFOLDER=CuriosityCLI
TASKPREFIX=CuriosityCLI-Sync-
# minutes between share start times, and hours between runs
STAGGER=10
EVERYHOURS=1
# delete logs not modified for this many days
LOGKEEPDAYS=7
# true: index the files where they are - the workspace stores only their URL,
# and needs its own read access to the share.
# false: copy the file contents into the workspace.
INPLACE=false
# optional: full path to curiosity-cli.exe, when it is not next to this script
#CLIPATH=E:\CuriosityCLI\curiosity-cli.exe
# optional: only these extensions, separated by ;
#EXTENSIONS=pdf;doc;docx;xlsx
# optional: cap the read rate, in Mbps
#BANDWIDTH=100
# optional: per-request timeout in seconds (the CLI default is 90)
#TIMEOUT=300
# One line per share, in run order: SHARE=<folder>[:<cache-file-stem>]
# The order sets the stagger - 1st share :00, 2nd :10, ... The optional stem
# names the permissions cache under cache\, for shares whose cache already exists.
SHARE=CompanyData:00
'@
try {
Set-Content -LiteralPath $Script:CfgPath -Value $template -Encoding UTF8
}
catch {
Write-Fail "ERROR: could not write $Script:CfgPath : $($_.Exception.Message)"
return
}
Write-Host "Template written: $Script:CfgPath"
Write-Host 'Every value is an example - edit them for this environment, list your'
Write-Host 'shares in run order, then:'
Write-Host " .\$Script:ScriptName check"
}
#================================================================= helpers ===
function Test-Elevated {
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [System.Security.Principal.WindowsPrincipal]::new($identity)
$principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Assert-Elevated {
if (-not (Test-Elevated)) { throw "`"$Action`" needs an elevated PowerShell prompt." }
}
function Get-TaskPath { "\$($Cfg.TaskFolder)\" }
function Get-TaskLeaf {
param([string] $Suffix)
"$($Cfg.TaskPrefix)$Suffix"
}
function Get-AccountPassword {
if ($Cfg.IsManaged) { return $null }
if ($null -eq $Script:Password) {
$secure = Read-Host -Prompt "Password for $($Cfg.Account)" -AsSecureString
$Script:Password = [System.Net.NetworkCredential]::new('', $secure).Password
if (-not $Script:Password) { throw 'no password was entered.' }
}
$Script:Password
}
# The task action always runs this script through powershell.exe. -File takes
# the arguments positionally, which is why the parameter block above declares
# them by position rather than by name.
function Get-TaskArguments {
param([string[]] $ScriptArgs)
$parts = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', "`"$Script:ScriptPath`"")
foreach ($arg in $ScriptArgs) {
$parts += $(if ($arg -match '\s') { "`"$arg`"" } else { $arg })
}
$parts -join ' '
}
# Registers one task. $Minute of $null means no trigger at all - a task that
# only ever runs on demand, which is what the probe and the token store are.
#
# A gMSA has no password anybody can type, so its principal is built with
# LogonType Password and registered without one: Windows fetches the managed
# password itself. An ordinary account is registered with -User / -Password.
function Register-SyncTask {
param(
[string] $Leaf,
[string[]] $ScriptArgs,
[object] $Minute = $null,
[int] $Hours = 1
)
$action = New-ScheduledTaskAction -Execute $Script:PSExe `
-Argument (Get-TaskArguments $ScriptArgs) `
-WorkingDirectory $Script:Dir
$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew `
-StartWhenAvailable `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-ExecutionTimeLimit ([TimeSpan]::Zero)
$register = @{
TaskName = $Leaf
TaskPath = Get-TaskPath
Settings = $settings
Action = $action
Force = $true
}
if ($null -ne $Minute) {
$register['Trigger'] = New-ScheduledTaskTrigger -Once `
-At (Get-Date).Date.AddMinutes([int] $Minute) `
-RepetitionInterval (New-TimeSpan -Hours $Hours)
}
if ($Cfg.IsManaged) {
$register['Principal'] = New-ScheduledTaskPrincipal -UserId $Cfg.Account -LogonType Password -RunLevel Limited
}
else {
$register['User'] = $Cfg.Account
$register['Password'] = Get-AccountPassword
$register['RunLevel'] = 'Limited'
}
$null = Register-ScheduledTask @register
}
function Get-SyncTask {
param([string] $Suffix)
Get-ScheduledTask -TaskPath (Get-TaskPath) -TaskName (Get-TaskLeaf $Suffix) -ErrorAction SilentlyContinue
}
function Remove-SyncTask {
param([string] $Suffix)
Unregister-ScheduledTask -TaskPath (Get-TaskPath) -TaskName (Get-TaskLeaf $Suffix) -Confirm:$false
}
# Why a helper: a native command writing to stderr surfaces as an error record,
# which under $ErrorActionPreference = 'Stop' would abort on a mere warning.
function Invoke-Cli {
param([string[]] $CliArgs)
$previous = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$output = & $Cfg.Exe @CliArgs 2>&1
$code = $LASTEXITCODE
}
catch {
$output = $_.Exception.Message
$code = 1
}
finally {
$ErrorActionPreference = $previous
}
[pscustomobject]@{
Ok = ($code -eq 0)
Code = $code
Output = @($output | ForEach-Object { [string] $_ })
}
}
function Test-Workspace {
Invoke-Cli @('test', '--server', $Cfg.Server, '--token', 'auto', '--timeout', '30')
}
#=============================================== "Log on as a batch job" ======
# Without it Task Scheduler cannot start a task as the account, and every run
# ends in "The user account does not have permission to run this task".
function Resolve-AccountSid {
param([string] $Name)
try {
([System.Security.Principal.NTAccount]::new($Name)).Translate([System.Security.Principal.SecurityIdentifier]).Value
}
catch {
$null
}
}
function Test-BatchLogonRight {
param([string] $Sid)
$export = Join-Path $env:TEMP "curiosity-sync-export-$([guid]::NewGuid().ToString('N')).inf"
try {
& secedit /export /cfg $export /areas USER_RIGHTS /quiet | Out-Null
}
catch {
return $null
}
if (-not (Test-Path -LiteralPath $export)) { return $null }
$value = ''
$match = Select-String -LiteralPath $export -Pattern '^SeBatchLogonRight\s*=' | Select-Object -First 1
if ($match) { $value = ($match.Line -split '=', 2)[1].Trim() }
Remove-Item -LiteralPath $export -Force -ErrorAction SilentlyContinue
$held = $value -split ',' | ForEach-Object { $_.Trim() }
($held -contains "*$Sid") -or ($held -contains $Cfg.Account)
}
#=========================================================== check ===========
# One command that answers "will the scheduled run work". The local half runs
# as whoever typed it; the half that matters runs as the service account,
# through a throwaway task, and needs elevation to register one.
function Invoke-Check {
$elevated = Test-Elevated
Write-Host "Checking $Script:Dir"
Write-Host " [ ok ] config valid: $Script:CfgPath"
if (Test-Path -LiteralPath $Cfg.Exe) {
Write-Host " [ ok ] CLI found: $($Cfg.Exe)"
}
else {
Write-Fail " [FAIL] CLI not found: $($Cfg.Exe)"
Write-Host ' Download it from github.com/curiosity-ai/curiosity-cli/releases, or set CLIPATH.'
}
if ($Script:Dir -match '\s') {
Write-Fail " [FAIL] this folder's path contains a space - a task argument list is easier to get wrong"
}
else {
Write-Host ' [ ok ] no spaces in the script path'
}
$smbHost = $Cfg.UncBase.TrimStart('\').Split('\')[0]
if (Test-Connection -ComputerName $smbHost -Count 1 -Quiet -ErrorAction SilentlyContinue) {
Write-Host " [ ok ] $smbHost answers"
}
else {
Write-Host " [warn] $smbHost did not answer ping (ICMP may be blocked)"
}
$sid = Resolve-AccountSid $Cfg.Account
if (-not $sid) {
Write-Fail " [FAIL] Windows could not resolve $($Cfg.Account) to a SID"
Write-Host ' Check the DOMAIN\name spelling (a gMSA needs its trailing $) and that this'
Write-Host ' machine can reach the domain.'
}
else {
$right = Test-BatchLogonRight -Sid $sid
if ($null -eq $right) {
Write-Host " [warn] could not read the user-rights policy (secedit needs elevation)"
}
elseif ($right) {
Write-Host " [ ok ] $($Cfg.Account) holds `"Log on as a batch job`""
}
else {
Write-Host " [warn] $($Cfg.Account) does not hold `"Log on as a batch job`" - every task run will"
Write-Host ' fail with 0x80070569. Grant it in secpol.msc under Local Policies >'
Write-Host ' User Rights Assignment, or - where Group Policy manages that right -'
Write-Host ' add the account to the GPO, because a local grant is overwritten on the'
Write-Host ' next policy refresh.'
}
}
if ($Cfg.IsManaged) {
Write-Host " [ -- ] $($Cfg.Account) is a group managed service account: no password anywhere. It"
Write-Host ' must be installed on this machine (Install-ADServiceAccount).'
}
else {
Write-Host " [ -- ] install prompts once for $($Cfg.Account)'s password, masked and never stored."
}
foreach ($share in $Cfg.Shares) {
if (-not (Test-Path -LiteralPath (Join-Path $Script:CacheDir "$($share.Stem).json"))) {
Write-Host " [ -- ] no permissions cache yet for $($share.Name) - the first run builds it"
}
}
Write-Host " [ -- ] $($Cfg.Shares.Count) shares configured, account $($Cfg.Account)"
# The workspace, as whoever is running this. A different identity from the
# service account, so this is information rather than a verdict.
Write-Host ''
Write-Host "Workspace $($Cfg.Server), as $env:USERDOMAIN\$env:USERNAME"
if (Test-Path -LiteralPath $Cfg.Exe) {
$own = Test-Workspace
if ($own.Ok) {
Write-Host ' [ ok ] answered with --token auto'
}
else {
Write-Host ' [warn] did not answer with --token auto. That is expected when you never stored a'
Write-Host ' token yourself - what the scheduled run needs is the one below.'
$own.Output | Select-Object -First 5 | ForEach-Object { Write-Host " $_" }
}
}
else {
Write-Host ' [ -- ] skipped: the CLI is missing'
}
# The half that decides whether the tasks will work.
Write-Host ''
Write-Host "As $($Cfg.Account), through a throwaway scheduled task"
if (-not $elevated) {
Write-Host ' [ -- ] skipped: registering that task needs an elevated PowerShell prompt.'
Write-Host " Re-run .\$Script:ScriptName check elevated to test the shares, the folders"
Write-Host ' and the token as the account the tasks actually use.'
}
else {
Invoke-ServiceAccountProbe
}
Write-Host ''
if ($Script:ExitCode -eq 0) {
Write-Host 'Checks passed.'
}
else {
Write-Host 'Problems found - fix them before installing.'
}
}
#=============================================== self-test, as the account ====
function Invoke-ServiceAccountProbe {
Remove-Item -LiteralPath $Script:ProbeOut, $Script:ProbeAlt -Force -ErrorAction SilentlyContinue
$leaf = Get-TaskLeaf '_probe'
try {
Register-SyncTask -Leaf $leaf -ScriptArgs @('probe')
}
catch {
Write-Fail " [FAIL] could not register the probe task: $($_.Exception.Message)"
Write-Host ' Wrong password, no "Log on as a batch job" right, or - for a gMSA - it is'
Write-Host ' not installed on this machine.'
return
}
try {
Start-ScheduledTask -TaskPath (Get-TaskPath) -TaskName $leaf
}
catch {
Write-Fail " [FAIL] could not start the probe task: $($_.Exception.Message)"
Remove-SyncTask '_probe'
return
}
$found = $null
foreach ($attempt in 1..60) {
if (Test-Path -LiteralPath $Script:ProbeOut) { $found = $Script:ProbeOut; break }
if (Test-Path -LiteralPath $Script:ProbeAlt) { $found = $Script:ProbeAlt; break }
Start-Sleep -Seconds 2
}
if (-not $found) {
Write-Fail ' [FAIL] the probe produced no result within 120s.'
$info = Get-ScheduledTaskInfo -TaskPath (Get-TaskPath) -TaskName $leaf -ErrorAction SilentlyContinue
if ($info) { Write-Host (" last task result: 0x{0:X8}" -f $info.LastTaskResult) }
Remove-SyncTask '_probe'
return
}
Remove-SyncTask '_probe'
if ($found -eq $Script:ProbeAlt) {
Write-Fail " [FAIL] the result came from $Script:ProbeAlt : $($Cfg.Account) cannot write to $Script:Dir."
Write-Host ' The permission caches and logs live there, so the real run would fail too.'
}
foreach ($line in Get-Content -LiteralPath $found) {
if ($line.StartsWith('FAIL')) { Write-Fail " [FAIL] $($line.Substring(4).Trim())" }
elseif ($line.StartsWith('OK')) { Write-Host " [ ok ] $($line.Substring(2).Trim())" }
else { Write-Host " [ -- ] $line" }
}
Remove-Item -LiteralPath $Script:ProbeOut, $Script:ProbeAlt -Force -ErrorAction SilentlyContinue
}
# The probe itself: this part runs AS the service account.
function Invoke-Probe {
$out = $Script:ProbeOut
try {
Set-Content -LiteralPath $out -Value "identity=$env:USERDOMAIN\$env:USERNAME" -ErrorAction Stop
}
catch {
$out = $Script:ProbeAlt
Set-Content -LiteralPath $out -Value "identity=$env:USERDOMAIN\$env:USERNAME"
}
foreach ($pair in @(@{ Path = $Script:CacheDir; Label = 'cache' }, @{ Path = $Script:LogDir; Label = 'log' })) {
$probeFile = Join-Path $pair.Path '.probe'
try {
$null = New-Item -ItemType Directory -Path $pair.Path -Force -ErrorAction Stop
Set-Content -LiteralPath $probeFile -Value 'x' -ErrorAction Stop
Remove-Item -LiteralPath $probeFile -Force -ErrorAction SilentlyContinue
Add-Content -LiteralPath $out -Value "OK $($pair.Label) folder writable"
}
catch {
Add-Content -LiteralPath $out -Value "FAIL $($pair.Label) folder NOT writable"
}
}
# The one check that exercises the token, the network path and the workspace
# at once: "test" runs a small query and fails on a token this account cannot read.
if (-not (Test-Path -LiteralPath $Cfg.Exe)) {
Add-Content -LiteralPath $out -Value "FAIL CLI not found: $($Cfg.Exe)"
}
else {
$test = Test-Workspace
if ($test.Ok) {
Add-Content -LiteralPath $out -Value "OK $($Cfg.Server) answered with --token auto"
}
else {
Add-Content -LiteralPath $out -Value "FAIL $($Cfg.Server) did not answer with --token auto:"
$test.Output | Select-Object -First 5 | ForEach-Object { Add-Content -LiteralPath $out -Value " $_" }
}
}
foreach ($share in $Cfg.Shares) {
$path = Join-Path $Cfg.UncBase $share.Name
if (Test-Path -LiteralPath $path) {
Add-Content -LiteralPath $out -Value "OK share readable: $($share.Name)"
}
else {
Add-Content -LiteralPath $out -Value "FAIL share NOT readable: $path"
}
}
}
#=================================================================== token ===
# The token is encrypted into the running account's own profile and keyed by
# the server it was stored for, so it has to be stored AS the service account -
# which cannot log on interactively. A throwaway task is the way in. The token
# is in that task's action until it is deleted a second later, where only
# administrators can read it.
function Invoke-StoreToken {
Assert-Elevated
if (-not $Argument) {
Write-Fail "ERROR: usage: .\$Script:ScriptName store-token <library-token>"
Write-Host ' Create one in the workspace under Manage > Tokens > Library.'
return
}
Write-Host "Storing a token for $($Cfg.Server)"
Write-Host "as $($Cfg.Account), via a throwaway scheduled task."
Write-Host ''
$leaf = Get-TaskLeaf '_store-token'
try {
Register-SyncTask -Leaf $leaf -ScriptArgs @('store-token-now', $Argument)
}
catch {
Write-Fail " [FAIL] could not register the task: $($_.Exception.Message)"
Write-Host " See .\$Script:ScriptName help, ACCOUNT RIGHTS."
return
}
try {
Start-ScheduledTask -TaskPath (Get-TaskPath) -TaskName $leaf
Start-Sleep -Seconds 15
$info = Get-ScheduledTaskInfo -TaskPath (Get-TaskPath) -TaskName $leaf -ErrorAction SilentlyContinue
if ($info -and $info.LastTaskResult -ne 0) {
Write-Fail (" [FAIL] the task ended with 0x{0:X8} - the token was not stored." -f $info.LastTaskResult)
Write-Host ' 0x80070569 means the account lacks "Log on as a batch job".'
return
}
}
finally {
try { Remove-SyncTask '_store-token' } catch { Write-Host " [warn] could not remove $leaf : $($_.Exception.Message)" }
}
Write-Host "Token stored for $($Cfg.Server)."
Write-Host "Confirm it with: .\$Script:ScriptName check"
}
# The scheduled-task entry point for "store-token". The token is stored against
# SERVER, which is what --token auto looks it up by on every later run.
function Invoke-StoreTokenNow {
$result = Invoke-Cli @('store-token', '--server', $Cfg.Server, '--token', $Argument)
if (-not $result.Ok) { $Script:ExitCode = $result.Code }
}
#============================================================ task control ===
function Invoke-Install {
Assert-Elevated
Write-Host "Registering tasks as $($Cfg.Account)."
if ($Cfg.IsManaged) {
Write-Host 'Group managed service account - registered without a password, Windows supplies it.'
}
else {
Write-Host 'The password is asked for once and kept in memory for this run only.'
}
Write-Host ''
$minute = 0
foreach ($share in $Cfg.Shares) {
try {
Register-SyncTask -Leaf (Get-TaskLeaf $share.Name) `
-ScriptArgs @('run', $share.Name) `
-Minute $minute `
-Hours $Cfg.EveryHours
Write-Host (" [ ok ] {0} at :{1:d2} past every {2}h" -f $share.Name, $minute, $Cfg.EveryHours)
}
catch {
Write-Fail " [FAIL] $($share.Name): $($_.Exception.Message)"
if ($Cfg.IsManaged) {
Write-Host ' For a gMSA that almost always means it is not installed on this machine:'
Write-Host " Install-ADServiceAccount $($Cfg.Account) (RSAT, as a domain admin, without the domain prefix)"
}
Write-Host " It also needs `"Log on as a batch job`" - .\$Script:ScriptName check reports that."
}
$minute += $Cfg.Stagger
}
Write-Host ''
Invoke-Status
}
function Invoke-Uninstall {
Assert-Elevated
foreach ($share in $Cfg.Shares) {
if (-not (Get-SyncTask $share.Name)) {
Write-Host " skipped $($share.Name) - not registered"
continue
}
try {
Remove-SyncTask $share.Name
Write-Host " removed $($share.Name)"
}
catch {
Write-Fail " [FAIL] $($share.Name): $($_.Exception.Message)"
}
}
}
function Set-TaskState {
param([ValidateSet('Enable', 'Disable')] [string] $State)
foreach ($share in $Cfg.Shares) {
$task = Get-SyncTask $share.Name
if (-not $task) {
Write-Fail " [FAIL] $($share.Name) - not registered, or access denied"
continue
}
try {
if ($State -eq 'Enable') {
$null = Enable-ScheduledTask -InputObject $task
Write-Host " enabled $($share.Name)"
}
else {
$null = Disable-ScheduledTask -InputObject $task
Write-Host " disabled $($share.Name)"
}
}
catch {
Write-Fail " [FAIL] $($share.Name): $($_.Exception.Message)"
}
}
}
function Invoke-Start {
Assert-Elevated
Set-TaskState -State Enable
Write-Host ''
Invoke-Status
}
function Invoke-Stop {
Assert-Elevated
Set-TaskState -State Disable
Write-Host ''
Write-Host 'Waiting for the run in flight to finish. Ctrl+C leaves it running.'
foreach ($attempt in 1..40) {
if (-not (Get-Process -Name 'curiosity-cli' -ErrorAction SilentlyContinue)) {
Write-Host ''
Write-Host 'Sync stopped.'
return
}
Start-Sleep -Seconds 15
}
Write-Host ' Still running after 10 min - the process was left alone.'
Write-Host " Run .\$Script:ScriptName force if you need it down now."
$Script:ExitCode = 2
}
function Invoke-Force {
Assert-Elevated
Set-TaskState -State Disable
Write-Host ''
Write-Host 'Force-stopping...'
foreach ($share in $Cfg.Shares) {
$task = Get-SyncTask $share.Name
if (-not $task) { continue }
try {
Stop-ScheduledTask -InputObject $task
}
catch {
Write-Host " [warn] could not end $($share.Name)'s task: $($_.Exception.Message)"
}
}
$running = Get-Process -Name 'curiosity-cli' -ErrorAction SilentlyContinue
if (-not $running) {
Write-Host ' no curiosity-cli.exe was running'
}
else {
try {
$running | Stop-Process -Force
Write-Host ' curiosity-cli.exe killed'
}
catch {
Write-Fail " [FAIL] could not kill curiosity-cli.exe: $($_.Exception.Message)"
}
}
Write-Host ''
Write-Host 'WARNING: a hard kill can leave a permissions cache half-written. That share'
Write-Host ' rebuilds its cache on the next run, which is slower but not harmful.'
Write-Host ''
Write-Host 'Sync stopped.'
}
# SCHED_S_* informational results: ready, running, not yet run, and so on.
# A freshly registered task reports 0x00041303, which install would otherwise
# print as a failure the moment it succeeds.
$Script:InformationalResults = @(0x00041300, 0x00041301, 0x00041302, 0x00041303, 0x00041304, 0x00041305)
function Invoke-Status {
Write-Host 'Share State Last Next run'
Write-Host '-------------------------------------------------------------------------'
foreach ($share in $Cfg.Shares) {
$state = '?'
$last = '-'
$next = 'not registered'
$task = Get-SyncTask $share.Name
if ($task) {
try {
$info = Get-ScheduledTaskInfo -InputObject $task
$state = [string] $task.State
$last = '0x{0:X8}' -f $info.LastTaskResult
$next = $(if ($info.NextRunTime) { $info.NextRunTime.ToString('yyyy-MM-dd HH:mm') } else { 'none scheduled' })
if ($info.LastTaskResult -ne 0 -and $Script:InformationalResults -notcontains $info.LastTaskResult) {
$Script:ExitCode = 1
}
}
catch {
$state = 'error'
$next = $_.Exception.Message
$Script:ExitCode = 1
}
}
Write-Host ('{0,-22} {1,-12} {2,-11} {3}' -f $share.Name, $state, $last, $next)
}
}
#==================================================================== logs ===
# One log per share per DAY. An append-only log is touched every run, so it
# would never age out - rotating by day is what makes age-based pruning work.
function Remove-OldLogs {
param([switch] $Quiet)
if (-not (Test-Path -LiteralPath $Script:LogDir)) {
if (-not $Quiet) { Write-Host "No log folder yet: $Script:LogDir" }
return
}
$cutoff = (Get-Date).AddDays(-$Cfg.LogKeepDays)
$stale = @(Get-ChildItem -LiteralPath $Script:LogDir -Filter '*.log' -File |
Where-Object { $_.LastWriteTime -lt $cutoff })
if (-not $Quiet) {
Write-Host "Deleting logs in $Script:LogDir not modified for $($Cfg.LogKeepDays) days..."
}
foreach ($file in $stale) {
try {
Remove-Item -LiteralPath $file.FullName -Force
if (-not $Quiet) { Write-Host " deleted $($file.Name)" }
}
catch {
if (-not $Quiet) { Write-Fail " [FAIL] $($file.Name): $($_.Exception.Message)" }
}
}
if ($Quiet) { return }
if (-not $stale) { Write-Host ' nothing to delete' }
Write-Host ''
Write-Host 'Remaining:'
Get-ChildItem -LiteralPath $Script:LogDir -Filter '*.log' -File |
Sort-Object LastWriteTime -Descending |
ForEach-Object { Write-Host " $($_.Name)" }
}
#==================================================================== sync ===
function Invoke-Run {
if (-not $Argument) {
Write-Fail "ERROR: run needs a share name, e.g. .\$Script:ScriptName run Engineering"
return
}
$share = $Cfg.Shares | Where-Object { $_.Name -ieq $Argument } | Select-Object -First 1
if (-not $share) {
Write-Fail "ERROR: unknown share `"$Argument`". Configured: $(($Cfg.Shares | ForEach-Object Name) -join ', ')"
return
}
if (-not (Test-Path -LiteralPath $Cfg.Exe)) {
Write-Fail "ERROR: CLI not found: $($Cfg.Exe)"
Write-Host ' Download it from github.com/curiosity-ai/curiosity-cli/releases, or set CLIPATH.'
return
}
try {
$null = New-Item -ItemType Directory -Path $Script:LogDir -Force
$null = New-Item -ItemType Directory -Path $Script:CacheDir -Force
}
catch {
Write-Fail "ERROR: cannot create the logs and cache folders under $Script:Dir : $($_.Exception.Message)"
return
}
$path = Join-Path $Cfg.UncBase $share.Name
$log = Join-Path $Script:LogDir ("{0}-{1}.log" -f $share.Name, (Get-Date -Format 'yyyy-MM-dd'))
$cliArgs = @(
'upload-folder-with-permissions'
'--server', $Cfg.Server
'--token', 'auto'
'--source', $Cfg.Source
'--path', $path
'--root-path', $path
'--root-folder-name', "/$($Cfg.Source)/$($share.Name)/"
'--permissions-cache', (Join-Path $Script:CacheDir "$($share.Stem).json")
'--sync-file-url'
'--fetch-server-state', 'true'
)
if ($Cfg.InPlace) { $cliArgs += '--in-place' }
if ($Cfg.Extensions) { $cliArgs += @('--extensions', $Cfg.Extensions) }
if ($Cfg.Bandwidth) { $cliArgs += @('--bandwidth', $Cfg.Bandwidth) }
if ($Cfg.Timeout) { $cliArgs += @('--timeout', $Cfg.Timeout) }
Write-Host "$($share.Name): $path -> $($Cfg.Server)"
Write-Host "Log: $log"
Write-Host ''
# One writer for the whole run, so the markers and the CLI's own output land
# in the file with one encoding, and each line is echoed as it arrives - a
# run started by hand shows its progress and its errors instead of going
# silent until it is over.
$writer = [System.IO.StreamWriter]::new($log, $true)
$started = Get-Date
$failure = $null
$code = 0
$elapsed = '00:00:00'
$previous = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
Push-Location -LiteralPath $Script:Dir
try {
$writer.WriteLine('')
$writer.WriteLine(("===== {0} start {1} =====" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $share.Name))
$writer.Flush()
& $Cfg.Exe @cliArgs 2>&1 | ForEach-Object {
$line = [string] $_
Write-Host $line
$writer.WriteLine($line)
}
$code = $LASTEXITCODE
}
catch {
$code = 1
$failure = $_.Exception.Message
}
finally {
Pop-Location
$ErrorActionPreference = $previous
$elapsed = '{0:hh\:mm\:ss}' -f ((Get-Date) - $started)
if ($failure) { $writer.WriteLine("ERROR $failure") }
$writer.WriteLine(("===== {0} end {1} exit={2} elapsed={3} =====" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $share.Name, $code, $elapsed))
$writer.Dispose()
}
Write-Host ''
if ($code -eq 0) {
Write-Host " [ ok ] $($share.Name) finished in $elapsed"
}
else {
if ($failure) { Write-Host " $failure" }
Write-Host " [FAIL] $($share.Name) exited with $code after $elapsed"
Write-Host " What the CLI said is above, and in $log"
}
$Script:ExitCode = $code
# Self-maintaining: every run also ages out the old logs.
Remove-OldLogs -Quiet
}
#==================================================================== help ===
function Invoke-Help {
@"
curiosity-sync.ps1 - scheduled network-share sync for the Curiosity CLI
RUNNING IT
From an elevated PowerShell prompt: .\curiosity-sync.ps1 <action>
Where the execution policy blocks that:
powershell -NoProfile -ExecutionPolicy Bypass -File curiosity-sync.ps1 <action>
The registered tasks always pass -ExecutionPolicy Bypass themselves, so the
machine's policy does not have to be relaxed for the scheduled runs.
Every action prints what failed and exits non-zero, so a scheduled run
records a result that "status" can show.
SETUP
1. Download curiosity-cli.exe from github.com/curiosity-ai/curiosity-cli/releases
to a fixed, space-free path every account can read, e.g.
E:\CuriosityCLI\curiosity-cli.exe
Put this script in that folder, or set CLIPATH in the config.
2. .\curiosity-sync.ps1 init writes curiosity-sync.config.txt, then edit it:
SERVER workspace URL - the token is stored against it
SOURCE source label, and the first folder in the workspace
UNCBASE UNC root, e.g. \\your-fileserver
ACCOUNT DOMAIN\account the tasks run as; a gMSA ends in $
TASKFOLDER Task Scheduler folder for the tasks
TASKPREFIX task-name prefix
STAGGER minutes between share start times
EVERYHOURS hours between runs
LOGKEEPDAYS days of logs to keep
INPLACE true to index the files in place, false to copy them in
SHARE one line per share, in run order
3. .\curiosity-sync.ps1 store-token <t> store the Library Token for SERVER,
as ACCOUNT.
4. .\curiosity-sync.ps1 check everything: the config, the CLI, the
logon right, and - elevated - the
shares, folders and token AS ACCOUNT.
5. .\curiosity-sync.ps1 install register the tasks.
ACCOUNT RIGHTS
The account needs: read on every share (both the share permission and the
NTFS permission), read on the security descriptors, the ability to query
Active Directory, write in this folder (logs and caches), and the
"Log on as a batch job" right. "check" warns when it does not hold that
right; grant it in secpol.msc under Local Policies > User Rights
Assignment, or through Group Policy where a GPO manages it - a local grant
is overwritten on the next policy refresh. A gMSA needs the same, plus
Install-ADServiceAccount on this machine.
SECRETS
Nothing here holds one. The Library Token is encrypted into the service
account's own profile by "store-token", keyed by SERVER, and used with
--token auto; its protection is the ACL on that profile. The account's
password is typed at the install prompt and kept in memory for that run
only, or held by Active Directory for a gMSA.
GROUP MANAGED SERVICE ACCOUNTS
Write ACCOUNT as DOMAIN\name$ - Windows holds the password and nobody can
type it, so there is no prompt. Those tasks are registered with a principal
whose LogonType is Password and no password supplied, which is how Windows
is told to fetch the managed one. The account still needs "Log on as a batch
job", read on the shares, and write in this folder - and the gMSA has to be
installed on this machine first with Install-ADServiceAccount.
LOGS
logs\<share>-<date>.log - one file per share per day, with start and end
timestamps, the elapsed time and the CLI exit code. A run started by hand
also prints the CLI's output as it arrives. Files not modified for
LOGKEEPDAYS days are deleted after every run, so this needs no cleanup task
of its own. To do it by hand: .\curiosity-sync.ps1 prune
"@ | Write-Host
}
#=================================================================== main ====
try {
switch ($Action) {
'init' { Invoke-Init; break }
'help' { Invoke-Help; break }
default {
$Script:Cfg = Read-SyncConfig
switch ($Action) {
'check' { Invoke-Check }
'store-token' { Invoke-StoreToken }
'store-token-now' { Invoke-StoreTokenNow }
'probe' { Invoke-Probe }
'install' { Invoke-Install }
'uninstall' { Invoke-Uninstall }
'start' { Invoke-Start }
'stop' { Invoke-Stop }
'force' { Invoke-Force }
'status' { Invoke-Status }
'run' { Invoke-Run }
'prune' { Remove-OldLogs }
}
}
}
}
catch {
Write-Host "ERROR: $($_.Exception.Message)"
$Script:ExitCode = 1
}
exit $Script:ExitCode
The configuration file
.\curiosity-sync.ps1 init writes this file next to the script:
# curiosity-sync configuration. KEY=value, no quotes, no trailing spaces.
# Holds no secret: the token is stored per-account by ".\curiosity-sync.ps1 store-token".
#
# Workspace URL. The CLI normalises it to end in /api/, and the token is
# stored against it - change it and the token has to be stored again.
SERVER=http://localhost:8080/
# Source label written on every file entry, and the first folder in the workspace.
SOURCE=CompanyData
# UNC root the shares live under, without a trailing backslash.
UNCBASE=\\CORPSERVER.corp.example.com\
# DOMAIN\account the tasks run as. A group managed service account is DOMAIN\name$.
ACCOUNT=CORP\svc-curiosity$
# Task Scheduler folder and task-name prefix.
TASKFOLDER=CuriosityCLI
TASKPREFIX=CuriosityCLI-Sync-
# minutes between share start times, and hours between runs
STAGGER=10
EVERYHOURS=1
# delete logs not modified for this many days
LOGKEEPDAYS=7
# true: index the files where they are - the workspace stores only their URL,
# and needs its own read access to the share.
# false: copy the file contents into the workspace.
INPLACE=false
# optional: full path to curiosity-cli.exe, when it is not next to this script
#CLIPATH=E:\CuriosityCLI\curiosity-cli.exe
# optional: only these extensions, separated by ;
#EXTENSIONS=pdf;doc;docx;xlsx
# optional: cap the read rate, in Mbps
#BANDWIDTH=100
# optional: per-request timeout in seconds (the CLI default is 90)
#TIMEOUT=300
# One line per share, in run order: SHARE=<folder>[:<cache-file-stem>]
# The order sets the stagger - 1st share :00, 2nd :10, ... The optional stem
# names the permissions cache under cache\, for shares whose cache already exists.
SHARE=CompanyData:00
Every value is an example
The template is a working shape, not a working config — it points at localhost and a fictional file server. check validates that the keys are present and parse, not that they are yours, so edit every value before install.
These are the keys.
| Key | Description |
|---|---|
SERVER |
Workspace URL. The CLI normalizes it to end in /api/, so either form works. The token is stored against it, so changing it means storing the token again. |
SOURCE |
The --source label written on every file entry, and the first folder in the workspace path. |
UNCBASE |
UNC root the shares live under, e.g. \\fileserver, without a trailing backslash. |
ACCOUNT |
DOMAIN\account the tasks run as. A gMSA is written DOMAIN\name$. |
TASKFOLDER |
Task Scheduler folder the tasks are created in. |
TASKPREFIX |
Prefix for the task names; the share name is appended. |
STAGGER |
Minutes between two shares' start times. |
EVERYHOURS |
Hours between runs of one share. |
LOGKEEPDAYS |
Delete logs not modified for this many days. Default 7. |
INPLACE |
true adds --in-place; false copies file contents into the workspace. Default true. |
CLIPATH |
Full path to curiosity-cli.exe. Default: next to the script. |
EXTENSIONS |
Optional --extensions list, separated by ;. |
BANDWIDTH |
Optional --bandwidth cap, in Mbps. |
TIMEOUT |
Optional --timeout, in seconds. The CLI default is 90. |
SHARE |
One line per share, in run order: SHARE=<folder> — or SHARE=<folder>:<stem> to name its cache file, for a share whose cache already exists under another name. |
The order of the SHARE lines sets the stagger: the first starts at :00, the second at :{STAGGER}, and so on.
Setting it up
Write and edit the config
From an elevated PowerShell prompt:
Set-Location E:\CuriosityCLI
.\curiosity-sync.ps1 init
notepad curiosity-sync.config.txt
Where the execution policy blocks a local script, either allow it once
(Set-ExecutionPolicy -Scope Process RemoteSigned) or invoke it as
powershell -NoProfile -ExecutionPolicy Bypass -File .\curiosity-sync.ps1 init.
The scheduled tasks are unaffected either way — they carry -ExecutionPolicy Bypass
themselves.
Store the token as the service account
Create a Library Token in the workspace under Manage → Tokens → Library, ideally for a dedicated service user with minimal rights, then:
.\curiosity-sync.ps1 store-token eyJhbGciOi...
store-token writes into the running account's profile and keys the token by the server it was stored for, and the service account cannot log on interactively — so the script registers a throwaway task, runs it as that account, and deletes it. The token is in that task's action for the few seconds it exists, where only administrators can read it. Change SERVER later and the token has to be stored again.
It reports the task's own result, so a store that never ran — usually a missing logon right — is a failure here rather than a surprise on the first sync.
Check everything
.\curiosity-sync.ps1 check
Two halves. The local one runs as you: the config parses, the CLI is where CLIPATH says, the path has no spaces, the file server answers, the account resolves to a SID and holds Log on as a batch job, and which shares have no permissions cache yet.
The half that decides whether the tasks will work runs as the service account, through the same throwaway-task trick store-token uses, and so needs the elevated prompt. It reports, as that account: whether the log and cache folders are writable, whether every share is readable, and whether curiosity-cli test --token auto gets an answer from the workspace — the one check that exercises the token, the network path and the workspace at once. Run without elevation, check says which part it skipped rather than implying it passed.
A probe result that arrives from %WINDIR%\Temp instead of the script folder means the account cannot write there, which the real run needs.
Register the tasks
.\curiosity-sync.ps1 install
.\curiosity-sync.ps1 status
install registers one task per share and then prints the status table. For an ordinary account it prompts once for the password, masked, and keeps it in memory for that run only; a gMSA has no prompt, because Windows supplies the managed password itself.
What one run does
.\curiosity-sync.ps1 run <share> is the scheduled-task entry point. For SHARE=Engineering with SOURCE=fileserver it resolves to:
curiosity-cli upload-folder-with-permissions `
--server https://my-workspace.example.com/ `
--token auto `
--source fileserver `
--path \\fileserver\Engineering `
--root-path \\fileserver\Engineering `
--root-folder-name /fileserver/Engineering/ `
--permissions-cache E:\CuriosityCLI\cache\Engineering.json `
--sync-file-url `
--fetch-server-state true --in-place
--root-path and --root-folder-name are what put the share's content under /fileserver/Engineering/ in the workspace rather than under the UNC path it came from — see Layout in the workspace. --fetch-server-state true is what makes a file deleted on the share disappear from the workspace on the next run.
Everything the CLI writes goes to logs\<share>-<date>.log, framed by a start and end line carrying the exit code — 0 success, 1 a command-line error, 2 the command threw, with the exception in the log above it.
A run that is still going when the next trigger fires is not doubled up: the task is registered with -MultipleInstances IgnoreNew, which is also the Task Scheduler's own default, so the new instance is skipped and that share picks up at the following trigger. If a share regularly overruns its interval, raise EVERYHOURS for it rather than letting every trigger be skipped.
Group managed service accounts
A gMSA is a domain account whose password Active Directory generates and rotates itself. It is the way to run this with no password anywhere: not in the config, not in a prompt, not in the task definition.
Write it in the config with its trailing $ and the script does the rest:
ACCOUNT=CORP\svc-curiosity$
One thing then works differently, handled by Register-SyncTask: a gMSA cannot be registered with a password, because it has none anybody can type. So instead of -User / -Password, the task is built with a principal of its own —
New-ScheduledTaskPrincipal -UserId $Cfg.Account -LogonType Password -RunLevel Limited
— and registered with no password supplied. LogonType Password with nothing to supply is exactly how Windows is told to fetch the managed password itself.
The account still needs everything in Prerequisites — read on the shares, write in the script folder, and Log on as a batch job — plus the gMSA itself installed on this machine:
Install-ADServiceAccount svc-curiosity # RSAT, as a domain admin, no domain prefix
Test-ADServiceAccount svc-curiosity # must return True
.\curiosity-sync.ps1 check, run elevated, is what proves all of it at once.
Day to day
| Command | Does |
|---|---|
.\curiosity-sync.ps1 |
The status table: state, last result and next run per share. Also the default action. |
.\curiosity-sync.ps1 stop |
Disables every task, then waits up to 10 minutes for the run in flight to finish. |
.\curiosity-sync.ps1 force |
Disables every task and kills curiosity-cli.exe now. A hard kill can leave a permissions cache half-written; that share rebuilds it on the next run. |
.\curiosity-sync.ps1 start |
Re-enables every task. |
.\curiosity-sync.ps1 run <share> |
One pass over one share, in the foreground. It runs as you, not as the service account, so it reads the share and resolves the ACLs with your access. The CLI's output is echoed as it arrives and the run ends in an [ ok ] or [FAIL] line carrying the exit code — but check is what tells you whether the task will work. |
.\curiosity-sync.ps1 prune |
Deletes logs older than LOGKEEPDAYS and lists what is left. Every scheduled run does this too. |
.\curiosity-sync.ps1 uninstall |
Removes the tasks. Nothing else is touched — the caches, logs and config stay. |
Adding a share is two steps: add its SHARE= line, re-run install. Removing one is uninstall, remove the line, install again — uninstall reads the config, so deleting the line first leaves its task orphaned in the Task Scheduler.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Last result 0x80070569 / 2147943785, or The user account does not have permission to run this task |
The account lacks Log on as a batch job, which check warns about. Grant it in secpol.msc or through the GPO that manages it. |
install fails with The user name or password is incorrect |
Wrong password, or a gMSA written without its trailing $ — without it the script takes the password path, which cannot register a gMSA. |
.\curiosity-sync.ps1 is opened in an editor, or cmd prints that the script cannot run |
It was saved as .bat or .cmd. Save it as .ps1 and run it from PowerShell; the guard at the top of the file says the same. |
| running scripts is disabled on this system | The execution policy blocks a local script. Run powershell -NoProfile -ExecutionPolicy Bypass -File .\curiosity-sync.ps1 <action>, or allow it for the session with Set-ExecutionPolicy -Scope Process RemoteSigned. The registered tasks are unaffected — they pass -ExecutionPolicy Bypass themselves. |
Can't find token for url: … in the log |
The token was stored under a different account's profile. Re-run .\curiosity-sync.ps1 store-token, which stores it as the service account. |
The workspace rejects the call and the log shows auto being sent as the token |
An older CLI build only read the stored token inside store-token itself. Download a newer curiosity-cli.exe from the releases page and replace the file. |
CLIPATH points at a dotnet-tool install and the task reports success while nothing syncs, or .\curiosity-sync.ps1 force finds no process to kill |
A dotnet-tool install runs the CLI through dotnet, so there is no curiosity-cli process for force to find. Use the downloaded executable. |
check reports the probe result came from %WINDIR%\Temp |
The service account cannot write to the script folder, so the permissions cache and logs have nowhere to go. |
Access is denied reading a share |
Check both the share permission and the NTFS ACL, and that the path is a UNC path — a mapped drive letter belongs to one interactive logon and is invisible to a task. |
NotImplementedException: Only the windows build of the CLI supports permission sync |
The permissioned commands need the Windows build of the CLI. |
| Every run re-resolves every SID and takes as long as the first | The --permissions-cache path changed, or the file was deleted. Keep cache\<share>.json stable. |
A share's task shows a last result of 2 |
The command threw; the exception is in logs\<share>-<date>.log above the end line. |
| Tasks are registered but never run | They were disabled — .\curiosity-sync.ps1 start re-enables them. |
On Linux
The permissioned commands are Windows-only, but the same shape works for upload-folder on a mounted share: one systemd timer (or cron entry) per share, staggered, each running one pass and logging it. --token auto behaves the same — store-token writes to $XDG_CONFIG_HOME/.mosaik.cli.config for the user the unit runs as, so store it as that user.
See also
upload-folder-with-permissions— the command being scheduled, and every option it takes.monitor-with-permissions— the always-on alternative, and how to run it as a service.store-token— how--token autofinds a token, and where it is kept.- Service account — creating a domain account or gMSA for Curiosity on Windows, including the gMSA troubleshooting.
- Access control — how the workspace stores the ACLs this ingests.